from __future__ import annotations import json from typing import Any from django import forms from django.db.models import Count, Max, Q from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.views import View from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import FormMixin from django.views.generic.list import MultipleObjectMixin from inertia import render from labs.models import CertificateType, Exam, ExamQuestion, ExamSection, ExamVariant from .base import PermissionRequiredAdminMixin # ============================================================================= # Helper Functions # ============================================================================= def update_variant_stats(variant: ExamVariant) -> None: """Update variant's question_count and total_score based on its questions.""" from django.db.models import Sum stats = ExamQuestion.objects.filter(variant=variant).aggregate( count=Count("id"), total=Sum("score"), ) variant.question_count = stats["count"] or 0 variant.total_score = stats["total"] or 0 variant.save(update_fields=["question_count", "total_score"]) # ============================================================================= # Forms # ============================================================================= class ExamForm(forms.ModelForm): """Form for creating/updating exams.""" class Meta: model = Exam fields = ["code", "credits", "employee_only", "certificate_short_name", "published"] def clean_certificate_short_name(self) -> str | None: cert_short_name: str = ( self.cleaned_data.get("certificate_short_name") or "" ).strip() if not cert_short_name: # Normalize empty string to NULL in DB. return None if not CertificateType.objects.filter(short_name=cert_short_name).exists(): raise forms.ValidationError( f"CertificateType with short_name={cert_short_name!r} does not exist" ) return cert_short_name class ExamVariantForm(forms.ModelForm): """Form for creating/updating exam variants.""" class Meta: model = ExamVariant fields = [ "language_code", "title", "description", "certificate_purpose", "preparation_guide", "level", "splash_image", "duration_seconds", "pass_score", ] # Note: question_count and total_score are auto-calculated from questions class ExamSectionForm(forms.ModelForm): """Form for creating/updating exam sections.""" instruction_content = forms.CharField( widget=forms.Textarea(attrs={"rows": 10}), required=False, label="Content", help_text="The text content for the instruction section.", ) class Meta: model = ExamSection fields = ["title", "section_type"] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) if self.instance and self.instance.pk: self.fields["instruction_content"].initial = self.instance.content.get( "content", "" ) def save(self, commit: bool = True) -> ExamSection: instance = super().save(commit=False) # If it's an instruction section, save the content field wrapped in JSON if instance.section_type == ExamSection.SectionType.INSTRUCTION: instance.content = { "content": self.cleaned_data.get("instruction_content", "") } # For other types, preserve existing content or set empty elif not instance.content: instance.content = {} if commit: instance.save() return instance class ExamQuestionForm(forms.ModelForm): """Form for creating/updating exam questions.""" options_json = forms.CharField( widget=forms.HiddenInput(), required=True, help_text="JSON array of options", ) answer_json = forms.CharField( widget=forms.HiddenInput(), required=True, help_text="JSON array of correct answer IDs", ) class Meta: model = ExamQuestion fields = ["score", "question_type", "stem", "explanation"] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) if self.instance and self.instance.pk: self.fields["options_json"].initial = json.dumps( self.instance.content, ensure_ascii=False ) self.fields["answer_json"].initial = json.dumps(self.instance.answer) def clean_options_json(self) -> list: options_json = self.cleaned_data.get("options_json", "") if not options_json or not options_json.strip(): raise forms.ValidationError("Options are required") try: options = json.loads(options_json) except json.JSONDecodeError as e: raise forms.ValidationError(f"Invalid JSON: {e}") if not isinstance(options, list): raise forms.ValidationError("Options must be an array") if len(options) < 2: raise forms.ValidationError("At least 2 options are required") # Validate each option has id and text, and ids are unique ids = set() for opt in options: if not isinstance(opt, dict): raise forms.ValidationError("Each option must be an object") if "id" not in opt or "text" not in opt: raise forms.ValidationError("Each option must have 'id' and 'text'") if opt["id"] in ids: raise forms.ValidationError(f"Duplicate option id: {opt['id']}") ids.add(opt["id"]) return options def clean_answer_json(self) -> list: answer_json = self.cleaned_data.get("answer_json", "") if not answer_json or not answer_json.strip(): raise forms.ValidationError("Answer is required") try: answer = json.loads(answer_json) except json.JSONDecodeError as e: raise forms.ValidationError(f"Invalid JSON: {e}") if not isinstance(answer, list): raise forms.ValidationError("Answer must be an array") if len(answer) < 1: raise forms.ValidationError("At least 1 answer is required") return answer def clean(self) -> dict: cleaned_data = super().clean() options = cleaned_data.get("options_json", []) answer = cleaned_data.get("answer_json", []) question_type = cleaned_data.get("question_type") if options and answer: option_ids = {opt["id"] for opt in options} for ans_id in answer: if ans_id not in option_ids: raise forms.ValidationError( f"Answer id {ans_id} does not exist in options" ) # Single choice must have exactly one answer if question_type == ExamQuestion.QuestionType.SINGLE_CHOICE: if len(answer) != 1: raise forms.ValidationError( "Single choice question must have exactly 1 answer" ) return cleaned_data def save(self, commit: bool = True) -> ExamQuestion: instance = super().save(commit=False) instance.content = self.cleaned_data.get("options_json", []) instance.answer = self.cleaned_data.get("answer_json", []) if commit: instance.save() return instance # ============================================================================= # Exam Views # ============================================================================= class ExamListView(PermissionRequiredAdminMixin, MultipleObjectMixin, View): """List view for exams with search.""" model = Exam permission_required = "labs.view_exam" paginate_by = 10 page_kwarg = "page" ordering = "-created_at" search_fields = ["code"] component = "exams/Index" def get_queryset(self): queryset = self.model._default_manager.all() queryset = queryset.annotate(variant_count=Count("variants")) if self.ordering: queryset = queryset.order_by(self.ordering) query = self.request.GET.get("q", "").strip() if query: conditions = Q() for field in self.search_fields: conditions |= Q(**{f"{field}__icontains": query}) queryset = queryset.filter(conditions) return queryset def serialize_exam(self, exam: Exam) -> dict[str, Any]: return { "id": exam.id, "code": exam.code, "credits": exam.credits, "employee_only": exam.employee_only, "published": exam.published, "certificate_short_name": exam.certificate_short_name, "variant_count": getattr(exam, "variant_count", 0), "created_at": exam.created_at.isoformat(), } def get(self, request: HttpRequest) -> HttpResponse: self.object_list = self.get_queryset() paginate_by = self.get_paginate_by(self.object_list) if paginate_by: paginator, page, object_list, is_paginated = self.paginate_queryset( self.object_list, paginate_by ) else: paginator = None page = None object_list = self.object_list is_paginated = False exams = [self.serialize_exam(e) for e in object_list] pagination = None if paginator and page: pagination = { "page": page.number, "per_page": paginator.per_page, "total": paginator.count, "num_pages": paginator.num_pages, "is_paginated": is_paginated, } return render( request, self.component, { "exams": exams, "filters": {"q": request.GET.get("q", "").strip()}, "pagination": pagination, }, ) class ExamCreateView(PermissionRequiredAdminMixin, FormMixin, View): """Create view for exams.""" permission_required = "labs.add_exam" form_class = ExamForm component = "exams/Create" def get(self, request: HttpRequest) -> HttpResponse: return render(request, self.component, {"form": {}, "errors": {}}) def post(self, request: HttpRequest) -> HttpResponse: form = self.get_form() if form.is_valid(): return self.form_valid(form) return self.form_invalid(form) def form_valid(self, form: forms.ModelForm) -> HttpResponse: exam = form.save() self.request.session["flash_success"] = "Exam created" return redirect(reverse("labs_admin:exams_detail", kwargs={"pk": exam.id})) def form_invalid(self, form: forms.ModelForm) -> HttpResponse: return render( self.request, self.component, {"form": dict(form.data), "errors": form.errors}, ) class ExamDetailView(PermissionRequiredAdminMixin, FormMixin, SingleObjectMixin, View): """Detail/Edit view for a single exam with its variants.""" model = Exam permission_required = "labs.view_exam" form_class = ExamForm component = "exams/Detail" def serialize_exam(self, exam: Exam) -> dict[str, Any]: return { "id": exam.id, "code": exam.code, "credits": exam.credits, "employee_only": exam.employee_only, "published": exam.published, "certificate_short_name": exam.certificate_short_name or "", "created_at": exam.created_at.isoformat(), "updated_at": exam.updated_at.isoformat(), } def serialize_variant(self, variant: ExamVariant) -> dict[str, Any]: return { "id": variant.id, "language_code": variant.language_code, "title": variant.title, "level": variant.level, "question_count": variant.question_count, "duration_seconds": variant.duration_seconds, "pass_score": variant.pass_score, "total_score": variant.total_score, } def get_form_kwargs(self) -> dict[str, Any]: kwargs = super().get_form_kwargs() kwargs["instance"] = self.object return kwargs def get(self, request: HttpRequest, pk: int) -> HttpResponse: self.object = self.get_object() variants = self.object.variants.annotate( section_count=Count("examsection") ).order_by("language_code") return render( request, self.component, { "exam": self.serialize_exam(self.object), "variants": [self.serialize_variant(v) for v in variants], }, ) def post(self, request: HttpRequest, pk: int) -> HttpResponse: if not request.user.has_perm("labs.change_exam"): return redirect(f"/admin/exams/{pk}/") self.object = self.get_object() form = self.get_form() if form.is_valid(): return self.form_valid(form) return self.form_invalid(form) def form_valid(self, form: forms.ModelForm) -> HttpResponse: form.save() self.request.session["flash_success"] = "Exam updated" return redirect( reverse("labs_admin:exams_detail", kwargs={"pk": self.object.id}) ) def form_invalid(self, form: forms.ModelForm) -> HttpResponse: variants = self.object.variants.annotate( section_count=Count("examsection") ).order_by("language_code") return render( self.request, self.component, { "exam": self.serialize_exam(self.object), "variants": [self.serialize_variant(v) for v in variants], "errors": form.errors, "form": dict(form.data), }, ) class ExamDeleteView(PermissionRequiredAdminMixin, SingleObjectMixin, View): """Delete confirmation view for an exam.""" model = Exam permission_required = "labs.delete_exam" component = "exams/Delete" def get(self, request: HttpRequest, pk: int) -> HttpResponse: self.object = self.get_object() variant_count = self.object.variants.count() section_count = ExamSection.objects.filter(variant__exam=self.object).count() question_count = ExamQuestion.objects.filter(variant__exam=self.object).count() return render( request, self.component, { "exam": { "id": self.object.id, "code": self.object.code, }, "counts": { "variants": variant_count, "sections": section_count, "questions": question_count, }, }, ) def post(self, request: HttpRequest, pk: int) -> HttpResponse: self.object = self.get_object() self.object.delete() self.request.session["flash_success"] = "Exam deleted" return redirect(reverse("labs_admin:exams")) # ============================================================================= # ExamVariant Views # ============================================================================= class ExamVariantCreateView(PermissionRequiredAdminMixin, FormMixin, View): """Create view for exam variants.""" permission_required = "labs.add_examvariant" form_class = ExamVariantForm component = "exams/variants/Create" def get_exam(self) -> Exam: return get_object_or_404(Exam, pk=self.kwargs["exam_id"]) def get(self, request: HttpRequest, exam_id: int) -> HttpResponse: exam = self.get_exam() return render( request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "form": {}, "errors": {}, }, ) def post(self, request: HttpRequest, exam_id: int) -> HttpResponse: exam = self.get_exam() form = self.get_form() if form.is_valid(): variant = form.save(commit=False) variant.exam = exam variant.save() self.request.session["flash_success"] = "Variant created" return redirect( reverse( "labs_admin:exams_variants_detail", kwargs={"exam_id": exam_id, "pk": variant.id}, ) ) return render( self.request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "form": dict(form.data), "errors": form.errors, }, ) class ExamVariantDetailView( PermissionRequiredAdminMixin, FormMixin, SingleObjectMixin, View ): """Detail/Edit view for a single exam variant with its sections.""" model = ExamVariant permission_required = "labs.view_examvariant" form_class = ExamVariantForm component = "exams/variants/Detail" def get_object(self, queryset=None): obj = super().get_object(queryset) # Verify parent relationship if obj.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return obj def serialize_variant(self, variant: ExamVariant) -> dict[str, Any]: return { "id": variant.id, "language_code": variant.language_code, "title": variant.title, "description": variant.description or "", "certificate_purpose": variant.certificate_purpose or "", "preparation_guide": variant.preparation_guide or "", "level": variant.level or "", "splash_image": variant.splash_image or "", "question_count": variant.question_count, "duration_seconds": variant.duration_seconds, "pass_score": variant.pass_score, "total_score": variant.total_score, "created_at": variant.created_at.isoformat(), "updated_at": variant.updated_at.isoformat(), } def serialize_section(self, section: ExamSection) -> dict[str, Any]: return { "id": section.id, "title": section.title, "section_type": section.section_type, "section_type_label": ExamSection.SectionType(section.section_type).label, "display_order": section.display_order, "question_count": getattr(section, "question_count", 0), } def get_form_kwargs(self) -> dict[str, Any]: kwargs = super().get_form_kwargs() kwargs["instance"] = self.object return kwargs def get(self, request: HttpRequest, exam_id: int, pk: int) -> HttpResponse: self.object = self.get_object() exam = self.object.exam sections = ( ExamSection.objects.filter(variant=self.object) .annotate(question_count=Count("examquestion")) .order_by("display_order") ) return render( request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "variant": self.serialize_variant(self.object), "sections": [self.serialize_section(s) for s in sections], "section_types": [ {"value": t.value, "label": t.label} for t in ExamSection.SectionType ], }, ) def post(self, request: HttpRequest, exam_id: int, pk: int) -> HttpResponse: if not request.user.has_perm("labs.change_examvariant"): return redirect(f"/admin/exams/{exam_id}/variants/{pk}/") self.object = self.get_object() exam = self.object.exam form = self.get_form() if form.is_valid(): form.save() self.request.session["flash_success"] = "Variant updated" return redirect( reverse( "labs_admin:exams_variants_detail", kwargs={"exam_id": exam_id, "pk": pk}, ) ) sections = ( ExamSection.objects.filter(variant=self.object) .annotate(question_count=Count("examquestion")) .order_by("display_order") ) return render( self.request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "variant": self.serialize_variant(self.object), "sections": [self.serialize_section(s) for s in sections], "section_types": [ {"value": t.value, "label": t.label} for t in ExamSection.SectionType ], "errors": form.errors, "form": dict(form.data), }, ) class ExamVariantDeleteView(PermissionRequiredAdminMixin, SingleObjectMixin, View): """Delete confirmation view for an exam variant.""" model = ExamVariant permission_required = "labs.delete_examvariant" component = "exams/variants/Delete" def get_object(self, queryset=None): obj = super().get_object(queryset) if obj.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return obj def get(self, request: HttpRequest, exam_id: int, pk: int) -> HttpResponse: self.object = self.get_object() section_count = ExamSection.objects.filter(variant=self.object).count() question_count = ExamQuestion.objects.filter(variant=self.object).count() return render( request, self.component, { "exam": {"id": self.object.exam.id, "code": self.object.exam.code}, "variant": { "id": self.object.id, "title": self.object.title, "language_code": self.object.language_code, }, "counts": { "sections": section_count, "questions": question_count, }, }, ) def post(self, request: HttpRequest, exam_id: int, pk: int) -> HttpResponse: self.object = self.get_object() self.object.delete() self.request.session["flash_success"] = "Variant deleted" return redirect(reverse("labs_admin:exams_detail", kwargs={"pk": exam_id})) # ============================================================================= # ExamSection Views # ============================================================================= class ExamSectionCreateView(PermissionRequiredAdminMixin, FormMixin, View): """Create view for exam sections.""" permission_required = "labs.add_examsection" form_class = ExamSectionForm component = "exams/sections/Create" def get_variant(self) -> ExamVariant: variant = get_object_or_404(ExamVariant, pk=self.kwargs["variant_id"]) if variant.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return variant def get(self, request: HttpRequest, exam_id: int, variant_id: int) -> HttpResponse: variant = self.get_variant() return render( request, self.component, { "exam": {"id": variant.exam.id, "code": variant.exam.code}, "variant": {"id": variant.id, "title": variant.title}, "form": {}, "errors": {}, "section_types": [ {"value": t.value, "label": t.label} for t in ExamSection.SectionType ], }, ) def post(self, request: HttpRequest, exam_id: int, variant_id: int) -> HttpResponse: variant = self.get_variant() form = self.get_form() if form.is_valid(): section = form.save(commit=False) section.variant = variant # Auto-append new section at the end; display_order is not user-editable. max_order = ExamSection.objects.filter(variant=variant).aggregate( max=Max("display_order") )["max"] section.display_order = (max_order + 1) if max_order is not None else 0 section.save() self.request.session["flash_success"] = "Section created" return redirect( reverse( "labs_admin:exams_sections_detail", kwargs={ "exam_id": exam_id, "variant_id": variant_id, "pk": section.id, }, ) ) return render( self.request, self.component, { "exam": {"id": variant.exam.id, "code": variant.exam.code}, "variant": {"id": variant.id, "title": variant.title}, "form": dict(form.data), "errors": form.errors, "section_types": [ {"value": t.value, "label": t.label} for t in ExamSection.SectionType ], }, ) class ExamSectionDetailView( PermissionRequiredAdminMixin, FormMixin, SingleObjectMixin, View ): """Detail/Edit view for a single exam section with its questions.""" model = ExamSection permission_required = "labs.view_examsection" form_class = ExamSectionForm component = "exams/sections/Detail" def get_object(self, queryset=None): obj = super().get_object(queryset) if obj.variant_id != self.kwargs["variant_id"]: raise Http404("Section does not belong to this variant") if obj.variant.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return obj def serialize_section(self, section: ExamSection) -> dict[str, Any]: return { "id": section.id, "title": section.title, "section_type": section.section_type, "section_type_label": ExamSection.SectionType(section.section_type).label, "content": section.content, "instruction_content": section.content.get("content", ""), "display_order": section.display_order, "created_at": section.created_at.isoformat(), "updated_at": section.updated_at.isoformat(), } def serialize_question(self, question: ExamQuestion) -> dict[str, Any]: return { "id": question.id, "score": question.score, "question_type": question.question_type, "question_type_label": ExamQuestion.QuestionType( question.question_type ).label, "stem": question.stem[:100] + "..." if len(question.stem) > 100 else question.stem, "display_order": question.display_order, } def get_form_kwargs(self) -> dict[str, Any]: kwargs = super().get_form_kwargs() kwargs["instance"] = self.object return kwargs def get( self, request: HttpRequest, exam_id: int, variant_id: int, pk: int ) -> HttpResponse: self.object = self.get_object() variant = self.object.variant exam = variant.exam # Only get questions for QUESTION type sections questions = [] if self.object.section_type == ExamSection.SectionType.QUESTION: questions = ExamQuestion.objects.filter(section=self.object).order_by( "display_order" ) questions = [self.serialize_question(q) for q in questions] return render( request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "variant": {"id": variant.id, "title": variant.title}, "section": self.serialize_section(self.object), "questions": questions, "section_types": [ {"value": t.value, "label": t.label} for t in ExamSection.SectionType ], }, ) def post( self, request: HttpRequest, exam_id: int, variant_id: int, pk: int ) -> HttpResponse: if not request.user.has_perm("labs.change_examsection"): return redirect( f"/admin/exams/{exam_id}/variants/{variant_id}/sections/{pk}/" ) self.object = self.get_object() variant = self.object.variant exam = variant.exam form = self.get_form() if form.is_valid(): form.save() self.request.session["flash_success"] = "Section updated" return redirect( reverse( "labs_admin:exams_sections_detail", kwargs={ "exam_id": exam_id, "variant_id": variant_id, "pk": pk, }, ) ) questions = [] if self.object.section_type == ExamSection.SectionType.QUESTION: questions = ExamQuestion.objects.filter(section=self.object).order_by( "display_order" ) questions = [self.serialize_question(q) for q in questions] return render( self.request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "variant": {"id": variant.id, "title": variant.title}, "section": self.serialize_section(self.object), "questions": questions, "section_types": [ {"value": t.value, "label": t.label} for t in ExamSection.SectionType ], "errors": form.errors, "form": dict(form.data), }, ) class ExamSectionDeleteView(PermissionRequiredAdminMixin, SingleObjectMixin, View): """Delete confirmation view for an exam section.""" model = ExamSection permission_required = "labs.delete_examsection" component = "exams/sections/Delete" def get_object(self, queryset=None): obj = super().get_object(queryset) if obj.variant_id != self.kwargs["variant_id"]: raise Http404("Section does not belong to this variant") if obj.variant.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return obj def get( self, request: HttpRequest, exam_id: int, variant_id: int, pk: int ) -> HttpResponse: self.object = self.get_object() question_count = ExamQuestion.objects.filter(section=self.object).count() return render( request, self.component, { "exam": { "id": self.object.variant.exam.id, "code": self.object.variant.exam.code, }, "variant": { "id": self.object.variant.id, "title": self.object.variant.title, }, "section": { "id": self.object.id, "title": self.object.title, "section_type_label": ExamSection.SectionType( self.object.section_type ).label, }, "counts": {"questions": question_count}, }, ) def post( self, request: HttpRequest, exam_id: int, variant_id: int, pk: int ) -> HttpResponse: self.object = self.get_object() self.object.delete() self.request.session["flash_success"] = "Section deleted" return redirect( reverse( "labs_admin:exams_variants_detail", kwargs={"exam_id": exam_id, "pk": variant_id}, ) ) class ExamSectionReorderView(PermissionRequiredAdminMixin, View): """API to reorder sections within a variant.""" permission_required = "labs.change_examsection" def get_variant(self) -> ExamVariant: variant = get_object_or_404(ExamVariant, pk=self.kwargs["variant_id"]) if variant.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return variant def post(self, request: HttpRequest, exam_id: int, variant_id: int) -> HttpResponse: from django.http import JsonResponse variant = self.get_variant() try: data = json.loads(request.body) section_ids = data.get("section_ids", []) except (json.JSONDecodeError, AttributeError): return JsonResponse({"error": "Invalid JSON"}, status=400) if not isinstance(section_ids, list): return JsonResponse({"error": "section_ids must be an array"}, status=400) # Verify all sections belong to this variant sections = ExamSection.objects.filter(variant=variant) existing_ids = set(sections.values_list("id", flat=True)) if set(section_ids) != existing_ids: return JsonResponse( {"error": "section_ids must contain all sections in the variant"}, status=400, ) # Update display_order for each section for order, sid in enumerate(section_ids): ExamSection.objects.filter(id=sid, variant=variant).update( display_order=order ) return JsonResponse({"success": True}) class ExamQuestionCreateView(PermissionRequiredAdminMixin, FormMixin, View): """Create view for exam questions.""" permission_required = "labs.add_examquestion" form_class = ExamQuestionForm component = "exams/questions/Create" def get_section(self) -> ExamSection: section = get_object_or_404(ExamSection, pk=self.kwargs["section_id"]) if section.variant_id != self.kwargs["variant_id"]: raise Http404("Section does not belong to this variant") if section.variant.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") # Only QUESTION type sections can have questions if section.section_type != ExamSection.SectionType.QUESTION: raise Http404("Cannot add questions to INSTRUCTION sections") return section def get( self, request: HttpRequest, exam_id: int, variant_id: int, section_id: int ) -> HttpResponse: section = self.get_section() return render( request, self.component, { "exam": { "id": section.variant.exam.id, "code": section.variant.exam.code, }, "variant": {"id": section.variant.id, "title": section.variant.title}, "section": {"id": section.id, "title": section.title}, "form": {}, "errors": {}, "question_types": [ {"value": t.value, "label": t.label} for t in ExamQuestion.QuestionType ], }, ) def post( self, request: HttpRequest, exam_id: int, variant_id: int, section_id: int ) -> HttpResponse: section = self.get_section() form = self.get_form() if form.is_valid(): question = form.save(commit=False) question.section = section question.variant = section.variant # Auto-append new question at the end; display_order is not user-editable. max_order = ExamQuestion.objects.filter(section=section).aggregate( max=Max("display_order") )["max"] question.display_order = (max_order + 1) if max_order is not None else 0 question.save() # Update variant stats after creating question update_variant_stats(section.variant) self.request.session["flash_success"] = "Question created" return redirect( reverse( "labs_admin:exams_questions_detail", kwargs={ "exam_id": exam_id, "variant_id": variant_id, "section_id": section_id, "pk": question.id, }, ) ) return render( self.request, self.component, { "exam": { "id": section.variant.exam.id, "code": section.variant.exam.code, }, "variant": {"id": section.variant.id, "title": section.variant.title}, "section": {"id": section.id, "title": section.title}, "form": dict(form.data), "errors": form.errors, "question_types": [ {"value": t.value, "label": t.label} for t in ExamQuestion.QuestionType ], }, ) class ExamQuestionDetailView( PermissionRequiredAdminMixin, FormMixin, SingleObjectMixin, View ): """Detail/Edit view for a single exam question.""" model = ExamQuestion permission_required = "labs.view_examquestion" form_class = ExamQuestionForm component = "exams/questions/Detail" def get_object(self, queryset=None): obj = super().get_object(queryset) if obj.section_id != self.kwargs["section_id"]: raise Http404("Question does not belong to this section") if obj.section.variant_id != self.kwargs["variant_id"]: raise Http404("Section does not belong to this variant") if obj.section.variant.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return obj def serialize_question(self, question: ExamQuestion) -> dict[str, Any]: return { "id": question.id, "score": question.score, "question_type": question.question_type, "question_type_label": ExamQuestion.QuestionType( question.question_type ).label, "stem": question.stem, "content": question.content, "answer": question.answer, "display_order": question.display_order, "explanation": question.explanation or "", "created_at": question.created_at.isoformat(), "updated_at": question.updated_at.isoformat(), } def get_form_kwargs(self) -> dict[str, Any]: kwargs = super().get_form_kwargs() kwargs["instance"] = self.object return kwargs def get( self, request: HttpRequest, exam_id: int, variant_id: int, section_id: int, pk: int, ) -> HttpResponse: self.object = self.get_object() section = self.object.section variant = section.variant exam = variant.exam return render( request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "variant": {"id": variant.id, "title": variant.title}, "section": {"id": section.id, "title": section.title}, "question": self.serialize_question(self.object), "question_types": [ {"value": t.value, "label": t.label} for t in ExamQuestion.QuestionType ], }, ) def post( self, request: HttpRequest, exam_id: int, variant_id: int, section_id: int, pk: int, ) -> HttpResponse: if not request.user.has_perm("labs.change_examquestion"): return redirect( f"/admin/exams/{exam_id}/variants/{variant_id}/sections/{section_id}/questions/{pk}/" ) self.object = self.get_object() section = self.object.section variant = section.variant exam = variant.exam form = self.get_form() if form.is_valid(): form.save() # Update variant stats after updating question (score may have changed) update_variant_stats(variant) self.request.session["flash_success"] = "Question updated" return redirect( reverse( "labs_admin:exams_questions_detail", kwargs={ "exam_id": exam_id, "variant_id": variant_id, "section_id": section_id, "pk": pk, }, ) ) return render( self.request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "variant": {"id": variant.id, "title": variant.title}, "section": {"id": section.id, "title": section.title}, "question": self.serialize_question(self.object), "question_types": [ {"value": t.value, "label": t.label} for t in ExamQuestion.QuestionType ], "errors": form.errors, "form": dict(form.data), }, ) class ExamQuestionReorderView(PermissionRequiredAdminMixin, View): """API to reorder questions within a section.""" permission_required = "labs.change_examquestion" def get_section(self) -> ExamSection: section = get_object_or_404(ExamSection, pk=self.kwargs["section_id"]) if section.variant_id != self.kwargs["variant_id"]: raise Http404("Section does not belong to this variant") if section.variant.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return section def post( self, request: HttpRequest, exam_id: int, variant_id: int, section_id: int ) -> HttpResponse: from django.http import JsonResponse section = self.get_section() try: data = json.loads(request.body) question_ids = data.get("question_ids", []) except (json.JSONDecodeError, AttributeError): return JsonResponse({"error": "Invalid JSON"}, status=400) if not isinstance(question_ids, list): return JsonResponse({"error": "question_ids must be an array"}, status=400) # Verify all questions belong to this section questions = ExamQuestion.objects.filter(section=section) existing_ids = set(questions.values_list("id", flat=True)) if set(question_ids) != existing_ids: return JsonResponse( {"error": "question_ids must contain all questions in the section"}, status=400, ) # Update display_order for each question for order, qid in enumerate(question_ids): ExamQuestion.objects.filter(id=qid, section=section).update( display_order=order ) return JsonResponse({"success": True}) class ExamQuestionDeleteView(PermissionRequiredAdminMixin, SingleObjectMixin, View): """Delete confirmation view for an exam question.""" model = ExamQuestion permission_required = "labs.delete_examquestion" component = "exams/questions/Delete" def get_object(self, queryset=None): obj = super().get_object(queryset) if obj.section_id != self.kwargs["section_id"]: raise Http404("Question does not belong to this section") if obj.section.variant_id != self.kwargs["variant_id"]: raise Http404("Section does not belong to this variant") if obj.section.variant.exam_id != self.kwargs["exam_id"]: raise Http404("Variant does not belong to this exam") return obj def get( self, request: HttpRequest, exam_id: int, variant_id: int, section_id: int, pk: int, ) -> HttpResponse: self.object = self.get_object() section = self.object.section variant = section.variant exam = variant.exam return render( request, self.component, { "exam": {"id": exam.id, "code": exam.code}, "variant": {"id": variant.id, "title": variant.title}, "section": {"id": section.id, "title": section.title}, "question": { "id": self.object.id, "stem": self.object.stem[:100] + "..." if len(self.object.stem) > 100 else self.object.stem, }, }, ) def post( self, request: HttpRequest, exam_id: int, variant_id: int, section_id: int, pk: int, ) -> HttpResponse: self.object = self.get_object() variant = self.object.variant self.object.delete() # Update variant stats after deleting question update_variant_stats(variant) self.request.session["flash_success"] = "Question deleted" return redirect( reverse( "labs_admin:exams_sections_detail", kwargs={ "exam_id": exam_id, "variant_id": variant_id, "pk": section_id, }, ) )