Initial commit

This commit is contained in:
kddd21a
2026-07-29 10:47:40 +00:00
commit 402c3ec6ed
171 changed files with 10536 additions and 0 deletions
View File
+428
View File
@@ -0,0 +1,428 @@
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()
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ExamsConfig(AppConfig):
name = 'exams'
+277
View File
@@ -0,0 +1,277 @@
from io import BytesIO
import re
from django.core.exceptions import ValidationError
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.datavalidation import DataValidation
TEST_FIELDS = {"title", "description", "category"}
SECTION_HEADERS = ["section_order", "section_type", "time_limit_minutes", "passage_text"]
QUESTION_HEADERS = [
"section_order", "question_order", "question_type", "prompt", "options",
"correct_answer", "explanation", "passage_reference", "notes_for_admin",
]
QUESTION_HEADERS_GROUPED = QUESTION_HEADERS + ["group_key"]
GROUP_HEADERS = ["section_order", "group_key", "group_order", "layout_type", "title", "instructions", "layout_html"]
SKILLS = {"reading", "listening", "writing", "speaking", "full"}
SECTION_TYPES = {"reading", "listening", "writing", "speaking"}
QUESTION_TYPES = {"mcq", "gap", "matching", "essay", "speaking"}
class ExcelImportError(ValidationError):
pass
def _text(value):
return "" if value is None else str(value).strip()
def _integer(value, label, row, minimum=1, maximum=240):
try:
if isinstance(value, float) and value.is_integer():
number = int(value)
elif isinstance(value, str):
match = re.fullmatch(r"\s*(\d+)(?:\.0+)?(?:\s*(?:min|mins|minute|minutes))?\s*", value, re.IGNORECASE)
if not match:
raise ValueError
number = int(match.group(1))
else:
number = int(value)
except (TypeError, ValueError):
raise ExcelImportError(f"{label}, row {row}: enter a whole number.")
if not minimum <= number <= maximum:
raise ExcelImportError(f"{label}, row {row}: use a number from {minimum} to {maximum}.")
return number
def _headers(sheet, expected):
actual = [_text(cell.value).lower() for cell in sheet[1]][:len(expected)]
if actual != expected:
raise ExcelImportError(
f"Sheet '{sheet.title}' has changed column headers. Download a fresh template and keep its first row unchanged."
)
def parse_excel_test(file_object, publish=False):
try:
workbook = load_workbook(file_object, read_only=True, data_only=True)
except Exception as error:
raise ExcelImportError(f"The workbook could not be opened: {error}")
required = {"Test", "Sections", "Questions"}
missing = required.difference(workbook.sheetnames)
if missing:
raise ExcelImportError("Missing worksheet(s): " + ", ".join(sorted(missing)))
test_sheet = workbook["Test"]
metadata = {}
for row in test_sheet.iter_rows(min_row=2, max_col=2, values_only=True):
key = _text(row[0]).lower()
if key:
metadata[key] = _text(row[1])
if not metadata.get("title"):
raise ExcelImportError("Test sheet: title is required.")
category = metadata.get("category", "full").lower()
if category not in SKILLS:
raise ExcelImportError("Test sheet: category must be reading, listening, writing, speaking, or full.")
section_sheet = workbook["Sections"]
if section_sheet.max_row is not None and section_sheet.max_row > 51:
raise ExcelImportError("Sections sheet: maximum 50 section rows are allowed.")
_headers(section_sheet, SECTION_HEADERS)
sections = {}
for row_number, row in enumerate(section_sheet.iter_rows(min_row=2, max_col=4, values_only=True), start=2):
if not any(value not in (None, "") for value in row):
continue
order = _integer(row[0], "Sections sheet section_order", row_number, maximum=50)
section_type = _text(row[1]).lower()
if section_type not in SECTION_TYPES:
raise ExcelImportError(f"Sections sheet, row {row_number}: invalid section_type '{section_type}'.")
if order in sections:
raise ExcelImportError(f"Sections sheet: section_order {order} appears more than once.")
sections[order] = {
"order": order,
"section_type": section_type,
"time_limit_minutes": (
_integer(row[2], "Sections sheet time_limit_minutes", row_number)
if _text(row[2])
else {"reading": 60, "listening": 30, "writing": 60, "speaking": 15}[section_type]
),
"passage_text": _text(row[3]),
"questions": [],
}
if not sections:
raise ExcelImportError("Sections sheet: add at least one section.")
if "Groups" in workbook.sheetnames:
group_sheet = workbook["Groups"]
_headers(group_sheet, GROUP_HEADERS)
for row_number, row in enumerate(group_sheet.iter_rows(min_row=2, max_col=7, values_only=True), start=2):
if not any(value not in (None, "") for value in row):
continue
section_order = _integer(row[0], "Groups sheet section_order", row_number, maximum=50)
if section_order not in sections:
raise ExcelImportError(f"Groups sheet, row {row_number}: section_order {section_order} is not on the Sections sheet.")
key = _text(row[1]).lower()
if not re.fullmatch(r"[a-z0-9_-]+", key):
raise ExcelImportError(f"Groups sheet, row {row_number}: group_key must use letters, numbers, hyphens, or underscores.")
layout_type = _text(row[3]).lower() or "notes"
if layout_type not in {"notes", "table", "flow"}:
raise ExcelImportError(f"Groups sheet, row {row_number}: layout_type must be notes, table, or flow.")
if any(group["key"] == key for group in sections[section_order].setdefault("groups", [])):
raise ExcelImportError(f"Groups sheet, row {row_number}: group_key '{key}' is duplicated in section {section_order}.")
layout_html = _text(row[6])
if not layout_html:
raise ExcelImportError(f"Groups sheet, row {row_number}: layout_html is required.")
sections[section_order].setdefault("groups", []).append({
"key": key, "order": _integer(row[2], "Groups sheet group_order", row_number, maximum=200),
"layout_type": layout_type, "title": _text(row[4]), "instructions": _text(row[5]), "layout_html": layout_html,
})
question_sheet = workbook["Questions"]
if question_sheet.max_row is not None and question_sheet.max_row > 251:
raise ExcelImportError("Questions sheet: maximum 250 question rows are allowed.")
actual_headers = [_text(cell.value).lower() for cell in question_sheet[1]]
if actual_headers[:len(QUESTION_HEADERS_GROUPED)] == QUESTION_HEADERS_GROUPED:
question_columns = 10
elif actual_headers[:len(QUESTION_HEADERS)] == QUESTION_HEADERS:
question_columns = 9
else:
raise ExcelImportError("Sheet 'Questions' has changed column headers. Download a fresh template and keep its first row unchanged.")
seen = set()
for row_number, row in enumerate(question_sheet.iter_rows(min_row=2, max_col=question_columns, values_only=True), start=2):
if not any(value not in (None, "") for value in row):
continue
section_order = _integer(row[0], "Questions sheet section_order", row_number, maximum=50)
if section_order not in sections:
raise ExcelImportError(f"Questions sheet, row {row_number}: section_order {section_order} is not on the Sections sheet.")
question_order = _integer(row[1], "Questions sheet question_order", row_number, maximum=200)
key = (section_order, question_order)
if key in seen:
raise ExcelImportError(f"Questions sheet: question_order {question_order} is duplicated in section {section_order}.")
seen.add(key)
question_type = _text(row[2]).lower()
if question_type not in QUESTION_TYPES:
raise ExcelImportError(f"Questions sheet, row {row_number}: invalid question_type '{question_type}'.")
prompt = _text(row[3])
if not prompt:
raise ExcelImportError(f"Questions sheet, row {row_number}: prompt is required.")
options = [item.strip() for item in _text(row[4]).split("|") if item.strip()]
answer = _text(row[5])
if question_type in {"mcq", "matching"}:
if len(options) < 2:
raise ExcelImportError(f"Questions sheet, row {row_number}: {question_type} requires at least two | separated options.")
if answer not in options:
raise ExcelImportError(f"Questions sheet, row {row_number}: correct_answer must exactly match one option.")
if question_type == "gap" and not answer:
raise ExcelImportError(f"Questions sheet, row {row_number}: gap questions require a correct_answer.")
group_key = _text(row[9]).lower() if question_columns == 10 else ""
if group_key:
groups = sections[section_order].get("groups", [])
group = next((item for item in groups if item["key"] == group_key), None)
if group is None:
raise ExcelImportError(f"Questions sheet, row {row_number}: group_key '{group_key}' is not defined on the Groups sheet.")
if question_type not in {"gap", "matching"}:
raise ExcelImportError(f"Questions sheet, row {row_number}: grouped questions must use gap or matching.")
if f"[[{question_order}]]" not in group["layout_html"]:
raise ExcelImportError(f"Questions sheet, row {row_number}: group '{group_key}' layout_html does not contain [[{question_order}]].")
sections[section_order]["questions"].append({
"order": question_order,
"question_type": question_type,
"prompt": prompt,
"options": options or None,
"correct_answer": answer or None,
"explanation": _text(row[6]),
"passage_reference": _text(row[7]),
"group_key": group_key,
})
empty_sections = [str(order) for order, section in sections.items() if not section["questions"]]
if empty_sections:
raise ExcelImportError("Every section needs at least one question. Empty section_order: " + ", ".join(empty_sections))
for section in sections.values():
for group in section.get("groups", []):
grouped_orders = {question["order"] for question in section["questions"] if question.get("group_key") == group["key"]}
placeholder_orders = [int(value) for value in re.findall(r"\[\[(\d+)\]\]", group["layout_html"])]
if len(placeholder_orders) != len(set(placeholder_orders)):
raise ExcelImportError(f"Groups sheet: group '{group['key']}' contains a duplicate question placeholder.")
if set(placeholder_orders) != grouped_orders:
raise ExcelImportError(f"Groups sheet: group '{group['key']}' placeholders must exactly match its Questions rows.")
section["questions"].sort(key=lambda item: item["order"])
return {
"title": metadata["title"],
"description": metadata.get("description", ""),
"category": category,
"delivery_mode": "native",
"publish": bool(publish),
"sections": [sections[key] for key in sorted(sections)],
}
def build_excel_template():
workbook = Workbook()
instructions = workbook.active
instructions.title = "Instructions"
test = workbook.create_sheet("Test")
sections = workbook.create_sheet("Sections")
questions = workbook.create_sheet("Questions")
groups = workbook.create_sheet("Groups")
navy, blue, pale = "10213E", "1463E9", "EAF2FF"
instructions.append(["Testpoint — Excel Import Template"])
instructions.append(["Keep sheet names and column headers unchanged."])
instructions.append(["1", "Complete Test, Sections, and Questions. Use Groups only for inline notes, tables, forms, or flow charts."])
instructions.append(["2", "Use | between MCQ or matching options."])
instructions.append(["3", "Upload in Admin → Import Excel tests, review, then save."])
instructions.column_dimensions["A"].width = 16
instructions.column_dimensions["B"].width = 78
test.append(["field", "value"])
test.append(["title", "Academic Reading Practice 1"])
test.append(["description", "A complete IELTS practice test imported from Excel."])
test.append(["category", "reading"])
test.column_dimensions["A"].width = 22
test.column_dimensions["B"].width = 68
sections.append(SECTION_HEADERS)
sections.append([1, "reading", 20, "Paste the complete reading passage here."])
sections.column_dimensions["A"].width = 16
sections.column_dimensions["B"].width = 20
sections.column_dimensions["C"].width = 22
sections.column_dimensions["D"].width = 75
groups.append(GROUP_HEADERS)
groups.append([1, "notes_1", 1, "notes", "Urban farming in Paris", "Complete the notes below. Choose NO MORE THAN TWO WORDS AND/OR A NUMBER.", "<h3>Urban farming in Paris</h3><h4>Farm layout and production</h4><ul><li>Vertical tubes grow strawberries, [[1]] and herbs.</li><li>The daily harvest may reach [[2]] in weight.</li></ul>"])
for index, width in enumerate([15, 18, 15, 18, 32, 65, 95], start=1):
groups.column_dimensions[chr(64 + index)].width = width
questions.append(QUESTION_HEADERS_GROUPED)
questions.append([1, 1, "gap", "Vertical tubes grow strawberries, _____ and herbs.", "", "lettuces", "Explain why Option B is correct.", "From identical vertical tubes nearby burst row upon row of lettuces.", "", "notes_1"])
questions.append([1, 2, "gap", "The daily harvest may reach _____ in weight.", "", "1,000 kg", "", "Staff will harvest up to 1,000 kg every day.", "", "notes_1"])
widths = [15, 15, 18, 48, 44, 28, 42, 46, 28, 20]
for index, width in enumerate(widths, start=1):
questions.column_dimensions[chr(64 + index)].width = width
for sheet in workbook.worksheets:
sheet.freeze_panes = "A2"
sheet.sheet_view.showGridLines = False
for cell in sheet[1]:
cell.fill = PatternFill("solid", fgColor=navy)
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(wrap_text=True, vertical="center")
for row in sheet.iter_rows():
for cell in row:
cell.alignment = Alignment(wrap_text=True, vertical="top")
category_validation = DataValidation(type="list", formula1='"reading,listening,writing,speaking,full"')
test.add_data_validation(category_validation)
category_validation.add(test["B4"])
section_validation = DataValidation(type="list", formula1='"reading,listening,writing,speaking"')
sections.add_data_validation(section_validation)
section_validation.add("B2:B50")
question_validation = DataValidation(type="list", formula1='"mcq,gap,matching,essay,speaking"')
questions.add_data_validation(question_validation)
question_validation.add("C2:C250")
output = BytesIO()
workbook.save(output)
output.seek(0)
return output
+172
View File
@@ -0,0 +1,172 @@
from django import forms
from django.core.exceptions import ValidationError
import bleach
from .models import ExamSet, Question, QuestionGroup, Section
RICH_TEXT_TAGS = ["p", "br", "strong", "em", "u", "h2", "h3", "ul", "ol", "li", "blockquote", "a"]
RICH_TEXT_ATTRIBUTES = {"a": ["href", "title", "target", "rel"]}
class SectionAdminForm(forms.ModelForm):
class Meta:
model = Section
fields = "__all__"
widgets = {
"passage_text": forms.Textarea(
attrs={
"data-rich-text-editor": "passage",
"rows": 18,
}
)
}
def clean_passage_text(self):
value = self.cleaned_data.get("passage_text") or ""
return bleach.clean(
value,
tags=RICH_TEXT_TAGS,
attributes=RICH_TEXT_ATTRIBUTES,
protocols=["http", "https", "mailto"],
strip=True,
)
class QuestionAdminForm(forms.ModelForm):
class Meta:
model = Question
fields = "__all__"
widgets = {
"prompt": forms.Textarea(
attrs={
"data-rich-text-editor": "prompt",
"rows": 7,
}
)
}
def clean_prompt(self):
value = self.cleaned_data.get("prompt") or ""
return bleach.clean(
value,
tags=RICH_TEXT_TAGS,
attributes=RICH_TEXT_ATTRIBUTES,
protocols=["http", "https", "mailto"],
strip=True,
)
class QuestionGroupAdminForm(forms.ModelForm):
class Meta:
model = QuestionGroup
fields = "__all__"
widgets = {"instructions": forms.Textarea(attrs={"rows": 4}), "layout_html": forms.Textarea(attrs={"rows": 14})}
def clean_layout_html(self):
value = self.cleaned_data.get("layout_html") or ""
allowed = RICH_TEXT_TAGS + ["div", "section", "table", "thead", "tbody", "tr", "th", "td", "caption", "span"]
return bleach.clean(value, tags=allowed, attributes={}, strip=True)
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(item, initial) for item in data]
return [single_file_clean(data, initial)]
class HtmlExamImportForm(forms.Form):
title = forms.CharField(
label="Test title",
max_length=200,
help_text="Use a clear name such as Academic Reading Mock Test 2.",
)
description = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 3}),
)
section_type = forms.ChoiceField(
label="Skill",
choices=Section.SECTION_TYPES,
initial="reading",
help_text="The test category and every uploaded section will use this skill.",
)
html_files = MultipleFileField(
widget=MultipleFileInput(attrs={"accept": ".html,.htm,text/html"}),
help_text="Choose one complete HTML or HTM test file.",
)
publish = forms.BooleanField(
label="Publish immediately",
required=False,
help_text="Draft is recommended until questions and answers have been reviewed.",
)
def clean_html_files(self):
files = self.cleaned_data["html_files"]
if len(files) != 1:
raise ValidationError("Upload exactly one complete HTML file.")
for uploaded in files:
if not uploaded.name.lower().endswith((".html", ".htm")):
raise ValidationError(f"{uploaded.name} is not an HTML file.")
if uploaded.size > 2 * 1024 * 1024:
raise ValidationError(f"{uploaded.name} is larger than 2 MB.")
return files
class ExcelExamImportForm(forms.Form):
excel_file = forms.FileField(
label="Excel workbook",
widget=forms.ClearableFileInput(attrs={"accept": ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}),
help_text="Upload a completed IELTS_Test_Import_Template.xlsx file (maximum 5 MB).",
)
publish = forms.BooleanField(
label="Publish immediately",
required=False,
help_text="Draft is recommended until you open and review the imported test.",
)
def clean_excel_file(self):
uploaded = self.cleaned_data["excel_file"]
if not uploaded.name.lower().endswith(".xlsx"):
raise ValidationError("Upload an .xlsx workbook.")
if uploaded.size > 5 * 1024 * 1024:
raise ValidationError("The workbook is larger than 5 MB.")
return uploaded
class ListeningTestSetupForm(forms.Form):
title = forms.CharField(
label="Listening test title",
max_length=200,
help_text="For example: Listening Practice Test 1.",
)
description = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 3}),
help_text="A short note students will see before starting the test.",
)
audio_file = forms.FileField(
label="Listening audio",
widget=forms.ClearableFileInput(attrs={"accept": "audio/mpeg,audio/mp4,audio/wav,audio/ogg,.mp3,.m4a,.wav,.ogg"}),
help_text="MP3 is recommended. Maximum file size: 30 MB.",
)
time_limit_minutes = forms.IntegerField(
label="Time limit (minutes)",
min_value=1,
initial=30,
help_text="Use 30 minutes for a standard IELTS Listening practice test.",
)
def clean_audio_file(self):
uploaded = self.cleaned_data["audio_file"]
if not uploaded.name.lower().endswith((".mp3", ".m4a", ".wav", ".ogg")):
raise ValidationError("Upload an MP3, M4A, WAV, or OGG audio file.")
if uploaded.size > 30 * 1024 * 1024:
raise ValidationError("The audio file must be 30 MB or smaller.")
return uploaded
+28
View File
@@ -0,0 +1,28 @@
import math
# IELTS Academic Reading conversion commonly used for 40-question papers.
# Scores between published boundaries are represented in 0.5-band steps.
ACADEMIC_READING_BANDS = (
(39, 9.0), (37, 8.5), (35, 8.0), (33, 7.5), (30, 7.0),
(27, 6.5), (23, 6.0), (19, 5.5), (15, 5.0), (13, 4.5),
(10, 4.0), (8, 3.5), (6, 3.0), (4, 2.5), (3, 2.0),
(2, 1.5), (1, 1.0), (0, 0.0),
)
def academic_reading_band(correct, total=40):
"""Return (band, equivalent_40_score, is_estimate) for an objective Reading score."""
if total <= 0:
return None, None, False
correct = max(0, min(int(correct), int(total)))
if total == 40:
equivalent = correct
estimated = False
else:
# Round half upward so Python's banker rounding does not disadvantage
# passage-sized practice scores at exact .5 boundaries.
equivalent = min(40, math.floor((correct * 40 / total) + 0.5))
estimated = True
band = next(band for minimum, band in ACADEMIC_READING_BANDS if equivalent >= minimum)
return band, equivalent, estimated
+343
View File
@@ -0,0 +1,343 @@
import re
from dataclasses import asdict, dataclass
from html import unescape
from html.parser import HTMLParser
from django.core.exceptions import ValidationError
from django.db import transaction
from .models import ExamSet, Question, QuestionGroup, Section
class HtmlImportError(ValueError):
pass
def _classes(attrs):
return set(dict(attrs).get("class", "").split())
def _clean_text(value):
value = unescape(value).replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return re.sub(r"\n{3,}", "\n\n", value).strip()
def _clean_inline_text(value):
return re.sub(r"\s+", " ", unescape(value).replace("\xa0", " ")).strip()
class IeltsHtmlParser(HTMLParser):
"""Extracts structured content without executing or retaining uploaded markup."""
block_tags = {"p", "div", "h1", "h2", "h3", "h4", "li", "br"}
void_tags = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.passage_depth = 0
self.passage_parts = []
self.current_question = None
self.question_depth = 0
self.question_text_depth = 0
self.current_label = None
self.label_depth = 0
self.current_option = None
self.option_depth = 0
self.group_depth = 0
self.group_parts = []
self.group_context_frozen = False
self.questions = []
def handle_starttag(self, tag, attrs):
attrs_dict = dict(attrs)
classes = _classes(attrs)
if "question-group" in classes and not self.group_depth:
self.group_depth = 1
self.group_parts = []
self.group_context_frozen = False
elif self.group_depth:
if not self.group_context_frozen and tag in self.block_tags:
self.group_parts.append("\n")
if tag not in self.void_tags:
self.group_depth += 1
if "passage-content" in classes and not self.passage_depth:
self.passage_depth = 1
elif self.passage_depth:
if tag in self.block_tags:
self.passage_parts.append("\n")
if tag not in self.void_tags:
self.passage_depth += 1
if self.current_question is None and "question" in classes and attrs_dict.get("data-question"):
try:
order = int(attrs_dict["data-question"])
except ValueError:
order = len(self.questions) + 1
self.current_question = {
"order": order,
"prompt_parts": [],
"input_types": set(),
"select_options": [],
"radio_options": [],
"group_context": _clean_text("".join(self.group_parts)),
}
self.group_context_frozen = True
self.question_depth = 1
return
if self.current_question is None:
return
if tag not in self.void_tags:
self.question_depth += 1
if "question-text" in classes:
self.question_text_depth = 1
elif self.question_text_depth and tag not in self.void_tags:
self.question_text_depth += 1
if tag == "input":
input_type = attrs_dict.get("type", "text").lower()
self.current_question["input_types"].add(input_type)
if input_type == "text" and self.question_text_depth:
self.current_question["prompt_parts"].append(" ____ ")
if input_type == "radio" and self.current_label is not None:
self.current_label["value"] = attrs_dict.get("value", "").strip()
elif tag == "label":
self.current_label = {"value": "", "parts": []}
self.label_depth = 1
elif self.current_label is not None and tag not in self.void_tags:
self.label_depth += 1
if tag == "option":
self.current_option = {
"value": attrs_dict.get("value", "").strip(),
"parts": [],
"disabled": "disabled" in attrs_dict,
}
self.option_depth = 1
elif self.current_option is not None and tag not in self.void_tags:
self.option_depth += 1
def handle_startendtag(self, tag, attrs):
self.handle_starttag(tag, attrs)
if tag not in self.void_tags:
self.handle_endtag(tag)
def handle_data(self, data):
if self.passage_depth:
self.passage_parts.append(data)
if self.group_depth and not self.group_context_frozen:
self.group_parts.append(data)
if self.current_question is None:
return
if self.question_text_depth:
self.current_question["prompt_parts"].append(data)
if self.current_label is not None:
self.current_label["parts"].append(data)
if self.current_option is not None:
self.current_option["parts"].append(data)
def handle_endtag(self, tag):
if self.passage_depth:
if tag in self.block_tags:
self.passage_parts.append("\n")
self.passage_depth -= 1
if self.group_depth:
if not self.group_context_frozen and tag in self.block_tags:
self.group_parts.append("\n")
self.group_depth -= 1
if self.group_depth == 0:
self.group_parts = []
self.group_context_frozen = False
if self.current_question is None:
return
if self.current_option is not None:
self.option_depth -= 1
if self.option_depth == 0:
text = _clean_text("".join(self.current_option["parts"]))
value = self.current_option["value"] or text
if value and not self.current_option["disabled"]:
self.current_question["select_options"].append(value)
self.current_option = None
if self.current_label is not None:
self.label_depth -= 1
if self.label_depth == 0:
value = self.current_label["value"]
text = _clean_inline_text("".join(self.current_label["parts"]))
if value:
self.current_question["radio_options"].append((value, text or value))
self.current_label = None
if self.question_text_depth:
self.question_text_depth -= 1
self.question_depth -= 1
if self.question_depth == 0:
self.questions.append(self.current_question)
self.current_question = None
@dataclass
class ImportedQuestion:
order: int
question_type: str
prompt: str
options: list | None
correct_answer: str
@dataclass
class ImportedSection:
source_name: str
passage_text: str
questions: list
warnings: list
def as_payload(self):
return {
"source_name": self.source_name,
"passage_text": self.passage_text,
"questions": [asdict(question) for question in self.questions],
"warnings": self.warnings,
}
def _extract_correct_answers(html):
match = re.search(
r"(?:const|let|var)\s+correctAnswers\s*=\s*\{(?P<body>.*?)\}\s*;?",
html,
flags=re.IGNORECASE | re.DOTALL,
)
if not match:
return {}
pairs = re.findall(
r"[\"']?q?(\d+)[\"']?\s*:\s*[\"']([^\"']*)[\"']",
match.group("body"),
flags=re.IGNORECASE,
)
return {int(number): unescape(answer).strip() for number, answer in pairs}
def parse_ielts_html(content, source_name="uploaded.html", section_type="reading"):
if isinstance(content, bytes):
try:
html = content.decode("utf-8")
except UnicodeDecodeError:
html = content.decode("cp1252")
else:
html = content
parser = IeltsHtmlParser()
parser.feed(html)
answers = _extract_correct_answers(html)
passage = _clean_text("".join(parser.passage_parts))
if section_type == "reading" and not passage:
raise HtmlImportError(f"{source_name}: no element with class 'passage-content' was found.")
if not parser.questions:
raise HtmlImportError(f"{source_name}: no numbered question blocks were found.")
imported = []
warnings = []
seen_orders = set()
last_matching_context = None
for raw in parser.questions:
order = raw["order"]
if order in seen_orders:
raise HtmlImportError(f"{source_name}: question number {order} appears more than once.")
seen_orders.add(order)
prompt = _clean_text("".join(raw["prompt_parts"]))
prompt = re.sub(rf"^\s*{order}\s*[.)]?\s*", "", prompt).strip()
if not prompt:
raise HtmlImportError(f"{source_name}: question {order} has no readable prompt.")
answer = answers.get(order, "")
input_types = raw["input_types"]
if section_type == "writing":
question_type = "essay"
options = None
answer = ""
elif section_type == "speaking":
question_type = "speaking"
options = None
answer = ""
elif raw["select_options"]:
question_type = "matching"
options = raw["select_options"]
context = raw.get("group_context", "")
if context and context != last_matching_context:
prompt = f"{context}\n\n{prompt}"
last_matching_context = context
elif "radio" in input_types:
question_type = "mcq"
options = [text for _value, text in raw["radio_options"]]
answer_map = {value.casefold(): text for value, text in raw["radio_options"]}
answer = answer_map.get(answer.casefold(), answer)
elif "text" in input_types:
question_type = "gap"
options = None
else:
warnings.append(f"Question {order} had no recognized input and was treated as a gap fill.")
question_type = "gap"
options = None
if question_type in {"mcq", "gap", "matching"} and not answer:
raise HtmlImportError(
f"{source_name}: correct answer for question {order} was not found in correctAnswers."
)
if question_type == "mcq" and answer not in options:
raise HtmlImportError(
f"{source_name}: answer for question {order} does not match an available option."
)
imported.append(ImportedQuestion(order, question_type, prompt, options, answer))
return ImportedSection(source_name, passage, imported, warnings)
@transaction.atomic
def create_exam_from_payload(payload):
exam = ExamSet(
title=payload["title"],
description=payload.get("description", ""),
category=payload["category"],
is_published=False,
delivery_mode=payload.get("delivery_mode", "native"),
source_html=payload.get("source_html", ""),
)
exam.full_clean()
exam.save()
for section_order, section_data in enumerate(payload.get("sections", []), start=1):
section = Section(
exam_set=exam,
order=section_data.get("order", section_order),
section_type=section_data.get("section_type", payload.get("section_type", "reading")),
time_limit_minutes=section_data.get("time_limit_minutes", payload.get("time_limit_minutes", 60)),
passage_text=section_data["passage_text"],
)
section.full_clean()
section.save()
groups_by_key = {}
for group_data in section_data.get("groups", []):
group = QuestionGroup(section=section, **group_data)
group.full_clean()
group.save()
groups_by_key[group.key] = group
for question_data in section_data["questions"]:
question_data = question_data.copy()
group_key = question_data.pop("group_key", "")
if group_key:
question_data["group"] = groups_by_key[group_key]
question = Question(section=section, **question_data)
question.full_clean()
question.save()
if payload.get("publish") and exam.is_ready:
exam.is_published = True
exam.save(update_fields=["is_published"])
return exam
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,125 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from exams.models import ExamSet, Question, Section
class Command(BaseCommand):
help = "Create an original, reusable IELTS skills diagnostic test."
@transaction.atomic
def handle(self, *args, **options):
exam, created = ExamSet.objects.get_or_create(
title="IELTS Skills Diagnostic",
defaults={
"description": (
"An original three-skill diagnostic covering reading, writing, and "
"speaking. This is practice material, not an official IELTS test."
),
"is_published": True,
},
)
if not created and exam.sections.exists():
self.stdout.write(self.style.WARNING("Diagnostic test already exists; no changes made."))
return
exam.description = (
"An original three-skill diagnostic covering reading, writing, and speaking. "
"This is practice material, not an official IELTS test."
)
exam.is_published = True
exam.save(update_fields=["description", "is_published"])
reading = Section.objects.create(
exam_set=exam,
order=1,
section_type="reading",
time_limit_minutes=20,
passage_text=(
"Pocket Parks in Growing Cities\n\n"
"As cities become more densely populated, planners are looking for ways to "
"create useful green spaces on small pieces of unused land. These compact areas, "
"often called pocket parks, may occupy a single vacant lot or a widened section of "
"pavement. Although they are much smaller than traditional public parks, they can "
"provide seating, shade, and a quiet place away from traffic.\n\n"
"The first widely recognised pocket park opened in New York City in 1967. Its "
"design used trees, a waterfall, and movable chairs to make a narrow site feel calm "
"and welcoming. Similar projects have since appeared in cities around the world. "
"Researchers have reported that even brief contact with greenery can reduce stress, "
"while local businesses may benefit from increased pedestrian activity.\n\n"
"Pocket parks are not a complete solution to the need for urban open space. They "
"cannot provide large playing fields, long walking routes, or major wildlife habitats. "
"They also require regular maintenance and careful lighting. However, when they are "
"planned with local residents, they can turn neglected land into a shared community asset."
),
)
Question.objects.bulk_create(
[
Question(
section=reading,
order=1,
question_type="mcq",
prompt="What is a pocket park usually created on?",
options=[
"A small unused urban site",
"A large wildlife reserve",
"A private sports field",
],
correct_answer="A small unused urban site",
),
Question(
section=reading,
order=2,
question_type="gap",
prompt="In which year did the first widely recognised pocket park open?",
correct_answer="1967",
),
Question(
section=reading,
order=3,
question_type="mcq",
prompt="Which limitation of pocket parks is mentioned in the passage?",
options=[
"They cannot provide large playing fields",
"They always reduce pedestrian activity",
"They cannot contain trees or seating",
],
correct_answer="They cannot provide large playing fields",
),
]
)
writing = Section.objects.create(
exam_set=exam,
order=2,
section_type="writing",
time_limit_minutes=30,
)
Question.objects.create(
section=writing,
order=1,
question_type="essay",
prompt=(
"Some people believe every neighbourhood should have a public green space. "
"To what extent do you agree or disagree? Give reasons and relevant examples."
),
)
speaking = Section.objects.create(
exam_set=exam,
order=3,
section_type="speaking",
time_limit_minutes=10,
)
Question.objects.create(
section=speaking,
order=1,
question_type="speaking",
prompt=(
"Describe a public place in your town or city that you enjoy visiting. "
"Explain where it is, what people do there, and why it is important to you. "
"Record your response and upload the audio, or type notes as a fallback."
),
)
self.stdout.write(self.style.SUCCESS("Created and published IELTS Skills Diagnostic."))
@@ -0,0 +1,210 @@
from pathlib import Path
from django.core.files import File
from django.core.management.base import BaseCommand
from django.db import transaction
from exams.models import ExamSet, Question, Section
PASSAGE = (
"Pocket Parks in Growing Cities\n\n"
"As cities become more densely populated, planners are looking for ways to create useful "
"green spaces on small pieces of unused land. These compact areas, often called pocket "
"parks, may occupy a single vacant lot or a widened section of pavement. Although they are "
"much smaller than traditional public parks, they can provide seating, shade, and a quiet "
"place away from traffic.\n\n"
"The first widely recognised pocket park opened in New York City in 1967. Its design used "
"trees, a waterfall, and movable chairs to make a narrow site feel calm and welcoming. "
"Similar projects have since appeared in cities around the world. Researchers report that "
"even brief contact with greenery can reduce stress, while local businesses may benefit "
"from increased pedestrian activity.\n\n"
"Pocket parks are not a complete solution to the need for urban open space. They cannot "
"provide large playing fields, long walking routes, or major wildlife habitats. However, "
"when planned with local residents, they can turn neglected land into a shared asset."
)
class Command(BaseCommand):
help = "Create separate Reading, Listening, Writing, Speaking, and Full Mock tests."
def add_reading(self, exam, order=1):
section = Section.objects.create(
exam_set=exam,
order=order,
section_type="reading",
time_limit_minutes=20,
passage_text=PASSAGE,
)
Question.objects.bulk_create(
[
Question(
section=section,
order=1,
question_type="mcq",
prompt="What is a pocket park usually created on?",
options=["A small unused urban site", "A wildlife reserve", "A sports field"],
correct_answer="A small unused urban site",
),
Question(
section=section,
order=2,
question_type="gap",
prompt="In which year did the first widely recognised pocket park open?",
correct_answer="1967",
),
Question(
section=section,
order=3,
question_type="mcq",
prompt="Which limitation is mentioned in the passage?",
options=[
"They cannot provide large playing fields",
"They always reduce foot traffic",
"They cannot contain trees",
],
correct_answer="They cannot provide large playing fields",
),
]
)
def add_listening(self, exam, order=1):
section = Section.objects.create(
exam_set=exam,
order=order,
section_type="listening",
time_limit_minutes=10,
)
audio_path = Path(__file__).resolve().parents[2] / "seed_assets" / "community_library.wav"
with audio_path.open("rb") as audio:
section.audio_file.save("community_library.wav", File(audio), save=True)
Question.objects.bulk_create(
[
Question(
section=section,
order=1,
question_type="mcq",
prompt="What time will the library close on weekdays?",
options=["4:00 pm", "7:00 pm", "9:00 pm"],
correct_answer="7:00 pm",
),
Question(
section=section,
order=2,
question_type="gap",
prompt="On which floor is the new study room?",
correct_answer="second",
),
Question(
section=section,
order=3,
question_type="mcq",
prompt="What may students take into the study room?",
options=["Hot food", "Covered drinks", "Uncovered drinks"],
correct_answer="Covered drinks",
),
]
)
def add_writing(self, exam, order=1):
section = Section.objects.create(
exam_set=exam,
order=order,
section_type="writing",
time_limit_minutes=40,
)
Question.objects.create(
section=section,
order=1,
question_type="essay",
prompt=(
"Some people believe every neighbourhood should have a public green space. "
"To what extent do you agree or disagree? Give reasons and relevant examples."
),
)
def add_speaking(self, exam, order=1):
section = Section.objects.create(
exam_set=exam,
order=order,
section_type="speaking",
time_limit_minutes=15,
)
Question.objects.bulk_create(
[
Question(
section=section,
order=1,
question_type="speaking",
prompt="Describe a public place in your town or city that you enjoy visiting.",
),
Question(
section=section,
order=2,
question_type="speaking",
prompt="Why are shared public spaces important for a community?",
),
]
)
def create_exam(self, title, category, description, section_builders):
exam, created = ExamSet.objects.update_or_create(
title=title,
defaults={
"category": category,
"description": description,
"is_published": True,
},
)
if not created and exam.sections.exists():
return exam, False
for order, builder in enumerate(section_builders, start=1):
builder(exam, order)
return exam, True
@transaction.atomic
def handle(self, *args, **options):
definitions = [
(
"Reading Practice 1",
"reading",
"Focused reading practice with an original passage and objective questions.",
[self.add_reading],
),
(
"Listening Practice 1",
"listening",
"Focused listening practice with original audio and objective questions.",
[self.add_listening],
),
(
"Writing Practice 1",
"writing",
"Timed essay practice ready for instructor review and band feedback.",
[self.add_writing],
),
(
"Speaking Practice 1",
"speaking",
"Speaking prompts with audio-response upload and instructor review.",
[self.add_speaking],
),
(
"IELTS Full Mock Test 1",
"full",
"A compact full mock covering Listening, Reading, Writing, and Speaking.",
[self.add_listening, self.add_reading, self.add_writing, self.add_speaking],
),
]
created_count = 0
for definition in definitions:
_, created = self.create_exam(*definition)
created_count += int(created)
ExamSet.objects.filter(title="IELTS Skills Diagnostic").update(is_published=False)
self.stdout.write(
self.style.SUCCESS(
f"Practice library ready: {len(definitions)} published categories "
f"({created_count} newly populated)."
)
)
+69
View File
@@ -0,0 +1,69 @@
# Generated by Django 6.0.7 on 2026-07-12 07:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ExamSet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Section',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('section_type', models.CharField(choices=[('listening', 'Listening'), ('reading', 'Reading'), ('writing', 'Writing'), ('speaking', 'Speaking')], max_length=20)),
('time_limit_minutes', models.PositiveIntegerField()),
('audio_file', models.FileField(blank=True, null=True, upload_to='audio/')),
('exam_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='exams.examset')),
],
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField()),
('question_type', models.CharField(choices=[('mcq', 'Multiple Choice'), ('gap', 'Gap Fill'), ('matching', 'Matching'), ('essay', 'Essay'), ('speaking', 'Speaking Prompt')], max_length=20)),
('prompt', models.TextField()),
('options', models.JSONField(blank=True, null=True)),
('correct_answer', models.TextField(blank=True, null=True)),
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='exams.section')),
],
),
migrations.CreateModel(
name='StudentAttempt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('started_at', models.DateTimeField(auto_now_add=True)),
('submitted_at', models.DateTimeField(blank=True, null=True)),
('is_complete', models.BooleanField(default=False)),
('exam_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exams.examset')),
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='StudentAnswer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('answer_text', models.TextField(blank=True)),
('is_correct', models.BooleanField(null=True)),
('manual_score', models.FloatField(blank=True, null=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exams.question')),
('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='exams.studentattempt')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-07-13 06:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exams', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='section',
name='passage_text',
field=models.TextField(blank=True, null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-07-17 11:24
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exams', '0002_section_passage_text'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddConstraint(
model_name='studentanswer',
constraint=models.UniqueConstraint(fields=('attempt', 'question'), name='unique_answer_per_attempt_question'),
),
migrations.AddConstraint(
model_name='studentattempt',
constraint=models.UniqueConstraint(condition=models.Q(('is_complete', False)), fields=('student', 'exam_set'), name='unique_active_attempt_per_exam'),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 6.0.7 on 2026-07-17 11:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exams', '0003_studentanswer_unique_answer_per_attempt_question_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='question',
options={'ordering': ['order', 'id']},
),
migrations.AlterModelOptions(
name='section',
options={'ordering': ['order', 'id']},
),
migrations.AddField(
model_name='section',
name='order',
field=models.PositiveIntegerField(default=1),
),
migrations.AddConstraint(
model_name='question',
constraint=models.UniqueConstraint(fields=('section', 'order'), name='unique_question_order_per_section'),
),
migrations.AddConstraint(
model_name='section',
constraint=models.UniqueConstraint(fields=('exam_set', 'order'), name='unique_section_order_per_exam'),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 6.0.7 on 2026-07-17 11:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exams', '0004_alter_question_options_alter_section_options_and_more'),
]
operations = [
migrations.AddField(
model_name='studentattempt',
name='current_section',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='exams.section'),
),
migrations.AddField(
model_name='studentattempt',
name='section_deadline',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='studentattempt',
name='section_started_at',
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 6.0.7 on 2026-07-17 12:46
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exams', '0005_studentattempt_current_section_and_more'),
]
operations = [
migrations.AddField(
model_name='examset',
name='description',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='examset',
name='is_published',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='studentanswer',
name='manual_score',
field=models.FloatField(blank=True, help_text='Manual band score from 0.0 to 9.0 for writing or speaking responses.', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(9)]),
),
]
@@ -0,0 +1,18 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("exams", "0008_examset_category")]
operations = [
migrations.AddField(
model_name="question",
name="explanation",
field=models.TextField(blank=True, help_text="Shown to students in review mode after they submit the test."),
),
migrations.AddField(
model_name="question",
name="passage_reference",
field=models.TextField(blank=True, help_text="Optional exact sentence or short excerpt from the reading passage that supports this answer."),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 6.0.7 on 2026-07-17 13:00
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exams', '0006_examset_description_examset_is_published_and_more'),
]
operations = [
migrations.AddField(
model_name='studentanswer',
name='audio_response',
field=models.FileField(blank=True, help_text='Optional speaking response. Maximum size: 20 MB.', null=True, upload_to='speaking_responses/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(['mp3', 'm4a', 'wav', 'webm', 'ogg'])]),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-07-17 13:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exams', '0007_studentanswer_audio_response'),
]
operations = [
migrations.AddField(
model_name='examset',
name='category',
field=models.CharField(choices=[('reading', 'Reading'), ('listening', 'Listening'), ('writing', 'Writing'), ('speaking', 'Speaking'), ('full', 'Full Mock Test')], default='full', max_length=20),
),
]
@@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("exams", "0007_question_explanation_question_passage_reference")]
operations = [
migrations.AddField(
model_name="examset",
name="delivery_mode",
field=models.CharField(
choices=[("native", "Adaptive platform test"), ("exact_html", "Exact uploaded HTML")],
default="native",
max_length=20,
),
),
migrations.AddField(
model_name="examset",
name="source_html",
field=models.TextField(blank=True),
),
]
@@ -0,0 +1,39 @@
# Generated by Django 6.0.7 on 2026-07-19 16:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exams', '0009_examset_exact_html'),
]
operations = [
migrations.CreateModel(
name='QuestionGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(help_text='Short identifier used by Excel, for example notes_1.', max_length=60)),
('order', models.PositiveIntegerField(default=1)),
('layout_type', models.CharField(choices=[('notes', 'Notes / summary'), ('table', 'Table / form'), ('flow', 'Flow chart')], default='notes', max_length=20)),
('title', models.CharField(blank=True, max_length=200)),
('instructions', models.TextField(blank=True)),
('layout_html', models.TextField(help_text='Formatted worksheet HTML. Insert blanks with [[question number]], for example [[1]].')),
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='question_groups', to='exams.section')),
],
options={
'ordering': ['order', 'id'],
},
),
migrations.AddField(
model_name='question',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questions', to='exams.questiongroup'),
),
migrations.AddConstraint(
model_name='questiongroup',
constraint=models.UniqueConstraint(fields=('section', 'key'), name='unique_question_group_key_per_section'),
),
]
@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("exams", "0010_questiongroup_question_group_and_more"),
]
operations = [
migrations.AddField(
model_name="examset",
name="access_level",
field=models.CharField(
choices=[
("free", "Free"),
("premium", "Lifetime Premium"),
],
db_index=True,
default="free",
max_length=20,
),
),
]
View File
+198
View File
@@ -0,0 +1,198 @@
from django.db import models
from django.contrib.auth.models import User
from django.db.models import Q
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MaxValueValidator, MinValueValidator
class ExamSet(models.Model):
ACCESS_FREE = "free"
ACCESS_PREMIUM = "premium"
ACCESS_LEVELS = [
(ACCESS_FREE, "Free"),
(ACCESS_PREMIUM, "Lifetime Premium"),
]
DELIVERY_MODES = [("native", "Adaptive platform test"), ("exact_html", "Exact uploaded HTML")]
CATEGORY_CHOICES = [
("reading", "Reading"),
("listening", "Listening"),
("writing", "Writing"),
("speaking", "Speaking"),
("full", "Full Mock Test"),
]
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default="full")
access_level = models.CharField(
max_length=20,
choices=ACCESS_LEVELS,
default=ACCESS_FREE,
db_index=True,
)
is_published = models.BooleanField(default=False)
delivery_mode = models.CharField(max_length=20, choices=DELIVERY_MODES, default="native")
source_html = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
@property
def is_ready(self):
if self.delivery_mode == "exact_html":
return bool((self.source_html or "").strip())
sections = list(self.sections.all())
return bool(sections) and all(section.questions.exists() for section in sections)
class Section(models.Model):
SECTION_TYPES = [('listening','Listening'), ('reading','Reading'),
('writing','Writing'), ('speaking','Speaking')]
exam_set = models.ForeignKey(ExamSet, on_delete=models.CASCADE, related_name='sections')
order = models.PositiveIntegerField(default=1)
section_type = models.CharField(max_length=20, choices=SECTION_TYPES)
time_limit_minutes = models.PositiveIntegerField()
audio_file = models.FileField(upload_to='audio/', blank=True, null=True)
passage_text = models.TextField(blank=True, null=True)
def __str__(self):
return f"{self.exam_set.title} - {self.section_type}"
class Meta:
ordering = ["order", "id"]
constraints = [
models.UniqueConstraint(
fields=["exam_set", "order"],
name="unique_section_order_per_exam",
),
]
class QuestionGroup(models.Model):
LAYOUT_TYPES = [("notes", "Notes / summary"), ("table", "Table / form"), ("flow", "Flow chart")]
section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name="question_groups")
key = models.SlugField(max_length=60, help_text="Short identifier used by Excel, for example notes_1.")
order = models.PositiveIntegerField(default=1)
layout_type = models.CharField(max_length=20, choices=LAYOUT_TYPES, default="notes")
title = models.CharField(max_length=200, blank=True)
instructions = models.TextField(blank=True)
layout_html = models.TextField(help_text="Formatted worksheet HTML. Insert blanks with [[question number]], for example [[1]].")
def __str__(self):
return self.title or f"{self.section}{self.key}"
class Meta:
ordering = ["order", "id"]
constraints = [models.UniqueConstraint(fields=["section", "key"], name="unique_question_group_key_per_section")]
class Question(models.Model):
QUESTION_TYPES = [('mcq','Multiple Choice'), ('gap','Gap Fill'),
('matching','Matching'), ('essay','Essay'), ('speaking','Speaking Prompt')]
section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name='questions')
group = models.ForeignKey(QuestionGroup, on_delete=models.SET_NULL, related_name="questions", blank=True, null=True)
order = models.PositiveIntegerField()
question_type = models.CharField(max_length=20, choices=QUESTION_TYPES)
prompt = models.TextField()
options = models.JSONField(blank=True, null=True)
correct_answer = models.TextField(blank=True, null=True)
explanation = models.TextField(
blank=True,
help_text="Shown to students in review mode after they submit the test.",
)
passage_reference = models.TextField(
blank=True,
help_text="Optional exact sentence or short excerpt from the reading passage that supports this answer.",
)
def __str__(self):
return f"Q{self.order}: {self.prompt[:40]}"
def clean(self):
errors = {}
if self.group_id and self.section_id and self.group.section_id != self.section_id:
errors["group"] = "The question group must belong to the same section."
if self.question_type in {"mcq", "gap", "matching"} and not (
self.correct_answer or ""
).strip():
errors["correct_answer"] = "Objective questions require a correct answer."
if self.question_type == "mcq":
if not isinstance(self.options, list) or len(self.options) < 2:
errors["options"] = "Multiple-choice questions require at least two options."
elif self.correct_answer and self.correct_answer not in self.options:
errors["correct_answer"] = "The correct answer must match one of the options."
if errors:
raise ValidationError(errors)
class Meta:
ordering = ["order", "id"]
constraints = [
models.UniqueConstraint(
fields=["section", "order"],
name="unique_question_order_per_section",
),
]
class StudentAttempt(models.Model):
student = models.ForeignKey(User, on_delete=models.CASCADE)
exam_set = models.ForeignKey(ExamSet, on_delete=models.CASCADE)
started_at = models.DateTimeField(auto_now_add=True)
submitted_at = models.DateTimeField(null=True, blank=True)
is_complete = models.BooleanField(default=False)
current_section = models.ForeignKey(
Section,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
section_started_at = models.DateTimeField(null=True, blank=True)
section_deadline = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["student", "exam_set"],
condition=Q(is_complete=False),
name="unique_active_attempt_per_exam",
),
]
class StudentAnswer(models.Model):
attempt = models.ForeignKey(StudentAttempt, on_delete=models.CASCADE, related_name='answers')
question = models.ForeignKey(Question, on_delete=models.CASCADE)
answer_text = models.TextField(blank=True)
audio_response = models.FileField(
upload_to="speaking_responses/%Y/%m/",
blank=True,
null=True,
validators=[FileExtensionValidator(["mp3", "m4a", "wav", "webm", "ogg"])],
help_text="Optional speaking response. Maximum size: 20 MB.",
)
is_correct = models.BooleanField(null=True)
manual_score = models.FloatField(
null=True,
blank=True,
validators=[MinValueValidator(0), MaxValueValidator(9)],
help_text="Manual band score from 0.0 to 9.0 for writing or speaking responses.",
)
def clean(self):
if self.manual_score is not None and self.question.question_type not in {
"essay",
"speaking",
}:
raise ValidationError(
{"manual_score": "Manual scores are only valid for writing or speaking answers."}
)
if self.audio_response and self.audio_response.size > 20 * 1024 * 1024:
raise ValidationError({"audio_response": "Audio responses must be 20 MB or smaller."})
if self.audio_response and self.question.question_type != "speaking":
raise ValidationError(
{"audio_response": "Audio responses are only valid for speaking questions."}
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["attempt", "question"],
name="unique_answer_per_attempt_question",
),
]
@@ -0,0 +1,759 @@
<html xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word"
xmlns:m="http://schemas.microsoft.com/office/2004/12/omml"
xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta http-equiv=Content-Type content="text/html; charset=windows-1251">
<meta name=ProgId content=Word.Document>
<meta name=Generator content="Microsoft Word 15">
<meta name=Originator content="Microsoft Word 15">
<link rel=File-List href="student_dashboard.files/filelist.xml">
<!--[if gte mso 9]><xml>
<o:DocumentProperties>
<o:Author>sgd</o:Author>
<o:LastAuthor>sgd</o:LastAuthor>
<o:Revision>2</o:Revision>
<o:TotalTime>1</o:TotalTime>
<o:Created>2026-07-12T08:17:00Z</o:Created>
<o:LastSaved>2026-07-12T08:17:00Z</o:LastSaved>
<o:Pages>1</o:Pages>
<o:Version>16.00</o:Version>
</o:DocumentProperties>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
</o:OfficeDocumentSettings>
</xml><![endif]-->
<link rel=themeData href="student_dashboard.files/themedata.thmx">
<link rel=colorSchemeMapping
href="student_dashboard.files/colorschememapping.xml">
<!--[if gte mso 9]><xml>
<w:WordDocument>
<w:TrackMoves>false</w:TrackMoves>
<w:TrackFormatting/>
<w:PunctuationKerning/>
<w:ValidateAgainstSchemas/>
<w:SaveIfXMLInvalid>false</w:SaveIfXMLInvalid>
<w:IgnoreMixedContent>false</w:IgnoreMixedContent>
<w:AlwaysShowPlaceholderText>false</w:AlwaysShowPlaceholderText>
<w:DoNotPromoteQF/>
<w:LidThemeOther>RU</w:LidThemeOther>
<w:LidThemeAsian>X-NONE</w:LidThemeAsian>
<w:LidThemeComplexScript>X-NONE</w:LidThemeComplexScript>
<w:Compatibility>
<w:BreakWrappedTables/>
<w:SnapToGridInCell/>
<w:WrapTextWithPunct/>
<w:UseAsianBreakRules/>
<w:DontGrowAutofit/>
<w:SplitPgBreakAndParaMark/>
<w:EnableOpenTypeKerning/>
<w:DontFlipMirrorIndents/>
<w:OverrideTableStyleHps/>
</w:Compatibility>
<m:mathPr>
<m:mathFont m:val="Cambria Math"/>
<m:brkBin m:val="before"/>
<m:brkBinSub m:val="&#45;-"/>
<m:smallFrac m:val="off"/>
<m:dispDef/>
<m:lMargin m:val="0"/>
<m:rMargin m:val="0"/>
<m:defJc m:val="centerGroup"/>
<m:wrapIndent m:val="1440"/>
<m:intLim m:val="subSup"/>
<m:naryLim m:val="undOvr"/>
</m:mathPr></w:WordDocument>
</xml><![endif]--><!--[if gte mso 9]><xml>
<w:LatentStyles DefLockedState="false" DefUnhideWhenUsed="false"
DefSemiHidden="false" DefQFormat="false" DefPriority="99"
LatentStyleCount="376">
<w:LsdException Locked="false" Priority="0" QFormat="true" Name="Normal"/>
<w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 1"/>
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="heading 2"/>
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="heading 3"/>
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="heading 4"/>
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="heading 5"/>
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="heading 6"/>
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="heading 7"/>
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="heading 8"/>
<w:LsdException Locked="false" Priority="9" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="heading 9"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 5"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 6"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 7"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 8"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index 9"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 1"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 2"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 3"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 4"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 5"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 6"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 7"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 8"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" Name="toc 9"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Normal Indent"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="footnote text"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="annotation text"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="header"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="footer"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="index heading"/>
<w:LsdException Locked="false" Priority="35" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="caption"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="table of figures"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="envelope address"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="envelope return"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="footnote reference"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="annotation reference"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="line number"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="page number"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="endnote reference"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="endnote text"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="table of authorities"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="macro"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="toa heading"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Bullet"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Number"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List 5"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Bullet 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Bullet 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Bullet 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Bullet 5"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Number 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Number 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Number 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Number 5"/>
<w:LsdException Locked="false" Priority="10" QFormat="true" Name="Title"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Closing"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Signature"/>
<w:LsdException Locked="false" Priority="1" SemiHidden="true"
UnhideWhenUsed="true" Name="Default Paragraph Font"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Body Text"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Body Text Indent"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Continue"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Continue 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Continue 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Continue 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="List Continue 5"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Message Header"/>
<w:LsdException Locked="false" Priority="11" QFormat="true" Name="Subtitle"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Salutation"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Date"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Body Text First Indent"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Body Text First Indent 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Note Heading"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Body Text 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Body Text 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Body Text Indent 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Body Text Indent 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Block Text"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Hyperlink"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="FollowedHyperlink"/>
<w:LsdException Locked="false" Priority="22" QFormat="true" Name="Strong"/>
<w:LsdException Locked="false" Priority="20" QFormat="true" Name="Emphasis"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Document Map"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Plain Text"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="E-mail Signature"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Top of Form"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Bottom of Form"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Normal (Web)"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Acronym"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Address"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Cite"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Code"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Definition"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Keyboard"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Preformatted"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Sample"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Typewriter"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="HTML Variable"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Normal Table"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="annotation subject"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="No List"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Outline List 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Outline List 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Outline List 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Simple 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Simple 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Simple 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Classic 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Classic 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Classic 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Classic 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Colorful 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Colorful 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Colorful 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Columns 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Columns 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Columns 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Columns 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Columns 5"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Grid 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Grid 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Grid 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Grid 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Grid 5"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Grid 6"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Grid 7"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Grid 8"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table List 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table List 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table List 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table List 4"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table List 5"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table List 6"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table List 7"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table List 8"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table 3D effects 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table 3D effects 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table 3D effects 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Contemporary"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Elegant"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Professional"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Subtle 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Subtle 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Web 1"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Web 2"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Web 3"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Balloon Text"/>
<w:LsdException Locked="false" Priority="39" Name="Table Grid"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Table Theme"/>
<w:LsdException Locked="false" SemiHidden="true" Name="Placeholder Text"/>
<w:LsdException Locked="false" Priority="1" QFormat="true" Name="No Spacing"/>
<w:LsdException Locked="false" Priority="60" Name="Light Shading"/>
<w:LsdException Locked="false" Priority="61" Name="Light List"/>
<w:LsdException Locked="false" Priority="62" Name="Light Grid"/>
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1"/>
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2"/>
<w:LsdException Locked="false" Priority="65" Name="Medium List 1"/>
<w:LsdException Locked="false" Priority="66" Name="Medium List 2"/>
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1"/>
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2"/>
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3"/>
<w:LsdException Locked="false" Priority="70" Name="Dark List"/>
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading"/>
<w:LsdException Locked="false" Priority="72" Name="Colorful List"/>
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid"/>
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 1"/>
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 1"/>
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 1"/>
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 1"/>
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 1"/>
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 1"/>
<w:LsdException Locked="false" SemiHidden="true" Name="Revision"/>
<w:LsdException Locked="false" Priority="34" QFormat="true"
Name="List Paragraph"/>
<w:LsdException Locked="false" Priority="29" QFormat="true" Name="Quote"/>
<w:LsdException Locked="false" Priority="30" QFormat="true"
Name="Intense Quote"/>
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 1"/>
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 1"/>
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 1"/>
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 1"/>
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 1"/>
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 1"/>
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 1"/>
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 1"/>
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 2"/>
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 2"/>
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 2"/>
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 2"/>
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 2"/>
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 2"/>
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 2"/>
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 2"/>
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 2"/>
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 2"/>
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 2"/>
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 2"/>
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 2"/>
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 2"/>
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 3"/>
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 3"/>
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 3"/>
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 3"/>
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 3"/>
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 3"/>
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 3"/>
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 3"/>
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 3"/>
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 3"/>
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 3"/>
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 3"/>
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 3"/>
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 3"/>
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 4"/>
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 4"/>
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 4"/>
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 4"/>
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 4"/>
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 4"/>
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 4"/>
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 4"/>
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 4"/>
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 4"/>
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 4"/>
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 4"/>
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 4"/>
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 4"/>
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 5"/>
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 5"/>
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 5"/>
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 5"/>
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 5"/>
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 5"/>
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 5"/>
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 5"/>
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 5"/>
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 5"/>
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 5"/>
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 5"/>
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 5"/>
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 5"/>
<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 6"/>
<w:LsdException Locked="false" Priority="61" Name="Light List Accent 6"/>
<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 6"/>
<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 6"/>
<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 6"/>
<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 6"/>
<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 6"/>
<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 6"/>
<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 6"/>
<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 6"/>
<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 6"/>
<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 6"/>
<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 6"/>
<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 6"/>
<w:LsdException Locked="false" Priority="19" QFormat="true"
Name="Subtle Emphasis"/>
<w:LsdException Locked="false" Priority="21" QFormat="true"
Name="Intense Emphasis"/>
<w:LsdException Locked="false" Priority="31" QFormat="true"
Name="Subtle Reference"/>
<w:LsdException Locked="false" Priority="32" QFormat="true"
Name="Intense Reference"/>
<w:LsdException Locked="false" Priority="33" QFormat="true" Name="Book Title"/>
<w:LsdException Locked="false" Priority="37" SemiHidden="true"
UnhideWhenUsed="true" Name="Bibliography"/>
<w:LsdException Locked="false" Priority="39" SemiHidden="true"
UnhideWhenUsed="true" QFormat="true" Name="TOC Heading"/>
<w:LsdException Locked="false" Priority="41" Name="Plain Table 1"/>
<w:LsdException Locked="false" Priority="42" Name="Plain Table 2"/>
<w:LsdException Locked="false" Priority="43" Name="Plain Table 3"/>
<w:LsdException Locked="false" Priority="44" Name="Plain Table 4"/>
<w:LsdException Locked="false" Priority="45" Name="Plain Table 5"/>
<w:LsdException Locked="false" Priority="40" Name="Grid Table Light"/>
<w:LsdException Locked="false" Priority="46" Name="Grid Table 1 Light"/>
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2"/>
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3"/>
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4"/>
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark"/>
<w:LsdException Locked="false" Priority="51" Name="Grid Table 6 Colorful"/>
<w:LsdException Locked="false" Priority="52" Name="Grid Table 7 Colorful"/>
<w:LsdException Locked="false" Priority="46"
Name="Grid Table 1 Light Accent 1"/>
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 1"/>
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 1"/>
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 1"/>
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 1"/>
<w:LsdException Locked="false" Priority="51"
Name="Grid Table 6 Colorful Accent 1"/>
<w:LsdException Locked="false" Priority="52"
Name="Grid Table 7 Colorful Accent 1"/>
<w:LsdException Locked="false" Priority="46"
Name="Grid Table 1 Light Accent 2"/>
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 2"/>
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 2"/>
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 2"/>
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 2"/>
<w:LsdException Locked="false" Priority="51"
Name="Grid Table 6 Colorful Accent 2"/>
<w:LsdException Locked="false" Priority="52"
Name="Grid Table 7 Colorful Accent 2"/>
<w:LsdException Locked="false" Priority="46"
Name="Grid Table 1 Light Accent 3"/>
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 3"/>
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 3"/>
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 3"/>
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 3"/>
<w:LsdException Locked="false" Priority="51"
Name="Grid Table 6 Colorful Accent 3"/>
<w:LsdException Locked="false" Priority="52"
Name="Grid Table 7 Colorful Accent 3"/>
<w:LsdException Locked="false" Priority="46"
Name="Grid Table 1 Light Accent 4"/>
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 4"/>
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 4"/>
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 4"/>
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 4"/>
<w:LsdException Locked="false" Priority="51"
Name="Grid Table 6 Colorful Accent 4"/>
<w:LsdException Locked="false" Priority="52"
Name="Grid Table 7 Colorful Accent 4"/>
<w:LsdException Locked="false" Priority="46"
Name="Grid Table 1 Light Accent 5"/>
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 5"/>
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 5"/>
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 5"/>
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 5"/>
<w:LsdException Locked="false" Priority="51"
Name="Grid Table 6 Colorful Accent 5"/>
<w:LsdException Locked="false" Priority="52"
Name="Grid Table 7 Colorful Accent 5"/>
<w:LsdException Locked="false" Priority="46"
Name="Grid Table 1 Light Accent 6"/>
<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 6"/>
<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 6"/>
<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 6"/>
<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 6"/>
<w:LsdException Locked="false" Priority="51"
Name="Grid Table 6 Colorful Accent 6"/>
<w:LsdException Locked="false" Priority="52"
Name="Grid Table 7 Colorful Accent 6"/>
<w:LsdException Locked="false" Priority="46" Name="List Table 1 Light"/>
<w:LsdException Locked="false" Priority="47" Name="List Table 2"/>
<w:LsdException Locked="false" Priority="48" Name="List Table 3"/>
<w:LsdException Locked="false" Priority="49" Name="List Table 4"/>
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark"/>
<w:LsdException Locked="false" Priority="51" Name="List Table 6 Colorful"/>
<w:LsdException Locked="false" Priority="52" Name="List Table 7 Colorful"/>
<w:LsdException Locked="false" Priority="46"
Name="List Table 1 Light Accent 1"/>
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 1"/>
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 1"/>
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 1"/>
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 1"/>
<w:LsdException Locked="false" Priority="51"
Name="List Table 6 Colorful Accent 1"/>
<w:LsdException Locked="false" Priority="52"
Name="List Table 7 Colorful Accent 1"/>
<w:LsdException Locked="false" Priority="46"
Name="List Table 1 Light Accent 2"/>
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 2"/>
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 2"/>
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 2"/>
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 2"/>
<w:LsdException Locked="false" Priority="51"
Name="List Table 6 Colorful Accent 2"/>
<w:LsdException Locked="false" Priority="52"
Name="List Table 7 Colorful Accent 2"/>
<w:LsdException Locked="false" Priority="46"
Name="List Table 1 Light Accent 3"/>
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 3"/>
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 3"/>
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 3"/>
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 3"/>
<w:LsdException Locked="false" Priority="51"
Name="List Table 6 Colorful Accent 3"/>
<w:LsdException Locked="false" Priority="52"
Name="List Table 7 Colorful Accent 3"/>
<w:LsdException Locked="false" Priority="46"
Name="List Table 1 Light Accent 4"/>
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 4"/>
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 4"/>
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 4"/>
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 4"/>
<w:LsdException Locked="false" Priority="51"
Name="List Table 6 Colorful Accent 4"/>
<w:LsdException Locked="false" Priority="52"
Name="List Table 7 Colorful Accent 4"/>
<w:LsdException Locked="false" Priority="46"
Name="List Table 1 Light Accent 5"/>
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 5"/>
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 5"/>
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 5"/>
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 5"/>
<w:LsdException Locked="false" Priority="51"
Name="List Table 6 Colorful Accent 5"/>
<w:LsdException Locked="false" Priority="52"
Name="List Table 7 Colorful Accent 5"/>
<w:LsdException Locked="false" Priority="46"
Name="List Table 1 Light Accent 6"/>
<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 6"/>
<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 6"/>
<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 6"/>
<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 6"/>
<w:LsdException Locked="false" Priority="51"
Name="List Table 6 Colorful Accent 6"/>
<w:LsdException Locked="false" Priority="52"
Name="List Table 7 Colorful Accent 6"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Mention"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Smart Hyperlink"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Hashtag"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Unresolved Mention"/>
<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
Name="Smart Link"/>
</w:LatentStyles>
</xml><![endif]-->
<style>
<!--
/* Font Definitions */
@font-face
{font-family:"Cambria Math";
panose-1:2 4 5 3 5 4 6 3 2 4;
mso-font-charset:0;
mso-generic-font-family:roman;
mso-font-pitch:variable;
mso-font-signature:3 0 0 0 1 0;}
@font-face
{font-family:Calibri;
panose-1:2 15 5 2 2 2 4 3 2 4;
mso-font-charset:204;
mso-generic-font-family:swiss;
mso-font-pitch:variable;
mso-font-signature:-469750017 -1073732485 9 0 511 0;}
/* Style Definitions */
p.MsoNormal, li.MsoNormal, div.MsoNormal
{mso-style-unhide:no;
mso-style-qformat:yes;
mso-style-parent:"";
margin-top:0cm;
margin-right:0cm;
margin-bottom:8.0pt;
margin-left:0cm;
line-height:107%;
mso-pagination:widow-orphan;
font-size:11.0pt;
font-family:"Calibri",sans-serif;
mso-ascii-font-family:Calibri;
mso-ascii-theme-font:minor-latin;
mso-fareast-font-family:Calibri;
mso-fareast-theme-font:minor-latin;
mso-hansi-font-family:Calibri;
mso-hansi-theme-font:minor-latin;
mso-bidi-font-family:"Times New Roman";
mso-bidi-theme-font:minor-bidi;
mso-fareast-language:EN-US;}
.MsoChpDefault
{mso-style-type:export-only;
mso-default-props:yes;
font-family:"Calibri",sans-serif;
mso-ascii-font-family:Calibri;
mso-ascii-theme-font:minor-latin;
mso-fareast-font-family:Calibri;
mso-fareast-theme-font:minor-latin;
mso-hansi-font-family:Calibri;
mso-hansi-theme-font:minor-latin;
mso-bidi-font-family:"Times New Roman";
mso-bidi-theme-font:minor-bidi;
mso-fareast-language:EN-US;}
.MsoPapDefault
{mso-style-type:export-only;
margin-bottom:8.0pt;
line-height:107%;}
@page WordSection1
{size:595.3pt 841.9pt;
margin:2.0cm 42.5pt 2.0cm 3.0cm;
mso-header-margin:35.4pt;
mso-footer-margin:35.4pt;
mso-paper-source:0;}
div.WordSection1
{page:WordSection1;}
-->
</style>
<!--[if gte mso 10]>
<style>
/* Style Definitions */
table.MsoNormalTable
{mso-style-name:"Îáû÷íàÿ òàáëèöà";
mso-tstyle-rowband-size:0;
mso-tstyle-colband-size:0;
mso-style-noshow:yes;
mso-style-priority:99;
mso-style-parent:"";
mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
mso-para-margin-top:0cm;
mso-para-margin-right:0cm;
mso-para-margin-bottom:8.0pt;
mso-para-margin-left:0cm;
line-height:107%;
mso-pagination:widow-orphan;
font-size:11.0pt;
font-family:"Calibri",sans-serif;
mso-ascii-font-family:Calibri;
mso-ascii-theme-font:minor-latin;
mso-hansi-font-family:Calibri;
mso-hansi-theme-font:minor-latin;
mso-bidi-font-family:"Times New Roman";
mso-bidi-theme-font:minor-bidi;
mso-fareast-language:EN-US;}
</style>
<![endif]--><!--[if gte mso 9]><xml>
<o:shapedefaults v:ext="edit" spidmax="1026"/>
</xml><![endif]--><!--[if gte mso 9]><xml>
<o:shapelayout v:ext="edit">
<o:idmap v:ext="edit" data="1"/>
</o:shapelayout></xml><![endif]-->
</head>
<body lang=RU style='tab-interval:35.4pt;word-wrap:break-word'>
<div class=WordSection1>
<p class=MsoNormal><o:p>&nbsp;</o:p></p>
</div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
{% load static %}
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ exam_set.title }} | Testpoint</title><link rel="stylesheet" href="{% static 'css/exact-exam.css' %}?v=20260719.1"></head>
<body class="exact-exam-page"><header class="exact-exam-bar"><div><a href="{% url 'exam_list' %}" aria-label="Leave test">&#8592;</a><span><small>Exact HTML test</small><strong>{{ exam_set.title }}</strong></span></div><div><button id="exact-fullscreen" type="button">Fullscreen</button><form method="post" action="{% url 'complete_exact_exam' attempt.id %}" onsubmit="return confirm('Mark this test as complete?');">{% csrf_token %}<button type="submit" class="exact-finish">Finish test</button></form></div></header><main class="exact-exam-frame-wrap"><iframe title="{{ exam_set.title }}" src="{% url 'exact_exam_content' attempt.id %}" sandbox="allow-scripts allow-forms allow-modals allow-downloads"></iframe></main><script>document.getElementById('exact-fullscreen').addEventListener('click',function(){if(!document.fullscreenElement){document.documentElement.requestFullscreen().catch(function(){});this.textContent='Exit fullscreen';}else{document.exitFullscreen();this.textContent='Fullscreen';}});</script></body></html>
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ exam_set.title }} | Testpoint{% endblock %}
{% block meta_description %}Review the sections, timing, and instructions for {{ exam_set.title }}.{% endblock %}
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/exam-catalogue.css' %}?v=20260727.1">{% endblock %}
{% block content %}
<section class="exam-intro-page">
<div class="exam-intro-shell">
<a class="exam-back" href="{% url 'exam_list' %}"><i class="bi bi-arrow-left"></i> Back to tests</a>
<div class="exam-intro-grid">
<main class="exam-intro-card">
<span class="exam-intro-label">{% if exam_set.access_level == 'premium' %}<i class="bi bi-stars"></i> Lifetime Premium{% else %}IELTS practice test{% endif %}</span>
<h1>{{ exam_set.title }}</h1>
<p>{{ exam_set.description|default:"A timed IELTS practice test designed to help you understand your current performance." }}</p>
{% if exam_set.delivery_mode == 'exact_html' %}<p><strong>Original HTML test:</strong> this opens with the uploaded design, layout, questions, and controls intact.</p>{% endif %}
<div class="exam-intro-stats">
{% if exam_set.delivery_mode == 'exact_html' %}
<div><i class="bi bi-filetype-html"></i><span><strong>Exact</strong> HTML</span></div>
<div><i class="bi bi-aspect-ratio"></i><span><strong>Original</strong> layout</span></div>
<div><i class="bi bi-arrows-fullscreen"></i><span><strong>Full</strong> screen</span></div>
{% else %}
<div><i class="bi bi-layers"></i><span><strong>{{ sections|length }}</strong> sections</span></div>
<div><i class="bi bi-question-circle"></i><span><strong>{{ question_count }}</strong> questions</span></div>
<div><i class="bi bi-stopwatch"></i><span><strong>{{ total_minutes }}</strong> minutes</span></div>
{% endif %}
</div>
{% if exam_set.delivery_mode != 'exact_html' %}<h2>Test sections</h2>
<div class="exam-section-list">
{% for section in sections %}
<article><span>{{ forloop.counter }}</span><div><strong>{{ section.get_section_type_display }}</strong><small>{{ section.questions.count }} question{{ section.questions.count|pluralize }} · {{ section.time_limit_minutes }} minutes</small></div></article>
{% empty %}<p>No sections have been published for this test.</p>{% endfor %}
</div>{% endif %}
</main>
<aside class="exam-rules-card">
<span class="exam-rules-icon"><i class="bi bi-info-circle"></i></span>
<h2>Before you begin</h2>
<ul>
<li>The timer continues if you refresh or close the page.</li>
<li>Submit each section before its time expires.</li>
<li>Objective answers are graded immediately.</li>
<li>Writing and speaking responses may require instructor review.</li>
</ul>
{% if not can_access_exam %}
<a class="exam-premium-action" href="{% url 'premium' %}"><i class="bi bi-lock-fill"></i> Unlock with Lifetime Premium</a>
<p class="exam-premium-note">One payment unlocks this test and every future Premium test.</p>
{% elif is_ready %}
<form method="post" action="{% url 'start_exam' exam_set.id %}">{% csrf_token %}<button type="submit">{% if active_attempt %}Continue test{% else %}Begin test{% endif %} <i class="bi bi-arrow-right"></i></button></form>
{% else %}
<button type="button" disabled>Test unavailable</button>
<p class="exam-unavailable">This test still needs content from an administrator.</p>
{% endif %}
</aside>
</div>
</div>
</section>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% load static %}
{% block title %}IELTS Practice Tests | Testpoint{% endblock %}
{% block meta_description %}Browse available IELTS mock tests and continue your practice progress.{% endblock %}
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/exam-catalogue.css' %}?v=20260727.1"><link rel="stylesheet" href="{% static 'css/catalogue-density.css' %}?v=20260718.2"><link rel="stylesheet" href="{% static 'css/catalogue-compact.css' %}?v=20260727.1"><link rel="stylesheet" href="{% static 'css/catalogue-card-type.css' %}?v=20260719.1">{% endblock %}
{% block content %}
<section class="catalogue-hero">
<div class="catalogue-hero__content">
<span>Practice centre</span>
<h1>IELTS Practice Tests</h1>
<p>Choose a test, practise under realistic time limits, and review your results.</p>
<div class="catalogue-summary" aria-label="Test catalogue summary">
<div><strong>{{ total }}</strong><span>Available test{{ total|pluralize }}</span></div>
<div><strong>{{ counts.in_progress }}</strong><span>In progress</span></div>
<div><strong>{{ counts.completed }}</strong><span>Completed</span></div>
</div>
</div>
</section>
<section class="catalogue-page">
<div class="catalogue-shell">
<nav class="catalogue-filters" aria-label="Filter mock tests">
<div class="catalogue-filter-group">
<span>Test type</span>
<div>
<a href="?skill=all&amp;status={{ status_filter }}" {% if skill_filter == 'all' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-grid"></i>All tests <b>{{ all_total }}</b></a>
<a href="?skill=reading&amp;status={{ status_filter }}" {% if skill_filter == 'reading' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-book"></i>Reading <b>{{ skill_counts.reading }}</b></a>
<a href="?skill=listening&amp;status={{ status_filter }}" {% if skill_filter == 'listening' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-headphones"></i>Listening <b>{{ skill_counts.listening }}</b></a>
<a href="?skill=writing&amp;status={{ status_filter }}" {% if skill_filter == 'writing' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-pencil"></i>Writing <b>{{ skill_counts.writing }}</b></a>
<a href="?skill=speaking&amp;status={{ status_filter }}" {% if skill_filter == 'speaking' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-mic"></i>Speaking <b>{{ skill_counts.speaking }}</b></a>
<a href="?skill=full&amp;status={{ status_filter }}" {% if skill_filter == 'full' %}class="is-active" aria-current="page"{% endif %}><i class="bi bi-layers"></i>Full Mock Test <b>{{ skill_counts.full }}</b></a>
</div>
</div>
<div class="catalogue-filter-group">
<span>Progress</span>
<div>
<a href="?skill={{ skill_filter }}&amp;status=all" {% if status_filter == 'all' %}class="is-active" aria-current="page"{% endif %}>All <b>{{ total }}</b></a>
<a href="?skill={{ skill_filter }}&amp;status=not_started" {% if status_filter == 'not_started' %}class="is-active" aria-current="page"{% endif %}>Not started <b>{{ counts.not_started }}</b></a>
<a href="?skill={{ skill_filter }}&amp;status=in_progress" {% if status_filter == 'in_progress' %}class="is-active" aria-current="page"{% endif %}>In progress <b>{{ counts.in_progress }}</b></a>
<a href="?skill={{ skill_filter }}&amp;status=completed" {% if status_filter == 'completed' %}class="is-active" aria-current="page"{% endif %}>Completed <b>{{ counts.completed }}</b></a>
</div>
</div>
</nav>
<div class="catalogue-main">
<div class="catalogue-heading">
<div><span>Test library</span><h2>{% if skill_filter == 'reading' %}Reading tests{% elif skill_filter == 'listening' %}Listening tests{% elif skill_filter == 'writing' %}Writing tests{% elif skill_filter == 'speaking' %}Speaking tests{% elif skill_filter == 'full' %}Full mock tests{% elif status_filter == 'not_started' %}Tests not started{% elif status_filter == 'in_progress' %}Tests in progress{% elif status_filter == 'completed' %}Completed tests{% else %}All mock tests{% endif %}</h2></div>
<a href="{% url 'student_dashboard' %}"><i class="bi bi-grid"></i> Dashboard</a>
</div>
{% if exam_data %}
<section class="catalogue-search-panel" aria-label="Search and filter tests">
<label class="catalogue-search-box" for="catalogue-search"><i class="bi bi-search"></i><input id="catalogue-search" type="search" placeholder="Search tests by title..." autocomplete="off"></label>
<div class="catalogue-part-filters" role="group" aria-label="Filter by passage part"><span>Part:</span><button class="is-active" type="button" data-part="all" aria-pressed="true">All</button><button type="button" data-part="p1" aria-pressed="false">P1</button><button type="button" data-part="p2" aria-pressed="false">P2</button><button type="button" data-part="p3" aria-pressed="false">P3</button></div>
</section>
<div class="catalogue-grid catalogue-grid--compact" id="catalogue-test-grid">
{% for item in exam_data %}
<article class="catalogue-compact-card" data-title="{{ item.exam_set.title|lower }}" data-part="{{ item.part }}">
{% if item.requires_premium %}<span class="catalogue-premium-mark"><i class="bi bi-stars"></i> Premium</span>{% endif %}
<span class="visually-hidden"><strong>{{ item.section_count }}</strong> section{{ item.section_count|pluralize }}; <strong>{{ item.question_count }}</strong> question{{ item.question_count|pluralize }}; <strong>{{ item.total_minutes }}</strong> min{% if item.status == 'in_progress' %}; Continue test{% endif %}</span>
<span class="catalogue-compact-icon"><i class="bi {% if item.exam_set.category == 'reading' %}bi-book{% elif item.exam_set.category == 'listening' %}bi-headphones{% elif item.exam_set.category == 'writing' %}bi-pencil{% elif item.exam_set.category == 'speaking' %}bi-mic{% else %}bi-layers{% endif %}"></i></span>
<div class="catalogue-compact-body"><h3>{{ item.exam_set.title }}</h3><div class="catalogue-compact-tags"><span>{{ item.exam_set.get_category_display }}</span>{% if item.part != 'all' %}<b>{{ item.part|upper }}</b>{% endif %}{% if item.status == 'completed' %}<em class="is-complete">Completed{% if item.score %} · {{ item.score }}{% endif %}</em>{% elif item.status == 'in_progress' %}<em class="is-progress">In progress</em>{% endif %}</div><p>{% if item.is_exact %}<i class="bi bi-filetype-html"></i> Exact HTML{% else %}<i class="bi bi-stopwatch"></i> {{ item.total_minutes }} min <span>·</span> {{ item.question_count }} question{{ item.question_count|pluralize }}{% endif %}</p></div>
{% if item.status == 'completed' %}<a class="catalogue-compact-action" href="{% url 'results' item.attempt.id %}" aria-label="View results for {{ item.exam_set.title }}"><i class="bi bi-arrow-right"></i></a>{% elif item.is_locked %}<a class="catalogue-compact-action is-locked" href="{% url 'premium' %}" aria-label="Unlock {{ item.exam_set.title }} with Lifetime Premium"><i class="bi bi-lock-fill"></i></a>{% else %}<a class="catalogue-compact-action" href="{% url 'exam_detail' item.exam_set.id %}" aria-label="{% if item.status == 'in_progress' %}Continue test{% else %}View test{% endif %}: {{ item.exam_set.title }}"><i class="bi bi-arrow-right"></i></a>{% endif %}
</article>
{% endfor %}
</div>
<div class="catalogue-search-empty" id="catalogue-search-empty" hidden><i class="bi bi-search"></i><strong>No matching tests</strong><span>Try another title or passage part.</span></div>
{% else %}
<div class="catalogue-empty">
<span><i class="bi bi-search"></i></span>
<h3>No tests in this category</h3>
<p>{% if total %}Try another filter to see the rest of your mock tests.{% else %}Mock tests will appear here after they are published.{% endif %}</p>
{% if total %}<a href="?status=all">Show all tests</a>{% else %}<a href="{% url 'student_dashboard' %}">Return to dashboard</a>{% endif %}
</div>
{% endif %}
</div>
</div>
</section>
{% endblock %}
{% block extra_js %}<script src="{% static 'js/catalogue-search.js' %}?v=20260719.1"></script>{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% load static exam_content %}
{% block title %}Results | {{ attempt.exam_set.title }}{% endblock %}
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/exam-session.css' %}?v=20260719.3"><link rel="stylesheet" href="{% static 'css/review-mode.css' %}?v=20260719.2">{% if use_split_reading_review %}<link rel="stylesheet" href="{% static 'css/reading-review.css' %}?v=20260719.7">{% endif %}<link rel="stylesheet" href="{% static 'css/exam-content-typography.css' %}?v=20260719.10"><link rel="stylesheet" href="{% static 'css/assessment-workspace.css' %}?v=20260727.2">{% endblock %}
{% block content %}
{% if use_split_reading_review %}
<section class="reader-review-page assessment-page assessment-page--review" id="reader-review">
<header class="reader-review-head assessment-bar">
<div class="reader-review-title assessment-bar__title"><a class="assessment-bar__back" href="{% url 'student_dashboard' %}" aria-label="Back to dashboard"><i class="bi bi-arrow-left"></i></a><span class="reader-head-icon assessment-bar__icon"><i class="bi bi-book"></i></span><div><small>Reading · Review</small><h1>{{ attempt.exam_set.title }}</h1></div></div>
<article class="assessment-metric"><span class="reader-head-icon"><i class="bi bi-bullseye"></i></span><div><small>Raw score</small><strong>{{ correct_count }} / {{ total_gradable }}</strong></div></article>
<article class="assessment-metric"><span class="reader-head-icon reader-head-icon--green"><i class="bi bi-award"></i></span><div><small>{% if reading_band_estimated %}Estimated band{% else %}Reading band{% endif %}</small><strong>{{ reading_band|floatformat:1 }} / 9</strong>{% if reading_band_estimated %}<em>Equivalent to {{ reading_equivalent }} / 40</em>{% endif %}</div></article>
<form class="assessment-bar__action" method="post" action="{% url 'start_exam' attempt.exam_set.id %}">{% csrf_token %}<button type="submit"><i class="bi bi-arrow-repeat"></i> Retake test</button></form>
</header>
<main class="reader-review-workspace">
<section class="reader-passage-pane" aria-label="Reading passage">
<div class="reader-pane-heading reader-pane-heading--title"><h2>{{ attempt.exam_set.title }}</h2></div>
{% for section in reading_passages %}<article class="reader-passage" data-passage-section="{{ section.id }}"{% if not forloop.first %} hidden{% endif %}><div class="reader-passage-content rich-passage-content">{{ section.passage_text|render_rich_text }}</div></article>{% endfor %}
</section>
<section class="reader-question-pane" aria-label="Answer review">
{% for answer in reading_review_answers %}
<article class="reader-question-review{% if forloop.first %} is-active{% endif %}" data-review-index="{{ forloop.counter0 }}" data-section-id="{{ answer.question.section_id }}" data-reference="{{ answer.question.passage_reference }}" data-state="{% if answer.is_correct is True %}correct{% elif answer.is_correct is False %}incorrect{% else %}pending{% endif %}">
<div class="reader-review-card">
<p class="reader-question-count">Question {{ forloop.counter }} of {{ reading_review_answers|length }}</p>
<div class="reader-question-prompt rich-question-prompt">{{ answer.question.prompt|render_rich_text }}</div>
<div class="reader-answer-content">
<div class="reader-answer-grid"><div><small>Your answer</small><strong class="{% if answer.is_correct is True %}is-correct{% elif answer.is_correct is False %}is-wrong{% endif %}">{{ answer.answer_text|default:"No answer submitted" }}</strong></div><div><small>Correct answer</small><strong class="is-correct">{{ answer.question.correct_answer|default:"Instructor reviewed" }}</strong></div></div>
</div>
<section class="reader-evidence-content">{% if answer.question.passage_reference %}<blockquote>{{ answer.question.passage_reference }}</blockquote><button class="reader-show-evidence" type="button"><i class="bi bi-box-arrow-up-right"></i> Show in passage</button>{% else %}<p>No exact passage sentence has been attached to this question.</p>{% endif %}</section>
</div>
</article>
{% endfor %}
</section>
</main>
<footer class="reader-review-footer reader-review-footer--no-legend">
<nav class="reader-question-nav" aria-label="Review questions">{% for answer in reading_review_answers %}<button type="button" data-review-target="{{ forloop.counter0 }}" class="{% if answer.is_correct is True %}is-correct{% elif answer.is_correct is False %}is-incorrect{% else %}is-pending{% endif %}{% if forloop.first %} is-current{% endif %}" aria-label="Review question {{ forloop.counter }}">{{ forloop.counter }}</button>{% endfor %}</nav>
<div class="reader-review-pager"><button id="reader-previous" type="button"><i class="bi bi-arrow-left"></i> Previous</button><button id="reader-next" type="button">Next <i class="bi bi-arrow-right"></i></button></div>
</footer>
</section>
<script src="{% static 'js/reading-review.js' %}?v=20260727.1"></script>
{% else %}
<section class="results-page assessment-page assessment-page--review">
<header class="assessment-bar assessment-bar--results">
<div class="assessment-bar__title"><a class="assessment-bar__back" href="{% url 'student_dashboard' %}" aria-label="Back to dashboard"><i class="bi bi-arrow-left"></i></a><span class="assessment-bar__icon"><i class="bi bi-clipboard-check"></i></span><div><span>Completed test · Review</span><h1>{{ attempt.exam_set.title }}</h1><p>Review your responses and grading status.</p></div></div>
<div class="assessment-bar__summary"><span>{{ answers|length }} responses</span>{% if pending_manual %}<span>{{ pending_manual }} awaiting review</span>{% endif %}</div>
<form class="assessment-bar__action" method="post" action="{% url 'start_exam' attempt.exam_set.id %}">{% csrf_token %}<button type="submit"><i class="bi bi-arrow-repeat"></i> Retake test</button></form>
</header>
<div class="results-shell">
<header class="results-header"><span>Performance summary</span><h2>Your test results</h2><p>Objective answers are scored immediately. Instructor-reviewed responses update after grading.</p></header>
<div class="results-summary">
<article><span class="result-summary-icon result-summary-icon--blue"><i class="bi bi-bullseye"></i></span><div><span>Objective score</span><strong>{% if total_gradable %}{{ correct_count }} / {{ total_gradable }}{% else %}Not applicable{% endif %}</strong></div></article>
<article><span class="result-summary-icon result-summary-icon--green"><i class="bi bi-award"></i></span><div><span>Manual band average</span><strong>{% if average_manual_score is not None %}{{ average_manual_score|floatformat:1 }} / 9{% else %}Awaiting review{% endif %}</strong></div></article>
<article><span class="result-summary-icon result-summary-icon--orange"><i class="bi bi-hourglass-split"></i></span><div><span>Awaiting review</span><strong>{{ pending_manual }}</strong></div></article>
</div>
<section class="answer-review"><div class="answer-review-heading"><span>Answer review</span><h2>Your responses</h2></div>
{% for answer in answers %}<article class="answer-card"><div class="answer-number">{{ answer.question.order }}</div><div class="answer-body"><div class="rich-question-prompt review-question-prompt">{{ answer.question.prompt|render_rich_text }}</div><div class="answer-response"><span>Your answer</span><p>{{ answer.answer_text|default:"No typed answer submitted"|linebreaksbr }}</p>{% if answer.audio_response %}<audio controls preload="metadata" src="{{ answer.audio_response.url }}">Your browser does not support audio playback.</audio>{% endif %}</div>{% if answer.is_correct is not None %}{% if answer.is_correct %}<span class="answer-status answer-status--correct"><i class="bi bi-check-circle"></i> Correct</span>{% else %}<span class="answer-status answer-status--wrong"><i class="bi bi-x-circle"></i> Incorrect</span>{% endif %}{% elif answer.manual_score is not None %}<span class="answer-status answer-status--graded"><i class="bi bi-award"></i> Band {{ answer.manual_score|floatformat:1 }}</span>{% else %}<span class="answer-status answer-status--pending"><i class="bi bi-hourglass-split"></i> Pending instructor review</span>{% endif %}</div></article>{% empty %}<div class="results-empty">No answers were submitted for this test.</div>{% endfor %}
</section>
<div class="results-actions"><a href="{% url 'exam_list' %}">Browse more tests</a><a class="results-primary" href="{% url 'student_dashboard' %}">View dashboard</a></div>
</div></section>
{% endif %}
{% endblock %}
+448
View File
@@ -0,0 +1,448 @@
{% extends "base.html" %}
{% load static exam_content %}
{% block title %}{{ section.get_section_type_display }} | {{ section.exam_set.title }}{% endblock %}
{% block extra_css %}<link rel="stylesheet" href="{% static 'css/exam-session.css' %}?v=20260717.4"><link rel="stylesheet" href="{% static 'css/exam-retake.css' %}?v=20260717.1"><link rel="stylesheet" href="{% static 'css/speaking-recorder.css' %}?v=20260718.1"><link rel="stylesheet" href="{% static 'css/exam-layout-polish.css' %}?v=20260719.5"><link rel="stylesheet" href="{% static 'css/grouped-questions.css' %}?v=20260719.1"><link rel="stylesheet" href="{% static 'css/listening-worksheet.css' %}?v=20260726.2"><link rel="stylesheet" href="{% static 'css/selection-annotations.css' %}?v=20260719.2"><link rel="stylesheet" href="{% static 'css/exam-content-typography.css' %}?v=20260719.7"><link rel="stylesheet" href="{% static 'css/ielts-question-paper.css' %}?v=20260726.1"><link rel="stylesheet" href="{% static 'css/listening-real-exam.css' %}?v=20260726.1"><link rel="stylesheet" href="{% static 'css/assessment-workspace.css' %}?v=20260727.2">{% endblock %}
{% block content %}
<section id="exam-session" class="exam-session assessment-page assessment-page--test{% if section.section_type == 'reading' and section.passage_text %} exam-session--reading{% endif %}{% if section.section_type == 'listening' %} exam-session--listening{% endif %}">
<header class="exam-session-header assessment-bar">
<div class="exam-session-title assessment-bar__title">
<a class="assessment-bar__back" href="{% url 'exam_list' %}" aria-label="Leave test"><i class="bi bi-arrow-left"></i></a>
<span class="assessment-bar__icon"><i class="bi {% if section.section_type == 'reading' %}bi-book{% elif section.section_type == 'listening' %}bi-headphones{% elif section.section_type == 'writing' %}bi-pencil{% else %}bi-mic{% endif %}"></i></span>
<div><span>Section {{ section_number }} of {{ section_total }} · Live test</span><h1>{{ section.get_section_type_display }}</h1><p>{{ section.exam_set.title }}</p></div>
</div>
<div class="exam-session-controls">
<button id="fullscreen-toggle" class="exam-fullscreen-button" type="button" aria-label="Enter fullscreen"><i class="bi bi-arrows-fullscreen"></i><span>Fullscreen</span></button>
<div class="exam-timer-wrap"><span>Time remaining</span><strong id="timer" data-seconds="{{ remaining_seconds }}" role="timer" aria-live="off">--:--</strong></div>
<form method="post" action="{% url 'retake_exam' attempt.id %}" onsubmit="return confirm('Start this test again? Your unfinished answers in this attempt will be removed.');">{% csrf_token %}<button class="exam-retake-button" type="submit"><i class="bi bi-arrow-repeat"></i><span>Retake</span></button></form>
</div>
</header>
<form id="exam-form" method="post" enctype="multipart/form-data" class="exam-session-form" autocomplete="off">
{% csrf_token %}
{% if section.section_type == 'listening' %}<section class="listening-part-banner" aria-label="Listening section instructions"><strong>Part {{ section.order }}</strong><span>Listen to the audio and answer questions {{ first_question_order }}{% if first_question_order != last_question_order %}&ndash;{{ last_question_order }}{% endif %}.</span></section>{% endif %}
{% if section.section_type == 'reading' or section.section_type == 'listening' %}
<div id="passage-selection-toolbar" class="passage-selection-toolbar" role="toolbar" aria-label="Selected text tools" hidden>
<button id="passage-highlight" type="button" title="Highlight selected text"><i class="bi bi-highlighter"></i><span>Highlight</span></button>
<button id="passage-add-note" type="button" title="Add a note to selected text"><i class="bi bi-sticky"></i><span>Note</span></button>
<button id="passage-clear" type="button" title="Clear all highlights and notes"><i class="bi bi-eraser"></i><span>Clear</span></button>
</div>
<div id="passage-note-editor" class="passage-note-editor" hidden>
<strong>Add note</strong><p id="passage-note-quote"></p><label class="visually-hidden" for="passage-note-text">Your note</label><textarea id="passage-note-text" rows="3" maxlength="500" placeholder="Write a short note about this text..."></textarea><div><button id="passage-note-cancel" type="button">Cancel</button><button id="passage-note-save" type="button">Save note</button></div>
</div>
{% endif %}
<div class="exam-workspace{% if section.section_type == 'reading' and section.passage_text %} exam-workspace--split{% else %} exam-workspace--wide{% endif %}{% if section.section_type == 'listening' %} exam-workspace--listening{% endif %}" id="exam-workspace">
{% if section.section_type == 'reading' and section.passage_text %}
<aside class="exam-passage" aria-label="Reading passage">
<div class="exam-pane-heading exam-pane-heading--title"><span>{{ section.exam_set.title }}</span></div>
<div class="exam-passage-text rich-passage-content">{{ section.passage_text|render_rich_text }}</div>
</aside>
<div id="exam-resizer" class="exam-resizer" role="separator" aria-label="Resize passage and questions" aria-orientation="vertical" tabindex="0"></div>
{% endif %}
<main class="exam-questions" id="question-panel">
{% if section.audio_file %}<div class="exam-audio"><div><i class="bi bi-headphones"></i><span><strong>Listening audio</strong><small>Use headphones in a quiet place.</small></span></div><audio controls preload="metadata" src="{{ section.audio_file.url }}">Your browser does not support audio playback.</audio></div>{% endif %}
{% for block in question_blocks %}
{% if block.kind == 'group' %}
{{ block.group|render_question_group }}
{% else %}
{% with question=block.question %}
<fieldset class="exam-question-card" id="question-{{ question.id }}" data-question-id="{{ question.id }}" data-question-order="{{ question.order }}" tabindex="-1">
<legend><span>{{ question.order }}</span><span class="rich-question-prompt">{{ question.prompt|render_rich_text }}</span></legend>
{% if question.question_type == 'mcq' %}
<div class="exam-options">{% for option in question.options %}<label><input type="radio" name="q{{ question.id }}" value="{{ option }}"><span>{{ option }}</span></label>{% endfor %}</div>
{% elif question.question_type == 'matching' and question.options %}
<label class="visually-hidden" for="q{{ question.id }}">Answer for question {{ question.order }}</label><select id="q{{ question.id }}" name="q{{ question.id }}" class="exam-text-input"><option value="">Select an option</option>{% for option in question.options %}<option value="{{ option }}">{{ option }}</option>{% endfor %}</select>
{% elif question.question_type == 'gap' or question.question_type == 'matching' %}
<label class="visually-hidden" for="q{{ question.id }}">Answer for question {{ question.order }}</label><input id="q{{ question.id }}" type="text" name="q{{ question.id }}" class="exam-text-input" spellcheck="false">
{% elif question.question_type == 'speaking' %}
<label class="visually-hidden" for="q{{ question.id }}">Notes for question {{ question.order }}</label><textarea id="q{{ question.id }}" name="q{{ question.id }}" rows="5" class="exam-textarea" placeholder="Optional notes or typed response..."></textarea>
<div class="speaking-upload" data-speaking-recorder><label for="q{{ question.id }}_audio"><i class="bi bi-mic"></i><span><strong>Record or upload your response</strong><small>Record in your browser, or upload MP3, M4A, WAV, WebM, or OGG &middot; maximum 20 MB</small></span></label><div class="speaking-recorder"><button type="button" class="speaking-record-button" aria-pressed="false" aria-label="Start recording"><i class="bi bi-mic-fill"></i></button><div class="speaking-recorder__center"><div class="speaking-waveform" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><span class="speaking-record-status" role="status" aria-live="polite">Tap the microphone to record</span></div><time class="speaking-record-time" aria-label="Recording duration">00:00</time></div><audio class="speaking-record-preview" controls hidden></audio><div class="speaking-upload__divider"><span>or upload a file</span></div><input id="q{{ question.id }}_audio" type="file" name="q{{ question.id }}_audio" accept="audio/mpeg,audio/mp4,audio/wav,audio/webm,audio/ogg,.m4a"></div>
{% else %}
<label class="visually-hidden" for="q{{ question.id }}">Response for question {{ question.order }}</label><textarea id="q{{ question.id }}" name="q{{ question.id }}" rows="8" class="exam-textarea" placeholder="Write your response here..."></textarea>
{% endif %}
</fieldset>
{% endwith %}
{% endif %}
{% endfor %}
{% if section.section_type != 'reading' and section.section_type != 'listening' %}<div class="exam-submit-area"><p><i class="bi bi-shield-check"></i> Your answers are submitted securely when you finish this section.</p><button id="submit-section" type="submit">Submit section <i class="bi bi-arrow-right"></i></button></div>{% endif %}
</main>
</div>
{% if section.section_type == 'reading' or section.section_type == 'listening' %}
<footer class="exam-question-nav" aria-label="Question navigation">
<div class="exam-question-numbers">
{% for question in section.questions.all %}<button type="button" class="exam-question-number{% if forloop.first %} is-current{% endif %}" data-target="question-{{ question.id }}" aria-label="Go to question {{ question.order }}">{{ question.order }}</button>{% endfor %}
</div>
<div class="exam-question-actions">
<button id="question-prev" type="button" class="exam-nav-button" disabled><i class="bi bi-arrow-left"></i><span>Previous</span></button>
<button id="question-next" type="button" class="exam-nav-button"><span>Next</span><i class="bi bi-arrow-right"></i></button>
<button id="submit-section" type="submit" class="exam-bottom-submit">Submit section <i class="bi bi-send"></i></button>
</div>
</footer>
{% endif %}
</form>
</section>
{% endblock %}
{% block extra_js %}
<script>
(() => {
const session = document.getElementById('exam-session');
const form = document.getElementById('exam-form');
const submitButton = document.getElementById('submit-section');
const timer = document.getElementById('timer');
let remaining = Math.max(0, Number(timer.dataset.seconds) || 0);
let submitted = false;
const renderTimer = () => {
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
timer.textContent = `${minutes}:${String(seconds).padStart(2, '0')}`;
timer.classList.toggle('is-warning', remaining <= 300 && remaining > 0);
};
const submitAutomatically = () => {
if (submitted) return;
submitted = true;
submitButton.disabled = true;
submitButton.textContent = 'Submitting...';
form.requestSubmit();
};
renderTimer();
if (remaining === 0) submitAutomatically();
const interval = window.setInterval(() => {
remaining = Math.max(0, remaining - 1);
renderTimer();
if (remaining === 0) {
window.clearInterval(interval);
submitAutomatically();
}
}, 1000);
form.addEventListener('submit', (event) => {
if (event.defaultPrevented) return;
if (!submitted && !window.confirm('Submit this section? You cannot return to change these answers.')) {
event.preventDefault();
return;
}
submitted = true;
submitButton.disabled = true;
});
const numberButtons = [...document.querySelectorAll('.exam-question-number')];
const cards = numberButtons.map((button) => document.getElementById(button.dataset.target));
const questionPanel = document.getElementById('question-panel');
const previousButton = document.getElementById('question-prev');
const nextButton = document.getElementById('question-next');
let currentIndex = 0;
const setCurrent = (index) => {
if (!numberButtons.length) return;
currentIndex = Math.max(0, Math.min(index, numberButtons.length - 1));
numberButtons.forEach((button, buttonIndex) => button.classList.toggle('is-current', buttonIndex === currentIndex));
previousButton.disabled = currentIndex === 0;
nextButton.disabled = currentIndex === numberButtons.length - 1;
};
const goToQuestion = (index) => {
setCurrent(index);
cards[currentIndex].scrollIntoView({behavior: 'smooth', block: 'center'});
cards[currentIndex].focus({preventScroll: true});
};
const updateAnswered = (card, index) => {
const radioAnswered = Boolean(card.querySelector('input[type="radio"]:checked'));
const textAnswered = [...card.querySelectorAll('input[type="text"], textarea, select')].some((input) => input.value.trim());
const fileAnswered = [...card.querySelectorAll('input[type="file"]')].some((input) => input.files.length);
const answered = radioAnswered || textAnswered || fileAnswered;
numberButtons[index].classList.toggle('is-answered', answered);
card.classList.toggle('is-answered', answered);
};
numberButtons.forEach((button, index) => button.addEventListener('click', () => goToQuestion(index)));
cards.forEach((card, index) => {
card.querySelectorAll('input, textarea, select').forEach((input) => {
input.addEventListener('input', () => updateAnswered(card, index));
input.addEventListener('change', () => updateAnswered(card, index));
});
});
if (previousButton) previousButton.addEventListener('click', () => goToQuestion(currentIndex - 1));
if (nextButton) nextButton.addEventListener('click', () => goToQuestion(currentIndex + 1));
if (numberButtons.length && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible) setCurrent(cards.indexOf(visible.target));
}, {root: questionPanel, threshold: [0.45, 0.7]});
cards.forEach((card) => observer.observe(card));
}
const fullscreenButton = document.getElementById('fullscreen-toggle');
if (fullscreenButton) {
fullscreenButton.addEventListener('click', async () => {
if (!document.fullscreenElement) await session.requestFullscreen();
else await document.exitFullscreen();
});
document.addEventListener('fullscreenchange', () => {
const active = Boolean(document.fullscreenElement);
fullscreenButton.querySelector('i').className = active ? 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen';
fullscreenButton.querySelector('span').textContent = active ? 'Exit fullscreen' : 'Fullscreen';
});
}
const workspace = document.getElementById('exam-workspace');
const resizer = document.getElementById('exam-resizer');
if (resizer) {
let resizing = false;
resizer.addEventListener('pointerdown', (event) => {
resizing = true;
resizer.setPointerCapture(event.pointerId);
document.body.classList.add('is-resizing-exam');
});
resizer.addEventListener('pointermove', (event) => {
if (!resizing) return;
const bounds = workspace.getBoundingClientRect();
const percentage = ((event.clientX - bounds.left) / bounds.width) * 100;
workspace.style.setProperty('--passage-width', `${Math.max(30, Math.min(70, percentage))}%`);
});
const stopResize = () => {
resizing = false;
document.body.classList.remove('is-resizing-exam');
};
resizer.addEventListener('pointerup', stopResize);
resizer.addEventListener('pointercancel', stopResize);
}
const annotationAreas = [...document.querySelectorAll('.exam-passage-text, .exam-questions')];
const selectionToolbar = document.getElementById('passage-selection-toolbar');
const highlightButton = document.getElementById('passage-highlight');
const addNoteButton = document.getElementById('passage-add-note');
const clearPassageButton = document.getElementById('passage-clear');
const noteEditor = document.getElementById('passage-note-editor');
const noteQuote = document.getElementById('passage-note-quote');
const noteText = document.getElementById('passage-note-text');
const noteSaveButton = document.getElementById('passage-note-save');
const noteCancelButton = document.getElementById('passage-note-cancel');
let selectedPassageRange = null;
let lastPointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const nativeHighlightsSupported = Boolean(window.CSS?.highlights && window.Highlight);
const nativePassageHighlights = nativeHighlightsSupported ? new Highlight() : null;
if (nativePassageHighlights) CSS.highlights.set('ielts-passage-highlight', nativePassageHighlights);
const selectionIsInAnnotationArea = (range) => {
if (!annotationAreas.length || !range) return false;
const container = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
? range.commonAncestorContainer.parentElement
: range.commonAncestorContainer;
return annotationAreas.some((area) => area.contains(container));
};
const hideSelectionToolbar = () => {
if (selectionToolbar) selectionToolbar.hidden = true;
};
const showSelectionToolbar = (event = null) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
hideSelectionToolbar();
return;
}
const range = selection.getRangeAt(0);
if (!selection.toString().trim() || !selectionIsInAnnotationArea(range)) {
hideSelectionToolbar();
return;
}
selectedPassageRange = range.cloneRange();
const selectionBox = range.getBoundingClientRect();
if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
lastPointer = { x: event.clientX, y: event.clientY };
} else if (selectionBox.width || selectionBox.height) {
lastPointer = { x: selectionBox.right, y: selectionBox.bottom };
}
const toolbarWidth = 166;
selectionToolbar.style.left = `${Math.max(10, Math.min(window.innerWidth - toolbarWidth - 10, lastPointer.x + 12))}px`;
selectionToolbar.style.top = `${Math.max(10, Math.min(window.innerHeight - 48, lastPointer.y + 12))}px`;
selectionToolbar.hidden = false;
};
const addHighlight = (range, note = '') => {
if (!range || range.collapsed) return null;
if (nativePassageHighlights) {
const savedRange = range.cloneRange();
nativePassageHighlights.add(savedRange);
return savedRange;
}
const mark = document.createElement('mark');
mark.className = 'exam-highlight';
if (note) {
mark.dataset.note = 'true';
mark.title = note;
}
try {
range.surroundContents(mark);
} catch (error) {
const contents = range.extractContents();
mark.appendChild(contents);
range.insertNode(mark);
}
return mark;
};
const clearPassageAnnotations = () => {
if (nativePassageHighlights) nativePassageHighlights.clear();
annotationAreas.forEach((area) => {
area.querySelectorAll('.exam-highlight').forEach((mark) => mark.replaceWith(...mark.childNodes));
});
noteEditor.hidden = true;
hideSelectionToolbar();
window.getSelection()?.removeAllRanges();
};
if (selectionToolbar && annotationAreas.length) {
annotationAreas.forEach((area) => area.addEventListener('pointermove', (event) => { lastPointer = { x: event.clientX, y: event.clientY }; }));
const capturePassageSelection = (event) => {
if (event) lastPointer = { x: event.clientX, y: event.clientY };
window.setTimeout(() => showSelectionToolbar(event), 0);
};
document.addEventListener('mouseup', capturePassageSelection);
document.addEventListener('touchend', capturePassageSelection, { passive: true });
document.addEventListener('keyup', capturePassageSelection);
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !selection.toString().trim()) {
hideSelectionToolbar();
return;
}
const range = selection.getRangeAt(0);
if (selectionIsInAnnotationArea(range)) {
window.requestAnimationFrame(() => showSelectionToolbar());
} else {
hideSelectionToolbar();
}
});
document.addEventListener('scroll', hideSelectionToolbar, true);
selectionToolbar.addEventListener('mousedown', (event) => event.preventDefault());
highlightButton.addEventListener('click', () => {
addHighlight(selectedPassageRange);
hideSelectionToolbar();
window.getSelection()?.removeAllRanges();
});
addNoteButton.addEventListener('click', () => {
if (!selectedPassageRange) return;
noteQuote.textContent = `${selectedPassageRange.toString().trim().slice(0, 160)}`;
noteText.value = '';
noteEditor.style.left = `${Math.max(10, Math.min(window.innerWidth - 310, lastPointer.x + 12))}px`;
noteEditor.style.top = `${Math.max(10, Math.min(window.innerHeight - 230, lastPointer.y + 16))}px`;
noteEditor.hidden = false;
hideSelectionToolbar();
noteText.focus();
});
noteCancelButton.addEventListener('click', () => {
noteEditor.hidden = true;
window.getSelection()?.removeAllRanges();
});
noteSaveButton.addEventListener('click', () => {
const text = noteText.value.trim();
if (!text || !selectedPassageRange) return;
addHighlight(selectedPassageRange, text);
noteEditor.hidden = true;
window.getSelection()?.removeAllRanges();
});
clearPassageButton.addEventListener('click', clearPassageAnnotations);
}
document.querySelectorAll('[data-speaking-recorder]').forEach((container) => {
const fileInput = container.querySelector('input[type="file"]');
const recordButton = container.querySelector('.speaking-record-button');
const status = container.querySelector('.speaking-record-status');
const recordTime = container.querySelector('.speaking-record-time');
const preview = container.querySelector('.speaking-record-preview');
let recorder = null;
let stream = null;
let chunks = [];
let previewUrl = null;
let timerInterval = null;
let recordingStartedAt = null;
const setStatus = (message, isRecording = false) => {
status.textContent = message;
status.classList.toggle('is-recording', isRecording);
};
const stopTracks = () => {
if (stream) stream.getTracks().forEach((track) => track.stop());
stream = null;
};
const renderRecordingTime = () => {
const elapsed = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
recordTime.textContent = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`;
};
const stopTimer = () => {
if (timerInterval) window.clearInterval(timerInterval);
timerInterval = null;
};
const finishRecording = () => {
const type = recorder?.mimeType || 'audio/webm';
const recording = new File([new Blob(chunks, { type })], `speaking-response-${Date.now()}.webm`, { type: 'audio/webm' });
const transfer = new DataTransfer();
transfer.items.add(recording);
fileInput.files = transfer.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(recording);
preview.src = previewUrl;
preview.hidden = false;
recordButton.classList.remove('is-recording');
recordButton.setAttribute('aria-pressed', 'false');
recordButton.setAttribute('aria-label', 'Record again');
setStatus('Recording ready to submit');
stopTimer();
stopTracks();
recorder = null;
chunks = [];
};
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
recordButton.disabled = true;
recordButton.title = 'Your browser does not support audio recording.';
setStatus('Recording is not supported in this browser. You can still upload audio.');
return;
}
recordButton.addEventListener('click', async () => {
if (recorder?.state === 'recording') {
recorder.stop();
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
recorder = new MediaRecorder(stream);
recorder.addEventListener('dataavailable', (event) => {
if (event.data.size) chunks.push(event.data);
});
recorder.addEventListener('stop', finishRecording, { once: true });
recorder.start();
recordButton.classList.add('is-recording');
recordButton.setAttribute('aria-pressed', 'true');
recordButton.setAttribute('aria-label', 'Stop recording');
setStatus('Recording… click Stop when you are finished.', true);
recordingStartedAt = Date.now();
renderRecordingTime();
timerInterval = window.setInterval(renderRecordingTime, 1000);
} catch (error) {
stopTracks();
stopTimer();
setStatus('Microphone access was not granted. You can still upload an audio file.');
}
});
fileInput.addEventListener('change', () => {
if (!fileInput.files.length) return;
if (recorder?.state === 'recording') recorder.stop();
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(fileInput.files[0]);
preview.src = previewUrl;
preview.hidden = false;
recordButton.setAttribute('aria-label', 'Record again');
setStatus('Audio file ready to submit');
stopTimer();
});
form.addEventListener('submit', (event) => {
if (recorder?.state !== 'recording') return;
event.preventDefault();
recorder.stop();
setStatus('Recording saved. Submit the section again when ready.');
}, true);
});
})();
</script>
{% endblock %}
+1
View File
@@ -0,0 +1 @@
+56
View File
@@ -0,0 +1,56 @@
import re
import bleach
from django import template
from django.utils.html import escape, linebreaks
from django.utils.safestring import mark_safe
from exams.forms import RICH_TEXT_ATTRIBUTES, RICH_TEXT_TAGS
register = template.Library()
RICH_TAG_PATTERN = re.compile(r"</?(?:p|br|strong|em|u|h2|h3|ul|ol|li|blockquote|a)\b", re.I)
@register.filter
def render_rich_text(value):
"""Render sanitized admin formatting while preserving legacy plain-text passages."""
value = value or ""
if not RICH_TAG_PATTERN.search(value):
# django.utils.html.linebreaks returns generated HTML, but a custom
# template filter must explicitly mark that generated markup safe or
# Django will display the <p> tags as literal text.
return mark_safe(linebreaks(value))
cleaned = bleach.clean(
value,
tags=RICH_TEXT_TAGS,
attributes=RICH_TEXT_ATTRIBUTES,
protocols=["http", "https", "mailto"],
strip=True,
)
return mark_safe(cleaned)
GROUP_TAGS = ["p", "br", "strong", "em", "u", "h2", "h3", "h4", "ul", "ol", "li", "blockquote", "a", "div", "section", "table", "thead", "tbody", "tr", "th", "td", "caption", "span"]
@register.filter
def render_question_group(group):
"""Render sanitized worksheet HTML and replace [[order]] tokens with real form controls."""
questions = list(group.questions.all())
cleaned = bleach.clean(group.layout_html or "", tags=GROUP_TAGS, attributes={}, strip=True)
for question in questions:
control_id = f"q{question.id}"
if question.question_type == "matching" and question.options:
options = ['<option value="">Select an option</option>'] + [
f'<option value="{escape(option)}">{escape(option)}</option>' for option in question.options
]
control = f'<span class="exam-inline-question" id="question-{question.id}" data-question-id="{question.id}" data-question-order="{question.order}" tabindex="-1"><span class="exam-inline-number">{question.order}</span><label class="visually-hidden" for="{control_id}">Answer for question {question.order}</label><select id="{control_id}" name="q{question.id}" class="exam-inline-input">{"".join(options)}</select></span>'
else:
control = f'<span class="exam-inline-question" id="question-{question.id}" data-question-id="{question.id}" data-question-order="{question.order}" tabindex="-1"><span class="exam-inline-number">{question.order}</span><label class="visually-hidden" for="{control_id}">Answer for question {question.order}</label><input id="{control_id}" name="q{question.id}" class="exam-inline-input" type="text" placeholder="{question.order}" spellcheck="false"></span>'
cleaned = cleaned.replace(f"[[{question.order}]]", control)
first = questions[0].order if questions else ""
last = questions[-1].order if questions else ""
heading = f"Questions {first}{last}" if first != last else f"Question {first}"
instructions = bleach.clean(group.instructions or "", tags=["strong", "em", "br", "p"], strip=True)
return mark_safe(f'<article class="exam-question-group" data-layout="{escape(group.layout_type)}"><div class="exam-group-instructions"><h2>{heading}</h2>{instructions}</div><div class="exam-group-sheet">{cleaned}</div></article>')
+837
View File
@@ -0,0 +1,837 @@
from datetime import timedelta
from io import BytesIO
from tempfile import TemporaryDirectory
from unittest.mock import patch
from django.contrib.auth.models import User
from django.db import IntegrityError, transaction
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from django.template import Context, Template
from accounts.models import PremiumEntitlement
from .models import ExamSet, Question, QuestionGroup, Section, StudentAnswer, StudentAttempt
from .html_importer import create_exam_from_payload, parse_ielts_html
from .excel_importer import build_excel_template, parse_excel_test
from .views import SUBMISSION_GRACE_SECONDS
from .grading import academic_reading_band
IMPORT_HTML = b'''<!doctype html><html><body>
<div class="passage-content"><h2>Music history</h2><p>This is the reading passage.</p></div>
<div class="question-group"><p>Choose the correct answer.</p>
<div class="question" data-question="1"><div class="question-text">1. Which option is correct?</div><label><input type="radio" name="q1" value="A"> A. First choice</label><label><input type="radio" name="q1" value="B"> B. Second choice</label></div>
<div class="question" data-question="2"><div class="question-text">2. Complete this ____ <input type="text" name="q2">.</div></div>
</div><script>const correctAnswers = {q1: "B", q2: "answer"};</script></body></html>'''
class HtmlImportTests(TestCase):
def setUp(self):
self.admin_user = User.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass-123"
)
def test_parser_extracts_real_question_types_and_answers(self):
section = parse_ielts_html(IMPORT_HTML, "reading.html")
self.assertEqual(section.passage_text, "Music history\n\nThis is the reading passage.")
self.assertEqual(len(section.questions), 2)
self.assertEqual(section.questions[0].question_type, "mcq")
self.assertEqual(section.questions[0].options, ["A. First choice", "B. Second choice"])
self.assertEqual(section.questions[0].correct_answer, "B. Second choice")
self.assertEqual(section.questions[1].question_type, "gap")
self.assertEqual(section.questions[1].correct_answer, "answer")
def test_listening_import_does_not_require_a_reading_passage(self):
listening_html = b'''<div class="question" data-question="1"><div class="question-text">1. Choose one.</div><label><input type="radio" value="A"> A</label><label><input type="radio" value="B"> B</label></div><script>const correctAnswers={q1:"B"};</script>'''
section = parse_ielts_html(listening_html, "listening.html", section_type="listening")
exam = create_exam_from_payload({"title":"Listening import", "description":"", "category":"listening", "section_type":"listening", "time_limit_minutes":10, "publish":False, "sections":[section.as_payload()]})
self.assertEqual(exam.sections.get().section_type, "listening")
self.assertEqual(exam.sections.get().questions.get().correct_answer, "B")
def test_payload_creates_a_publishable_exam(self):
section = parse_ielts_html(IMPORT_HTML, "reading.html")
exam = create_exam_from_payload(
{
"title": "Imported reading test",
"description": "Imported from HTML",
"category": "reading",
"section_type": "reading",
"time_limit_minutes": 20,
"publish": True,
"sections": [section.as_payload()],
}
)
self.assertTrue(exam.is_published)
self.assertEqual(exam.sections.count(), 1)
self.assertEqual(exam.sections.get().questions.count(), 2)
def test_admin_home_uses_clean_unfold_dashboard(self):
self.client.force_login(self.admin_user)
response = self.client.get(reverse("admin:index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Testpoint")
self.assertContains(response, "Tests")
self.assertContains(response, "/static/unfold/css/styles.css")
def test_category_quick_create_prefills_the_exam_category(self):
self.client.force_login(self.admin_user)
response = self.client.get(reverse("admin:exams_examset_add") + "?category=listening")
self.assertEqual(response.status_code, 200)
self.assertContains(response, '<option value="listening" selected>', html=False)
class ExactHtmlExamTests(TestCase):
def setUp(self):
self.student = User.objects.create_user(username="exact-student", password="test-pass-123")
self.exam = ExamSet.objects.create(
title="Original HTML test",
category="reading",
is_published=True,
delivery_mode="exact_html",
source_html="<!doctype html><html><body><h1 id='kept'>Keep me exact</h1></body></html>",
)
self.client.force_login(self.student)
def test_exact_test_opens_in_the_dedicated_viewer_and_serves_raw_html(self):
start = self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
self.assertRedirects(start, reverse("exact_exam", args=[attempt.id]), fetch_redirect_response=False)
content = self.client.get(reverse("exact_exam_content", args=[attempt.id]))
self.assertEqual(content.status_code, 200)
self.assertEqual(content.content.decode(), self.exam.source_html)
self.assertIn("sandbox", content.headers["Content-Security-Policy"])
def test_exact_test_can_be_marked_complete(self):
self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
response = self.client.post(reverse("complete_exact_exam", args=[attempt.id]))
attempt.refresh_from_db()
self.assertTrue(attempt.is_complete)
self.assertRedirects(response, reverse("exam_list"), fetch_redirect_response=False)
class ReadingBandTests(TestCase):
def test_full_reading_uses_the_40_question_band_table(self):
self.assertEqual(academic_reading_band(40, 40), (9.0, 40, False))
self.assertEqual(academic_reading_band(35, 40), (8.0, 35, False))
self.assertEqual(academic_reading_band(30, 40), (7.0, 30, False))
self.assertEqual(academic_reading_band(23, 40), (6.0, 23, False))
def test_single_passage_keeps_raw_total_and_estimates_band(self):
self.assertEqual(academic_reading_band(13, 13), (9.0, 40, True))
self.assertEqual(academic_reading_band(10, 13), (7.0, 31, True))
self.assertEqual(academic_reading_band(0, 13), (0.0, 0, True))
class ExcelImportTests(TestCase):
def setUp(self):
self.admin_user = User.objects.create_superuser(
username="excel-admin", email="excel@example.com", password="admin-pass-123"
)
self.client.force_login(self.admin_user)
def test_template_round_trip_creates_editable_native_test(self):
workbook = build_excel_template()
payload = parse_excel_test(workbook, publish=True)
exam = create_exam_from_payload(payload)
self.assertEqual(exam.delivery_mode, "native")
self.assertEqual(exam.category, "reading")
self.assertTrue(exam.is_published)
self.assertEqual(exam.sections.count(), 1)
self.assertEqual(exam.sections.get().questions.count(), 2)
self.assertEqual(exam.sections.get().questions.get(order=1).explanation, "Explain why Option B is correct.")
self.assertEqual(exam.sections.get().question_groups.count(), 1)
self.assertEqual(exam.sections.get().questions.filter(group__key="notes_1").count(), 2)
def test_group_renderer_replaces_excel_placeholders_with_question_inputs(self):
payload = parse_excel_test(build_excel_template(), publish=True)
exam = create_exam_from_payload(payload)
section = exam.sections.get()
group = section.question_groups.get(key="notes_1")
rendered = Template("{% load exam_content %}{{ group|render_question_group }}").render(Context({"group": group}))
self.assertIn('class="exam-question-group"', rendered)
self.assertIn(f'name="q{section.questions.get(order=1).id}"', rendered)
self.assertIn(f'id="question-{section.questions.get(order=2).id}"', rendered)
self.assertNotIn("[[1]]", rendered)
def test_excel_time_accepts_friendly_text_and_blank_defaults(self):
from openpyxl import load_workbook
workbook_file = build_excel_template()
workbook = load_workbook(workbook_file)
workbook["Sections"]["C2"] = "20 minutes"
friendly = BytesIO()
workbook.save(friendly)
friendly.seek(0)
self.assertEqual(parse_excel_test(friendly)["sections"][0]["time_limit_minutes"], 20)
workbook["Sections"]["C2"] = ""
blank = BytesIO()
workbook.save(blank)
blank.seek(0)
self.assertEqual(parse_excel_test(blank)["sections"][0]["time_limit_minutes"], 60)
def test_legacy_workbook_without_groups_still_imports(self):
from openpyxl import load_workbook
workbook = load_workbook(build_excel_template())
del workbook["Groups"]
workbook["Questions"].delete_cols(10)
legacy = BytesIO()
workbook.save(legacy)
legacy.seek(0)
payload = parse_excel_test(legacy)
self.assertNotIn("groups", payload["sections"][0])
self.assertTrue(all(not question.get("group_key") for question in payload["sections"][0]["questions"]))
def test_preserved_excel_importer_creates_a_draft_exam(self):
workbook = build_excel_template()
payload = parse_excel_test(workbook, publish=False)
exam = create_exam_from_payload(payload)
self.assertEqual(exam.title, "Academic Reading Practice 1")
self.assertFalse(exam.is_published)
def test_plain_excel_content_renders_as_paragraphs_not_literal_html_tags(self):
rendered = Template(
"{% load exam_content %}<div>{{ value|render_rich_text }}</div>"
).render(Context({"value": "First paragraph.\n\nSecond paragraph."}))
self.assertIn("<p>First paragraph.</p>", rendered)
self.assertIn("<p>Second paragraph.</p>", rendered)
self.assertNotIn("&lt;p&gt;", rendered)
class ExamConstraintTests(TestCase):
def setUp(self):
self.student = User.objects.create_user(username="student", password="test-pass-123")
self.exam = ExamSet.objects.create(title="IELTS Mock 1")
self.section = Section.objects.create(
exam_set=self.exam,
order=1,
section_type="reading",
time_limit_minutes=60,
passage_text="The evidence sentence is in this reading passage.",
)
self.question = Question.objects.create(
section=self.section,
order=1,
question_type="gap",
prompt="Complete the sentence.",
correct_answer="answer",
)
def test_only_one_active_attempt_is_allowed_per_student_and_exam(self):
StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
with self.assertRaises(IntegrityError), transaction.atomic():
StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
def test_completed_attempt_does_not_block_a_new_attempt(self):
StudentAttempt.objects.create(
student=self.student,
exam_set=self.exam,
is_complete=True,
)
StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
def test_only_one_answer_is_allowed_per_attempt_and_question(self):
attempt = StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
StudentAnswer.objects.create(
attempt=attempt,
question=self.question,
answer_text="answer",
)
with self.assertRaises(IntegrityError), transaction.atomic():
StudentAnswer.objects.create(
attempt=attempt,
question=self.question,
answer_text="duplicate",
)
def test_multiple_choice_configuration_is_validated(self):
question = Question(
section=self.section,
order=2,
question_type="mcq",
prompt="Choose one.",
options=["A"],
correct_answer="B",
)
with self.assertRaises(ValidationError):
question.full_clean()
def test_manual_score_is_restricted_to_reviewed_response_types(self):
attempt = StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
answer = StudentAnswer(
attempt=attempt,
question=self.question,
answer_text="answer",
manual_score=7.0,
)
with self.assertRaises(ValidationError):
answer.full_clean()
class ExamNavigationTests(TestCase):
def setUp(self):
self.student = User.objects.create_user(username="navigator", password="test-pass-123")
self.exam = ExamSet.objects.create(title="Ordered IELTS Mock", is_published=True)
# Create these in reverse sequence to prove navigation does not rely on IDs.
self.second_section = Section.objects.create(
exam_set=self.exam,
order=2,
section_type="listening",
time_limit_minutes=30,
)
self.first_section = Section.objects.create(
exam_set=self.exam,
order=1,
section_type="reading",
time_limit_minutes=60,
passage_text="A short reading passage for interface testing.",
)
Question.objects.create(
section=self.first_section,
order=1,
question_type="gap",
prompt="First section question",
correct_answer="answer",
)
Question.objects.create(
section=self.second_section,
order=1,
question_type="gap",
prompt="Second section question",
correct_answer="answer",
)
self.client.force_login(self.student)
def test_start_exam_opens_lowest_ordered_section(self):
response = self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
self.assertRedirects(
response,
reverse("take_section", args=[attempt.id, self.first_section.id]),
fetch_redirect_response=False,
)
def test_start_exam_rejects_get_requests(self):
response = self.client.get(reverse("start_exam", args=[self.exam.id]))
self.assertEqual(response.status_code, 405)
def test_completed_test_can_be_retaken_without_replacing_the_old_attempt(self):
completed_attempt = StudentAttempt.objects.create(
student=self.student,
exam_set=self.exam,
is_complete=True,
submitted_at=timezone.now(),
)
response = self.client.post(reverse("start_exam", args=[self.exam.id]))
active_attempt = StudentAttempt.objects.get(
student=self.student,
exam_set=self.exam,
is_complete=False,
)
self.assertNotEqual(active_attempt.id, completed_attempt.id)
self.assertRedirects(
response,
reverse("take_section", args=[active_attempt.id, self.first_section.id]),
)
def test_retake_button_replaces_only_the_unfinished_attempt(self):
active_attempt = StudentAttempt.objects.create(
student=self.student,
exam_set=self.exam,
current_section=self.second_section,
section_started_at=timezone.now(),
section_deadline=timezone.now() + timedelta(minutes=30),
)
response = self.client.post(reverse("retake_exam", args=[active_attempt.id]))
fresh_attempt = StudentAttempt.objects.get(
student=self.student,
exam_set=self.exam,
is_complete=False,
)
self.assertFalse(StudentAttempt.objects.filter(id=active_attempt.id).exists())
self.assertNotEqual(fresh_attempt.id, active_attempt.id)
self.assertEqual(fresh_attempt.current_section, self.first_section)
self.assertRedirects(
response,
reverse("take_section", args=[fresh_attempt.id, self.first_section.id]),
)
def test_reading_section_uses_fullscreen_workspace_and_question_navigator(self):
self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
response = self.client.get(
reverse("take_section", args=[attempt.id, self.first_section.id])
)
self.assertContains(response, "exam-session--reading")
self.assertContains(response, 'id="fullscreen-toggle"')
self.assertContains(response, 'class="exam-question-nav"')
self.assertContains(response, 'data-target="question-')
self.assertContains(response, 'id="exam-resizer"')
self.assertContains(response, 'id="passage-selection-toolbar"')
self.assertContains(response, 'id="passage-highlight"')
self.assertContains(response, 'id="passage-add-note"')
self.assertContains(response, 'id="passage-clear"')
def test_submitting_section_opens_next_ordered_section(self):
attempt = StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
attempt.current_section = self.first_section
attempt.section_started_at = timezone.now()
attempt.section_deadline = timezone.now() + timedelta(minutes=60)
attempt.save()
response = self.client.post(
reverse("take_section", args=[attempt.id, self.first_section.id]),
data={},
)
self.assertRedirects(
response,
reverse("take_section", args=[attempt.id, self.second_section.id]),
fetch_redirect_response=False,
)
class ExamCatalogueTests(TestCase):
def setUp(self):
self.student = User.objects.create_user(
username="catalogue", password="test-pass-123"
)
self.exam = ExamSet.objects.create(
title="Academic Practice Test 1", is_published=True
)
section = Section.objects.create(
exam_set=self.exam,
order=1,
section_type="reading",
time_limit_minutes=60,
)
Question.objects.create(
section=section,
order=1,
question_type="gap",
prompt="Complete the answer.",
correct_answer="answer",
)
self.client.force_login(self.student)
def test_catalogue_shows_real_test_metadata(self):
response = self.client.get(reverse("exam_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Academic Practice Test 1")
self.assertContains(response, "1</strong> section")
self.assertContains(response, "1</strong> question")
self.assertContains(response, "60</strong> min")
self.assertNotContains(response, "рџ")
def test_catalogue_status_filter_uses_the_students_attempt(self):
StudentAttempt.objects.create(student=self.student, exam_set=self.exam)
response = self.client.get(reverse("exam_list") + "?status=in_progress")
self.assertContains(response, "Academic Practice Test 1")
self.assertContains(response, "Continue test")
self.assertEqual(response.context["counts"]["in_progress"], 1)
def test_invalid_filter_falls_back_to_all(self):
response = self.client.get(reverse("exam_list") + "?status=unknown")
self.assertEqual(response.context["status_filter"], "all")
def test_skill_filter_shows_only_the_selected_category(self):
self.exam.category = "full"
self.exam.save(update_fields=["category"])
reading_exam = ExamSet.objects.create(
title="Reading Only", category="reading", is_published=True
)
reading_section = Section.objects.create(
exam_set=reading_exam,
order=1,
section_type="reading",
time_limit_minutes=20,
)
Question.objects.create(
section=reading_section,
order=1,
question_type="gap",
prompt="Answer",
correct_answer="yes",
)
response = self.client.get(reverse("exam_list") + "?skill=reading")
self.assertContains(response, "Reading Only")
self.assertNotContains(response, self.exam.title)
self.assertEqual(response.context["skill_filter"], "reading")
self.assertEqual(response.context["skill_counts"]["reading"], 1)
def test_unpublished_tests_are_not_visible_or_startable(self):
self.exam.is_published = False
self.exam.save(update_fields=["is_published"])
catalogue = self.client.get(reverse("exam_list"))
start = self.client.post(reverse("start_exam", args=[self.exam.id]))
self.assertNotContains(catalogue, self.exam.title)
self.assertEqual(start.status_code, 404)
class ServerDeadlineTests(TestCase):
def setUp(self):
self.student = User.objects.create_user(username="timed", password="test-pass-123")
self.exam = ExamSet.objects.create(title="Timed IELTS Mock", is_published=True)
self.section = Section.objects.create(
exam_set=self.exam,
order=1,
section_type="reading",
time_limit_minutes=60,
passage_text="The evidence sentence is in this reading passage.",
)
self.question = Question.objects.create(
section=self.section,
order=1,
question_type="gap",
prompt="Complete this answer.",
correct_answer="valid",
explanation="The passage gives the exact word used in the answer.",
passage_reference="The evidence sentence is in this reading passage.",
)
self.client.force_login(self.student)
def start_attempt(self):
self.client.post(reverse("start_exam", args=[self.exam.id]))
return StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
def test_refresh_does_not_reset_server_deadline(self):
attempt = self.start_attempt()
original_deadline = attempt.section_deadline
self.client.get(reverse("take_section", args=[attempt.id, self.section.id]))
attempt.refresh_from_db()
self.assertEqual(attempt.section_deadline, original_deadline)
def test_late_submission_does_not_accept_new_answer_text(self):
attempt = self.start_attempt()
late_time = attempt.section_deadline + timedelta(seconds=SUBMISSION_GRACE_SECONDS + 1)
with patch("exams.views.timezone.now", return_value=late_time):
self.client.post(
reverse("take_section", args=[attempt.id, self.section.id]),
data={f"q{self.question.id}": "valid"},
)
answer = StudentAnswer.objects.get(attempt=attempt, question=self.question)
self.assertEqual(answer.answer_text, "")
self.assertFalse(answer.is_correct)
def test_user_cannot_skip_to_a_later_section(self):
later_section = Section.objects.create(
exam_set=self.exam,
order=2,
section_type="listening",
time_limit_minutes=30,
)
Question.objects.create(
section=later_section,
order=1,
question_type="gap",
prompt="Later question",
correct_answer="answer",
)
attempt = self.start_attempt()
response = self.client.get(
reverse("take_section", args=[attempt.id, later_section.id])
)
self.assertRedirects(
response,
reverse("take_section", args=[attempt.id, self.section.id]),
fetch_redirect_response=False,
)
def test_completion_awards_xp_and_updates_streak_once(self):
attempt = self.start_attempt()
response = self.client.post(
reverse("take_section", args=[attempt.id, self.section.id]),
data={f"q{self.question.id}": "valid"},
)
self.student.studentprofile.refresh_from_db()
self.assertRedirects(
response,
reverse("results", args=[attempt.id]),
fetch_redirect_response=False,
)
self.assertEqual(self.student.studentprofile.xp, 60)
self.assertEqual(self.student.studentprofile.streak, 1)
self.assertEqual(self.student.studentprofile.last_activity_date, timezone.localdate())
def test_results_review_shows_correct_answer_explanation_and_passage_link(self):
attempt = self.start_attempt()
self.client.post(
reverse("take_section", args=[attempt.id, self.section.id]),
data={f"q{self.question.id}": "wrong"},
)
response = self.client.get(reverse("results", args=[attempt.id]))
self.assertContains(response, "Correct answer")
self.assertNotContains(response, self.question.explanation)
self.assertContains(response, self.question.passage_reference)
self.assertContains(response, 'class="reader-show-evidence"')
self.assertContains(response, f'data-passage-section="{self.section.id}"')
class DiagnosticSeedTests(TestCase):
def test_seed_command_creates_one_ready_published_diagnostic(self):
call_command("seed_diagnostic_exam", verbosity=0)
call_command("seed_diagnostic_exam", verbosity=0)
exam = ExamSet.objects.get(title="IELTS Skills Diagnostic")
self.assertTrue(exam.is_published)
self.assertTrue(exam.is_ready)
self.assertEqual(exam.sections.count(), 3)
self.assertEqual(Question.objects.filter(section__exam_set=exam).count(), 5)
class PracticeLibrarySeedTests(TestCase):
def setUp(self):
self.media_directory = TemporaryDirectory()
self.media_override = override_settings(MEDIA_ROOT=self.media_directory.name)
self.media_override.enable()
def tearDown(self):
self.media_override.disable()
self.media_directory.cleanup()
super().tearDown()
def test_seed_library_creates_all_five_separate_categories(self):
call_command("seed_practice_library", verbosity=0)
call_command("seed_practice_library", verbosity=0)
published = ExamSet.objects.filter(is_published=True)
self.assertEqual(published.count(), 5)
self.assertEqual(
set(published.values_list("category", flat=True)),
{"reading", "listening", "writing", "speaking", "full"},
)
full_mock = published.get(category="full")
self.assertEqual(
list(full_mock.sections.values_list("section_type", flat=True)),
["listening", "reading", "writing", "speaking"],
)
listening = published.get(category="listening").sections.get()
self.assertTrue(bool(listening.audio_file))
class CompleteDiagnosticJourneyTests(TestCase):
def setUp(self):
self.media_directory = TemporaryDirectory()
self.media_override = override_settings(MEDIA_ROOT=self.media_directory.name)
self.media_override.enable()
call_command("seed_diagnostic_exam", verbosity=0)
self.exam = ExamSet.objects.get(title="IELTS Skills Diagnostic")
self.student = User.objects.create_user(
username="journey", password="test-pass-123"
)
self.client.force_login(self.student)
def tearDown(self):
self.media_override.disable()
self.media_directory.cleanup()
super().tearDown()
def test_student_can_complete_the_published_diagnostic(self):
detail = self.client.get(reverse("exam_detail", args=[self.exam.id]))
self.assertContains(detail, "Begin test")
start = self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
reading, writing, speaking = list(self.exam.sections.all())
self.assertRedirects(
start,
reverse("take_section", args=[attempt.id, reading.id]),
fetch_redirect_response=False,
)
reading_answers = {
f"q{question.id}": question.correct_answer
for question in reading.questions.all()
}
reading_submit = self.client.post(
reverse("take_section", args=[attempt.id, reading.id]),
data=reading_answers,
)
self.assertRedirects(
reading_submit,
reverse("take_section", args=[attempt.id, writing.id]),
fetch_redirect_response=False,
)
writing_question = writing.questions.get()
self.client.post(
reverse("take_section", args=[attempt.id, writing.id]),
data={f"q{writing_question.id}": "A structured diagnostic essay response."},
)
speaking_question = speaking.questions.get()
finish = self.client.post(
reverse("take_section", args=[attempt.id, speaking.id]),
data={f"q{speaking_question.id}": "Structured notes for a spoken response."},
follow=True,
)
attempt.refresh_from_db()
self.student.studentprofile.refresh_from_db()
self.assertTrue(attempt.is_complete)
self.assertEqual(attempt.answers.count(), 5)
self.assertEqual(attempt.answers.filter(is_correct=True).count(), 3)
self.assertEqual(self.student.studentprofile.xp, 100)
self.assertContains(finish, "3 / 3")
self.assertContains(finish, "Awaiting review")
self.assertContains(finish, "Pending instructor review", count=2)
def test_speaking_audio_upload_is_stored_and_invalid_types_are_rejected(self):
self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.student, exam_set=self.exam)
reading, writing, speaking = list(self.exam.sections.all())
reading_answers = {
f"q{question.id}": question.correct_answer
for question in reading.questions.all()
}
self.client.post(
reverse("take_section", args=[attempt.id, reading.id]),
data=reading_answers,
)
writing_question = writing.questions.get()
self.client.post(
reverse("take_section", args=[attempt.id, writing.id]),
data={f"q{writing_question.id}": "Essay response"},
)
speaking_question = speaking.questions.get()
invalid = SimpleUploadedFile("response.exe", b"not audio")
rejected = self.client.post(
reverse("take_section", args=[attempt.id, speaking.id]),
data={f"q{speaking_question.id}_audio": invalid},
follow=True,
)
self.assertContains(rejected, "File extension")
attempt.refresh_from_db()
self.assertFalse(attempt.is_complete)
valid = SimpleUploadedFile("response.webm", b"small audio placeholder", "audio/webm")
self.client.post(
reverse("take_section", args=[attempt.id, speaking.id]),
data={f"q{speaking_question.id}_audio": valid},
)
answer = StudentAnswer.objects.get(attempt=attempt, question=speaking_question)
self.assertTrue(answer.audio_response.name.endswith(".webm"))
class PremiumExamAccessTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="free-student",
password="test-password",
)
self.exam = ExamSet.objects.create(
title="Premium Reading Test",
category="reading",
access_level=ExamSet.ACCESS_PREMIUM,
is_published=True,
)
self.section = Section.objects.create(
exam_set=self.exam,
order=1,
section_type="reading",
time_limit_minutes=20,
passage_text="A short reading passage.",
)
Question.objects.create(
section=self.section,
order=1,
question_type="gap",
prompt="Complete the sentence.",
correct_answer="answer",
)
self.client.force_login(self.user)
def test_free_user_sees_locked_test_and_cannot_start_it(self):
catalogue = self.client.get(reverse("exam_list"))
self.assertContains(catalogue, "Premium Reading Test")
self.assertContains(catalogue, "Unlock Premium Reading Test")
response = self.client.post(reverse("start_exam", args=[self.exam.id]))
self.assertRedirects(response, reverse("premium"))
self.assertFalse(
StudentAttempt.objects.filter(student=self.user, exam_set=self.exam).exists()
)
def test_premium_user_can_start_premium_test(self):
PremiumEntitlement.objects.create(
user=self.user,
source=PremiumEntitlement.SOURCE_PAYMENT,
order_reference="ORDER-3003",
)
response = self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.user, exam_set=self.exam)
self.assertRedirects(
response,
reverse("take_section", args=[attempt.id, self.section.id]),
)
def test_active_attempt_remains_available_after_revocation(self):
entitlement = PremiumEntitlement.objects.create(user=self.user)
self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.user, exam_set=self.exam)
entitlement.revoked_at = timezone.now()
entitlement.save(update_fields=["revoked_at"])
detail = self.client.get(reverse("exam_detail", args=[self.exam.id]))
self.assertContains(detail, "Continue test")
continuation = self.client.post(reverse("start_exam", args=[self.exam.id]))
self.assertRedirects(
continuation,
reverse("take_section", args=[attempt.id, self.section.id]),
)
def test_revoked_user_cannot_replace_attempt_with_retake(self):
entitlement = PremiumEntitlement.objects.create(user=self.user)
self.client.post(reverse("start_exam", args=[self.exam.id]))
attempt = StudentAttempt.objects.get(student=self.user, exam_set=self.exam)
entitlement.revoked_at = timezone.now()
entitlement.save(update_fields=["revoked_at"])
response = self.client.post(reverse("retake_exam", args=[attempt.id]))
self.assertRedirects(response, reverse("premium"))
self.assertTrue(StudentAttempt.objects.filter(id=attempt.id).exists())
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.exam_list, name='exam_list'),
path('<int:exam_set_id>/', views.exam_detail, name='exam_detail'),
path('start/<int:exam_set_id>/', views.start_exam, name='start_exam'),
path('attempt/<int:attempt_id>/section/<int:section_id>/', views.take_section, name='take_section'),
path('attempt/<int:attempt_id>/retake/', views.retake_exam, name='retake_exam'),
path('attempt/<int:attempt_id>/exact/', views.exact_exam, name='exact_exam'),
path('attempt/<int:attempt_id>/exact/content/', views.exact_exam_content, name='exact_exam_content'),
path('attempt/<int:attempt_id>/exact/complete/', views.complete_exact_exam, name='complete_exact_exam'),
path('results/<int:attempt_id>/', views.results, name='results'),
]
+486
View File
@@ -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
),
},
)