from django.contrib import admin, messages from django.contrib.auth import get_user_model from django.core import signing from django.core.exceptions import ValidationError from django.db.models import Count from django.http import FileResponse, HttpResponseRedirect from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils.html import strip_tags from unfold.admin import ModelAdmin, StackedInline, TabularInline from .forms import ExcelExamImportForm, HtmlExamImportForm, ListeningTestSetupForm, QuestionAdminForm, QuestionGroupAdminForm, SectionAdminForm from .excel_importer import ExcelImportError, build_excel_template, parse_excel_test from .html_importer import create_exam_from_payload from .models import ExamSet, Question, QuestionGroup, Section, StudentAnswer, StudentAttempt HTML_IMPORT_SALT = "exams.html-import.v1" EXCEL_IMPORT_SALT = "exams.excel-import.v1" # The custom IELTS Blue sidebar lives in admin/base_site.html. Django otherwise # skips the navigation block entirely when its built-in sidebar flag is disabled. _original_admin_index = admin.site.index def ielts_admin_index(request, extra_context=None): context = { "dashboard_metrics": [ {"label": "Total tests", "value": ExamSet.objects.count(), "icon": "▤"}, { "label": "Published tests", "value": ExamSet.objects.filter(is_published=True).count(), "icon": "✓", }, {"label": "Test attempts", "value": StudentAttempt.objects.count(), "icon": "↗"}, {"label": "Students", "value": get_user_model().objects.count(), "icon": "◉"}, ], "recent_tests": ExamSet.objects.annotate(section_count=Count("sections")) .order_by("-created_at")[:6], } if extra_context: context.update(extra_context) return _original_admin_index(request, extra_context=context) class SectionInline(TabularInline): model = Section extra = 0 fields = ("order", "section_type", "time_limit_minutes") show_change_link = True class QuestionInline(StackedInline): model = Question form = QuestionAdminForm extra = 0 fields = ( "order", "group", "question_type", "prompt", "options", "correct_answer", "explanation", "passage_reference", ) class QuestionGroupInline(StackedInline): model = QuestionGroup form = QuestionGroupAdminForm extra = 0 fields = ("key", "order", "layout_type", "title", "instructions", "layout_html") @admin.register(ExamSet) class ExamSetAdmin(ModelAdmin): list_display = ("title", "category", "access_level", "delivery_mode", "is_published", "section_total", "created_at") list_filter = ("category", "access_level", "is_published", "created_at") search_fields = ("title", "description") inlines = (SectionInline,) actions = ("publish_ready_tests", "unpublish_tests") def get_changeform_initial_data(self, request): initial = super().get_changeform_initial_data(request) category = request.GET.get("category") valid_categories = {value for value, _label in ExamSet.CATEGORY_CHOICES} if category in valid_categories: initial["category"] = category return initial def get_fields(self, request, obj=None): # Raw exact-mode documents are deliberately kept out of the ordinary # edit form. They are replaced through the importer, not a huge textarea. return ("title", "description", "category", "access_level", "delivery_mode", "is_published") def get_inlines(self, request, obj): if obj and obj.delivery_mode == "exact_html": return () return super().get_inlines(request, obj) def get_urls(self): """Expose the preserved workbook importer as an optional admin tool. Normal test creation stays in the standard Unfold forms; this route is deliberately separate so importing a prepared workbook never replaces the ordinary editor. """ custom_urls = [ path( "import-excel/", self.admin_site.admin_view(self.import_excel_view), name="exams_examset_import_excel", ), path( "excel-template/", self.admin_site.admin_view(self.excel_template_view), name="exams_examset_excel_template", ), ] return custom_urls + super().get_urls() def create_listening_view(self, request): context = { **self.admin_site.each_context(request), "opts": self.model._meta, "title": "Create Listening test", "form": ListeningTestSetupForm(), } if request.method == "POST": form = ListeningTestSetupForm(request.POST, request.FILES) context["form"] = form if form.is_valid(): exam = ExamSet.objects.create( title=form.cleaned_data["title"], description=form.cleaned_data["description"], category="listening", is_published=False, ) section = Section.objects.create( exam_set=exam, order=1, section_type="listening", time_limit_minutes=form.cleaned_data["time_limit_minutes"], audio_file=form.cleaned_data["audio_file"], ) self.message_user( request, "Listening test created as a draft. Now add its questions.", level=messages.SUCCESS, ) return HttpResponseRedirect( reverse("admin:exams_section_change", args=(section.pk,)) ) return TemplateResponse(request, "admin/exams/examset/create_listening.html", context) def excel_template_view(self, request): return FileResponse( build_excel_template(), as_attachment=True, filename="IELTS_Test_Import_Template.xlsx", content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) def import_excel_view(self, request): context = { **self.admin_site.each_context(request), "opts": self.model._meta, "title": "Import Excel tests", "form": ExcelExamImportForm(), "preview": None, } if request.method == "POST" and request.POST.get("confirm_import"): try: payload = signing.loads( request.POST.get("import_payload", ""), salt=EXCEL_IMPORT_SALT, max_age=60 * 60, ) exam = create_exam_from_payload(payload) except signing.BadSignature: self.message_user(request, "The preview expired. Upload the workbook again.", level=messages.ERROR) return HttpResponseRedirect(reverse("admin:exams_examset_import_excel")) except (KeyError, ValidationError, ValueError) as error: self.message_user(request, f"Import failed: {error}", level=messages.ERROR) return HttpResponseRedirect(reverse("admin:exams_examset_import_excel")) state = "published" if exam.is_published else "saved as a draft" self.message_user(request, f"{exam.title} imported successfully and {state}.", level=messages.SUCCESS) return HttpResponseRedirect(reverse("admin:exams_examset_change", args=(exam.pk,))) if request.method == "POST": form = ExcelExamImportForm(request.POST, request.FILES) context["form"] = form if form.is_valid(): try: payload = parse_excel_test(form.cleaned_data["excel_file"], publish=form.cleaned_data["publish"]) except ExcelImportError as error: form.add_error("excel_file", error.messages[0]) else: context["preview"] = payload context["preview_question_count"] = sum(len(section["questions"]) for section in payload["sections"]) context["preview_group_count"] = sum(len(section.get("groups", [])) for section in payload["sections"]) context["import_payload"] = signing.dumps(payload, salt=EXCEL_IMPORT_SALT, compress=True) return TemplateResponse(request, "admin/exams/examset/import_excel.html", context) def import_html_view(self, request): requested_type = request.GET.get("type", "reading") valid_section_types = {value for value, _label in Section.SECTION_TYPES} if requested_type not in valid_section_types: requested_type = "reading" context = { **self.admin_site.each_context(request), "opts": self.model._meta, "title": "Import HTML tests", "form": HtmlExamImportForm( initial={ "section_type": requested_type, } ), "preview_exact": False, } if request.method == "POST" and request.POST.get("confirm_import"): try: payload = signing.loads( request.POST.get("import_payload", ""), salt=HTML_IMPORT_SALT, max_age=60 * 60, ) exam = create_exam_from_payload(payload) except signing.BadSignature: self.message_user( request, "The import preview expired or was changed. Upload the HTML again.", level=messages.ERROR, ) return HttpResponseRedirect(reverse("admin:exams_examset_import_html")) except (KeyError, ValidationError, ValueError) as error: self.message_user(request, f"Import failed: {error}", level=messages.ERROR) return HttpResponseRedirect(reverse("admin:exams_examset_import_html")) state = "published" if exam.is_published else "saved as a draft" self.message_user( request, f"{exam.title} imported successfully and {state}.", level=messages.SUCCESS, ) return HttpResponseRedirect( reverse("admin:exams_examset_change", args=(exam.pk,)) ) if request.method == "POST": form = HtmlExamImportForm(request.POST, request.FILES) context["form"] = form if form.is_valid(): upload = form.cleaned_data["html_files"][0] raw = upload.read() try: source_html = raw.decode("utf-8") except UnicodeDecodeError: source_html = raw.decode("cp1252") if "