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