429 lines
16 KiB
Python
429 lines
16 KiB
Python
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 "<html" not in source_html.lower() and "<body" not in source_html.lower():
|
|
form.add_error("html_files", "The uploaded file must be a complete HTML document containing an HTML or BODY element.")
|
|
else:
|
|
payload = {
|
|
"title": form.cleaned_data["title"],
|
|
"description": form.cleaned_data["description"],
|
|
"category": form.cleaned_data["section_type"],
|
|
"delivery_mode": "exact_html",
|
|
"source_html": source_html,
|
|
"publish": form.cleaned_data["publish"],
|
|
"sections": [],
|
|
}
|
|
context.update({
|
|
"preview_exact": True,
|
|
"preview_source_name": upload.name,
|
|
"preview_file_size": len(raw),
|
|
"import_payload": signing.dumps(payload, salt=HTML_IMPORT_SALT, compress=True),
|
|
"preview_title": payload["title"],
|
|
"preview_publish": payload["publish"],
|
|
"total_questions": "Original HTML",
|
|
})
|
|
|
|
return TemplateResponse(
|
|
request,
|
|
"admin/exams/examset/import_html.html",
|
|
context,
|
|
)
|
|
|
|
@admin.display(description="Sections")
|
|
def section_total(self, obj):
|
|
return obj.sections.count()
|
|
|
|
@admin.action(description="Publish selected ready tests")
|
|
def publish_ready_tests(self, request, queryset):
|
|
published = 0
|
|
skipped = []
|
|
for exam_set in queryset.prefetch_related("sections__questions"):
|
|
if exam_set.is_ready:
|
|
exam_set.is_published = True
|
|
exam_set.save(update_fields=["is_published"])
|
|
published += 1
|
|
else:
|
|
skipped.append(exam_set.title)
|
|
if published:
|
|
self.message_user(request, f"Published {published} ready test(s).")
|
|
if skipped:
|
|
self.message_user(
|
|
request,
|
|
"Not published because sections or questions are missing: "
|
|
+ ", ".join(skipped),
|
|
level=messages.WARNING,
|
|
)
|
|
|
|
@admin.action(description="Unpublish selected tests")
|
|
def unpublish_tests(self, request, queryset):
|
|
updated = queryset.update(is_published=False)
|
|
self.message_user(request, f"Unpublished {updated} test(s).")
|
|
|
|
|
|
@admin.register(Section)
|
|
class SectionAdmin(ModelAdmin):
|
|
form = SectionAdminForm
|
|
list_display = (
|
|
"exam_set",
|
|
"order",
|
|
"section_type",
|
|
"time_limit_minutes",
|
|
"question_total",
|
|
)
|
|
list_filter = ("section_type", "exam_set")
|
|
search_fields = ("exam_set__title", "passage_text")
|
|
inlines = (QuestionGroupInline, QuestionInline)
|
|
@admin.display(description="Questions")
|
|
def question_total(self, obj):
|
|
return obj.questions.count()
|
|
|
|
|
|
@admin.register(Question)
|
|
class QuestionAdmin(ModelAdmin):
|
|
form = QuestionAdminForm
|
|
list_display = ("short_prompt", "section", "order", "question_type")
|
|
list_filter = ("question_type", "section__section_type", "section__exam_set")
|
|
search_fields = (
|
|
"prompt",
|
|
"correct_answer",
|
|
"explanation",
|
|
"passage_reference",
|
|
"section__exam_set__title",
|
|
)
|
|
fields = (
|
|
"section",
|
|
"group",
|
|
"order",
|
|
"question_type",
|
|
"prompt",
|
|
"options",
|
|
"correct_answer",
|
|
"explanation",
|
|
"passage_reference",
|
|
)
|
|
|
|
@admin.display(description="Question")
|
|
def short_prompt(self, obj):
|
|
return strip_tags(obj.prompt)[:70]
|
|
|
|
|
|
@admin.register(QuestionGroup)
|
|
class QuestionGroupAdmin(ModelAdmin):
|
|
form = QuestionGroupAdminForm
|
|
list_display = ("title", "section", "key", "layout_type", "order")
|
|
list_filter = ("layout_type", "section__section_type", "section__exam_set")
|
|
search_fields = ("title", "key", "instructions", "section__exam_set__title")
|
|
|
|
|
|
@admin.register(StudentAttempt)
|
|
class StudentAttemptAdmin(ModelAdmin):
|
|
list_display = ("student", "exam_set", "is_complete", "started_at", "submitted_at")
|
|
list_filter = ("is_complete", "exam_set", "started_at")
|
|
search_fields = ("student__username", "student__email", "exam_set__title")
|
|
readonly_fields = ("started_at", "submitted_at")
|
|
|
|
|
|
@admin.register(StudentAnswer)
|
|
class StudentAnswerAdmin(ModelAdmin):
|
|
list_display = (
|
|
"student_name",
|
|
"exam_name",
|
|
"question_kind",
|
|
"is_correct",
|
|
"manual_score",
|
|
)
|
|
list_filter = (
|
|
"question__question_type",
|
|
"question__section__section_type",
|
|
"attempt__exam_set",
|
|
"is_correct",
|
|
)
|
|
search_fields = (
|
|
"attempt__student__username",
|
|
"attempt__student__email",
|
|
"answer_text",
|
|
"question__prompt",
|
|
)
|
|
readonly_fields = ("attempt", "question", "answer_text", "audio_response", "is_correct")
|
|
fields = (
|
|
"attempt",
|
|
"question",
|
|
"answer_text",
|
|
"audio_response",
|
|
"is_correct",
|
|
"manual_score",
|
|
)
|
|
|
|
@admin.display(description="Student")
|
|
def student_name(self, obj):
|
|
return obj.attempt.student.username
|
|
|
|
@admin.display(description="Test")
|
|
def exam_name(self, obj):
|
|
return obj.attempt.exam_set.title
|
|
|
|
@admin.display(description="Type")
|
|
def question_kind(self, obj):
|
|
return obj.question.get_question_type_display()
|