Initial commit
This commit is contained in:
+486
@@ -0,0 +1,486 @@
|
||||
import math
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db.models import Avg
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ExamSet, Section, StudentAnswer, StudentAttempt
|
||||
from .grading import academic_reading_band
|
||||
from accounts.access import can_access_exam, has_lifetime_premium
|
||||
from accounts.models import StudentProfile
|
||||
|
||||
|
||||
SUBMISSION_GRACE_SECONDS = 15
|
||||
MAX_AUDIO_RESPONSE_SIZE = 20 * 1024 * 1024
|
||||
AUDIO_RESPONSE_VALIDATOR = FileExtensionValidator(["mp3", "m4a", "wav", "webm", "ogg"])
|
||||
|
||||
|
||||
def activate_section(attempt, section, now=None):
|
||||
"""Start a section once and persist its authoritative server deadline."""
|
||||
if attempt.current_section_id == section.id and attempt.section_deadline:
|
||||
return
|
||||
|
||||
now = now or timezone.now()
|
||||
attempt.current_section = section
|
||||
attempt.section_started_at = now
|
||||
attempt.section_deadline = now + timedelta(minutes=section.time_limit_minutes)
|
||||
attempt.save(
|
||||
update_fields=["current_section", "section_started_at", "section_deadline"]
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def exam_list(request):
|
||||
status_filter = request.GET.get("status", "all")
|
||||
skill_filter = request.GET.get("skill", "all")
|
||||
valid_filters = {"all", "not_started", "in_progress", "completed"}
|
||||
valid_skills = {"all", "reading", "listening", "writing", "speaking", "full"}
|
||||
if status_filter not in valid_filters:
|
||||
status_filter = "all"
|
||||
if skill_filter not in valid_skills:
|
||||
skill_filter = "all"
|
||||
|
||||
published_exams = ExamSet.objects.filter(is_published=True)
|
||||
user_has_premium = has_lifetime_premium(request.user)
|
||||
skill_counts = {
|
||||
category: published_exams.filter(category=category).count()
|
||||
for category in ("reading", "listening", "writing", "speaking", "full")
|
||||
}
|
||||
all_total = published_exams.count()
|
||||
if skill_filter != "all":
|
||||
published_exams = published_exams.filter(category=skill_filter)
|
||||
exam_sets = published_exams.prefetch_related("sections__questions")
|
||||
latest_attempts = {}
|
||||
for attempt in StudentAttempt.objects.filter(student=request.user).order_by("-started_at"):
|
||||
latest_attempts.setdefault(attempt.exam_set_id, attempt)
|
||||
|
||||
exam_data = []
|
||||
counts = {"not_started": 0, "in_progress": 0, "completed": 0}
|
||||
|
||||
for exam_set in exam_sets:
|
||||
sections = list(exam_set.sections.all())
|
||||
is_exact = exam_set.delivery_mode == "exact_html"
|
||||
attempt = latest_attempts.get(exam_set.id)
|
||||
if attempt is None:
|
||||
status = "not_started"
|
||||
elif attempt.is_complete:
|
||||
status = "completed"
|
||||
else:
|
||||
status = "in_progress"
|
||||
|
||||
counts[status] += 1
|
||||
score = None
|
||||
if status == "completed":
|
||||
answers = attempt.answers.exclude(is_correct=None)
|
||||
total = answers.count()
|
||||
correct = answers.filter(is_correct=True).count()
|
||||
score = f"{correct}/{total}" if total else None
|
||||
|
||||
question_orders = [question.order for section in sections for question in section.questions.all()]
|
||||
part = "all"
|
||||
if len(sections) == 1 and question_orders and exam_set.category in {"reading", "listening"}:
|
||||
first_order = min(question_orders)
|
||||
part = "p3" if first_order >= 27 else "p2" if first_order >= 14 else "p1"
|
||||
|
||||
exam_data.append(
|
||||
{
|
||||
"exam_set": exam_set,
|
||||
"status": status,
|
||||
"attempt": attempt,
|
||||
"score": score,
|
||||
"is_exact": is_exact,
|
||||
"section_count": 1 if is_exact else len(sections),
|
||||
"question_count": sum(len(section.questions.all()) for section in sections),
|
||||
"total_minutes": sum(section.time_limit_minutes for section in sections),
|
||||
"section_types": ["Original HTML"] if is_exact else [section.get_section_type_display() for section in sections],
|
||||
"part": part,
|
||||
"requires_premium": exam_set.access_level == ExamSet.ACCESS_PREMIUM,
|
||||
"is_locked": not (
|
||||
exam_set.access_level == ExamSet.ACCESS_FREE
|
||||
or user_has_premium
|
||||
or attempt is not None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if status_filter != "all":
|
||||
exam_data = [item for item in exam_data if item["status"] == status_filter]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"exams/exam_list.html",
|
||||
{
|
||||
"exam_data": exam_data,
|
||||
"counts": counts,
|
||||
"total": len(exam_sets),
|
||||
"all_total": all_total,
|
||||
"status_filter": status_filter,
|
||||
"skill_filter": skill_filter,
|
||||
"skill_counts": skill_counts,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def exam_detail(request, exam_set_id):
|
||||
exam_set = get_object_or_404(
|
||||
ExamSet.objects.prefetch_related("sections__questions"),
|
||||
id=exam_set_id,
|
||||
is_published=True,
|
||||
)
|
||||
sections = list(exam_set.sections.all())
|
||||
active_attempt = StudentAttempt.objects.filter(
|
||||
student=request.user,
|
||||
exam_set=exam_set,
|
||||
is_complete=False,
|
||||
).first()
|
||||
can_open = can_access_exam(request.user, exam_set) or active_attempt is not None
|
||||
return render(
|
||||
request,
|
||||
"exams/exam_detail.html",
|
||||
{
|
||||
"exam_set": exam_set,
|
||||
"sections": sections,
|
||||
"active_attempt": active_attempt,
|
||||
"total_minutes": sum(section.time_limit_minutes for section in sections),
|
||||
"question_count": sum(len(section.questions.all()) for section in sections),
|
||||
"is_ready": exam_set.is_ready,
|
||||
"can_access_exam": can_open,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def start_exam(request, exam_set_id):
|
||||
exam_set = get_object_or_404(
|
||||
ExamSet.objects.prefetch_related("sections__questions"),
|
||||
id=exam_set_id,
|
||||
is_published=True,
|
||||
)
|
||||
if not exam_set.is_ready:
|
||||
messages.error(request, "This test is not ready to begin. Please contact support.")
|
||||
return redirect("exam_detail", exam_set_id=exam_set.id)
|
||||
attempt = StudentAttempt.objects.filter(
|
||||
student=request.user,
|
||||
exam_set=exam_set,
|
||||
is_complete=False,
|
||||
).first()
|
||||
if attempt is None and not can_access_exam(request.user, exam_set):
|
||||
messages.info(
|
||||
request,
|
||||
"This test is included with Lifetime Premium. Upgrade once for permanent access.",
|
||||
)
|
||||
return redirect("premium")
|
||||
if attempt is None:
|
||||
attempt = StudentAttempt.objects.create(
|
||||
student=request.user,
|
||||
exam_set=exam_set,
|
||||
)
|
||||
if exam_set.delivery_mode == "exact_html":
|
||||
return redirect("exact_exam", attempt_id=attempt.id)
|
||||
first_section = exam_set.sections.order_by("order", "id").first()
|
||||
if first_section is None:
|
||||
return redirect("exam_list")
|
||||
|
||||
if attempt.current_section_id is None:
|
||||
activate_section(attempt, first_section)
|
||||
|
||||
return redirect(
|
||||
"take_section",
|
||||
attempt_id=attempt.id,
|
||||
section_id=attempt.current_section_id,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def retake_exam(request, attempt_id):
|
||||
"""Replace an unfinished attempt with a fresh one; completed attempts remain saved."""
|
||||
attempt = get_object_or_404(
|
||||
StudentAttempt.objects.select_related("exam_set"),
|
||||
id=attempt_id,
|
||||
student=request.user,
|
||||
is_complete=False,
|
||||
)
|
||||
exam_set = attempt.exam_set
|
||||
if not can_access_exam(request.user, exam_set):
|
||||
messages.info(
|
||||
request,
|
||||
"A new attempt for this test requires Lifetime Premium.",
|
||||
)
|
||||
return redirect("premium")
|
||||
attempt.delete()
|
||||
fresh_attempt = StudentAttempt.objects.create(student=request.user, exam_set=exam_set)
|
||||
if exam_set.delivery_mode == "exact_html":
|
||||
return redirect("exact_exam", attempt_id=fresh_attempt.id)
|
||||
first_section = exam_set.sections.order_by("order", "id").first()
|
||||
if first_section is None:
|
||||
fresh_attempt.delete()
|
||||
return redirect("exam_list")
|
||||
activate_section(fresh_attempt, first_section)
|
||||
return redirect(
|
||||
"take_section",
|
||||
attempt_id=fresh_attempt.id,
|
||||
section_id=first_section.id,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def exact_exam(request, attempt_id):
|
||||
attempt = get_object_or_404(
|
||||
StudentAttempt.objects.select_related("exam_set"), id=attempt_id,
|
||||
student=request.user, is_complete=False, exam_set__delivery_mode="exact_html",
|
||||
)
|
||||
return render(request, "exams/exact_exam.html", {"attempt": attempt, "exam_set": attempt.exam_set})
|
||||
|
||||
|
||||
@login_required
|
||||
@xframe_options_sameorigin
|
||||
def exact_exam_content(request, attempt_id):
|
||||
attempt = get_object_or_404(
|
||||
StudentAttempt.objects.select_related("exam_set"), id=attempt_id,
|
||||
student=request.user, is_complete=False, exam_set__delivery_mode="exact_html",
|
||||
)
|
||||
response = HttpResponse(attempt.exam_set.source_html, content_type="text/html; charset=utf-8")
|
||||
response["Content-Security-Policy"] = (
|
||||
"sandbox allow-scripts allow-forms allow-modals allow-downloads; "
|
||||
"default-src 'self' data: blob: https:; script-src 'unsafe-inline' https:; "
|
||||
"style-src 'unsafe-inline' https:; img-src data: blob: https:; "
|
||||
"media-src data: blob: https:; connect-src https:"
|
||||
)
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def complete_exact_exam(request, attempt_id):
|
||||
attempt = get_object_or_404(
|
||||
StudentAttempt.objects.select_related("exam_set"), id=attempt_id,
|
||||
student=request.user, is_complete=False, exam_set__delivery_mode="exact_html",
|
||||
)
|
||||
attempt.is_complete = True
|
||||
attempt.submitted_at = timezone.now()
|
||||
attempt.save(update_fields=["is_complete", "submitted_at"])
|
||||
profile, _ = StudentProfile.objects.get_or_create(user=request.user)
|
||||
profile.xp += 50
|
||||
profile.save(update_fields=["xp"])
|
||||
messages.success(request, "Exact HTML test marked as complete.")
|
||||
return redirect("exam_list")
|
||||
|
||||
|
||||
@login_required
|
||||
def take_section(request, attempt_id, section_id):
|
||||
attempt = get_object_or_404(
|
||||
StudentAttempt.objects.select_related("exam_set"),
|
||||
id=attempt_id,
|
||||
student=request.user,
|
||||
is_complete=False,
|
||||
)
|
||||
section = get_object_or_404(
|
||||
Section.objects.prefetch_related("questions", "question_groups__questions"),
|
||||
id=section_id,
|
||||
exam_set=attempt.exam_set,
|
||||
)
|
||||
|
||||
if attempt.current_section_id is None:
|
||||
first_section = attempt.exam_set.sections.order_by("order", "id").first()
|
||||
if first_section is None:
|
||||
return redirect("exam_list")
|
||||
activate_section(attempt, first_section)
|
||||
|
||||
if section.id != attempt.current_section_id:
|
||||
return redirect(
|
||||
"take_section",
|
||||
attempt_id=attempt.id,
|
||||
section_id=attempt.current_section_id,
|
||||
)
|
||||
|
||||
now = timezone.now()
|
||||
deadline = attempt.section_deadline
|
||||
remaining_seconds = max(0, math.ceil((deadline - now).total_seconds()))
|
||||
|
||||
if request.method == "POST":
|
||||
submission_is_late = now > deadline + timedelta(seconds=SUBMISSION_GRACE_SECONDS)
|
||||
|
||||
for question in section.questions.filter(question_type="speaking"):
|
||||
uploaded_audio = request.FILES.get(f"q{question.id}_audio")
|
||||
if uploaded_audio is None:
|
||||
continue
|
||||
try:
|
||||
AUDIO_RESPONSE_VALIDATOR(uploaded_audio)
|
||||
if uploaded_audio.size > MAX_AUDIO_RESPONSE_SIZE:
|
||||
raise ValidationError("Audio responses must be 20 MB or smaller.")
|
||||
except ValidationError as error:
|
||||
messages.error(request, error.messages[0])
|
||||
return redirect(
|
||||
"take_section",
|
||||
attempt_id=attempt.id,
|
||||
section_id=section.id,
|
||||
)
|
||||
|
||||
for question in section.questions.all():
|
||||
answer_text = request.POST.get(f"q{question.id}", "").strip()
|
||||
audio_response = request.FILES.get(f"q{question.id}_audio")
|
||||
if submission_is_late:
|
||||
answer_text = ""
|
||||
audio_response = None
|
||||
is_correct = None
|
||||
if question.question_type in {"mcq", "gap", "matching"}:
|
||||
expected = (question.correct_answer or "").strip().casefold()
|
||||
is_correct = answer_text.casefold() == expected
|
||||
|
||||
defaults = {"answer_text": answer_text, "is_correct": is_correct}
|
||||
if audio_response is not None:
|
||||
defaults["audio_response"] = audio_response
|
||||
StudentAnswer.objects.update_or_create(
|
||||
attempt=attempt,
|
||||
question=question,
|
||||
defaults=defaults,
|
||||
)
|
||||
|
||||
next_section = (
|
||||
Section.objects.filter(
|
||||
exam_set=attempt.exam_set,
|
||||
order__gt=section.order,
|
||||
)
|
||||
.order_by("order", "id")
|
||||
.first()
|
||||
)
|
||||
if next_section:
|
||||
activate_section(attempt, next_section, now=now)
|
||||
return redirect(
|
||||
"take_section",
|
||||
attempt_id=attempt.id,
|
||||
section_id=next_section.id,
|
||||
)
|
||||
|
||||
attempt.is_complete = True
|
||||
attempt.submitted_at = now
|
||||
attempt.current_section = None
|
||||
attempt.section_started_at = None
|
||||
attempt.section_deadline = None
|
||||
attempt.save(
|
||||
update_fields=[
|
||||
"is_complete",
|
||||
"submitted_at",
|
||||
"current_section",
|
||||
"section_started_at",
|
||||
"section_deadline",
|
||||
]
|
||||
)
|
||||
profile, _ = StudentProfile.objects.get_or_create(user=request.user)
|
||||
today = timezone.localdate(now)
|
||||
if profile.last_activity_date != today:
|
||||
if profile.last_activity_date == today - timedelta(days=1):
|
||||
profile.streak += 1
|
||||
else:
|
||||
profile.streak = 1
|
||||
profile.last_activity_date = today
|
||||
profile.xp += 50 + (attempt.answers.count() * 10)
|
||||
profile.save(update_fields=["xp", "streak", "last_activity_date"])
|
||||
return redirect("results", attempt_id=attempt.id)
|
||||
|
||||
grouped_question_ids = set()
|
||||
question_blocks = []
|
||||
for group in section.question_groups.all():
|
||||
group_questions = list(group.questions.all())
|
||||
if not group_questions:
|
||||
continue
|
||||
grouped_question_ids.update(question.id for question in group_questions)
|
||||
question_blocks.append({"kind": "group", "group": group, "order": min(question.order for question in group_questions)})
|
||||
for question in section.questions.all():
|
||||
if question.id not in grouped_question_ids:
|
||||
question_blocks.append({"kind": "question", "question": question, "order": question.order})
|
||||
question_blocks.sort(key=lambda block: (block["order"], 0 if block["kind"] == "group" else 1))
|
||||
question_orders = list(section.questions.values_list("order", flat=True))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"exams/take_section.html",
|
||||
{
|
||||
"attempt": attempt,
|
||||
"section": section,
|
||||
"remaining_seconds": remaining_seconds,
|
||||
"section_number": list(
|
||||
attempt.exam_set.sections.order_by("order", "id").values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
).index(section.id)
|
||||
+ 1,
|
||||
"section_total": attempt.exam_set.sections.count(),
|
||||
"question_blocks": question_blocks,
|
||||
"first_question_order": min(question_orders) if question_orders else None,
|
||||
"last_question_order": max(question_orders) if question_orders else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def results(request, attempt_id):
|
||||
attempt = get_object_or_404(
|
||||
StudentAttempt.objects.select_related("exam_set"),
|
||||
id=attempt_id,
|
||||
student=request.user,
|
||||
is_complete=True,
|
||||
)
|
||||
answers = attempt.answers.select_related("question__section").order_by(
|
||||
"question__section__order", "question__order", "id"
|
||||
)
|
||||
objective_answers = answers.exclude(is_correct=None)
|
||||
manually_graded = answers.filter(manual_score__isnull=False)
|
||||
pending_manual = answers.filter(
|
||||
is_correct=None,
|
||||
manual_score__isnull=True,
|
||||
).count()
|
||||
average_manual_score = manually_graded.aggregate(average=Avg("manual_score"))["average"]
|
||||
correct_count = objective_answers.filter(is_correct=True).count()
|
||||
total_gradable = objective_answers.count()
|
||||
reading_answers = objective_answers.filter(question__section__section_type="reading")
|
||||
reading_band = reading_equivalent = None
|
||||
reading_band_estimated = False
|
||||
if reading_answers.exists():
|
||||
reading_correct = reading_answers.filter(is_correct=True).count()
|
||||
reading_band, reading_equivalent, reading_band_estimated = academic_reading_band(
|
||||
reading_correct, reading_answers.count()
|
||||
)
|
||||
reading_passages = list(
|
||||
attempt.exam_set.sections.filter(section_type="reading")
|
||||
.exclude(passage_text__isnull=True)
|
||||
.exclude(passage_text="")
|
||||
)
|
||||
reading_review_answers = list(
|
||||
answers.filter(question__section__section_type="reading")
|
||||
)
|
||||
is_reading_only = not attempt.exam_set.sections.exclude(
|
||||
section_type="reading"
|
||||
).exists()
|
||||
return render(
|
||||
request,
|
||||
"exams/results.html",
|
||||
{
|
||||
"attempt": attempt,
|
||||
"answers": answers,
|
||||
"correct_count": correct_count,
|
||||
"total_gradable": total_gradable,
|
||||
"pending_manual": pending_manual,
|
||||
"average_manual_score": average_manual_score,
|
||||
"reading_band": reading_band,
|
||||
"reading_equivalent": reading_equivalent,
|
||||
"reading_band_estimated": reading_band_estimated,
|
||||
"reading_passages": reading_passages,
|
||||
"reading_review_answers": reading_review_answers,
|
||||
"use_split_reading_review": bool(
|
||||
is_reading_only and reading_passages and reading_review_answers
|
||||
),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user