From 4ecc32f83791a37de160af2e77d1d2131c0798e5 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 29 Dec 2025 12:26:56 +0000 Subject: [PATCH] Implement HTMX lazy-loading for exam collections and optimize queryset annotations for performance --- .../generic/examcollection_detail.html | 48 ++--- .../generic/partials/exam_list_fragment.html | 6 + generic/urls.py | 1 + generic/views.py | 165 ++++++++++++------ 4 files changed, 132 insertions(+), 88 deletions(-) create mode 100644 generic/templates/generic/partials/exam_list_fragment.html diff --git a/generic/templates/generic/examcollection_detail.html b/generic/templates/generic/examcollection_detail.html index 17aa224d..aa2f5aac 100755 --- a/generic/templates/generic/examcollection_detail.html +++ b/generic/templates/generic/examcollection_detail.html @@ -22,7 +22,7 @@ - {% if object.anatomy_exams.all %} + {% if object.anatomy_count %}
Anatomy Exams @@ -31,16 +31,14 @@
-
- {% with exams=object.anatomy_exams.all app_name="anatomy" %} - {% include "exam_list.html#exam-list" %} - {% endwith %} +
+
Loading anatomy exams…
{% endif %} - {% if object.longs_exams.all %} + {% if object.longs_count %}
Longs Exams @@ -49,16 +47,14 @@
-
- {% with exams=object.longs_exams.all app_name="longs" %} - {% include "exam_list.html#exam-list" %} - {% endwith %} +
+
Loading longs exams…
{% endif %} - {% if object.rapids_exams.all %} + {% if object.rapids_count %}
Rapids Exams @@ -67,16 +63,14 @@
-
- {% with exams=object.rapids_exams.all app_name="rapids" %} - {% include "exam_list.html#exam-list" %} - {% endwith %} +
+
Loading rapids exams…
{% endif %} - {% if object.shorts_exams.all %} + {% if object.shorts_count %}
Shorts Exams @@ -85,39 +79,33 @@
-
- {% with exams=object.shorts_exams.all app_name="shorts" %} - {% include "exam_list.html#exam-list" %} - {% endwith %} +
+
Loading shorts exams…
{% endif %} - {% if object.physics_exams.all %} + {% if object.physics_count %}
Physics Exams View exam list
-
- {% with exams=object.physics_exams.all app_name="physics" %} - {% include "exam_list.html#exam-list" %} - {% endwith %} +
+
Loading physics exams…
{% endif %} - {% if object.sbas_exams.all %} + {% if object.sbas_count %}
SBAs Exams View exam list
-
- {% with exams=object.sbas_exams.all app_name="sbas" %} - {% include "exam_list.html#exam-list" %} - {% endwith %} +
+
Loading SBAs exams…
{% endif %} diff --git a/generic/templates/generic/partials/exam_list_fragment.html b/generic/templates/generic/partials/exam_list_fragment.html new file mode 100644 index 00000000..be30c3c1 --- /dev/null +++ b/generic/templates/generic/partials/exam_list_fragment.html @@ -0,0 +1,6 @@ +{% load partials %} + +{% comment %} Minimal fragment rendering the exam-list partialdef for HTMX swaps. {% endcomment %} +{% with exams=exams app_name=app_name marking=marking collection=collection %} + {% include "exam_list.html#exam-list" %} +{% endwith %} diff --git a/generic/urls.py b/generic/urls.py index dbc83b56..d5205ed2 100755 --- a/generic/urls.py +++ b/generic/urls.py @@ -286,6 +286,7 @@ urlpatterns = [ path("collection//add_author", views.exam_collection_add_author, name="exam_collection_add_author"), path("collection//bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"), path("collection//add_marker/", views.exam_collection_add_marker, name="exam_collection_add_marker"), + path("collection//exams//partial", views.exam_collection_exams_partial, name="exam_collection_exams_partial"), path( "share_with_supervisor_toggle//", views.toggle_share_with_supervisor, diff --git a/generic/views.py b/generic/views.py index 6abc9745..7b9d913a 100644 --- a/generic/views.py +++ b/generic/views.py @@ -130,6 +130,89 @@ def cid_user_exam_partial(request, app_name: str, exam_id: int, cid: int): return render(request, "generic/partials/ciduserexam_detail_partial.html", {"cid_user_exam": cux}) +def exam_collection_exams_partial(request, collection_id: int, exam_type: str): + """Return the `exam-list` partial for a given collection and exam type. + + This endpoint is intended for HTMX lazy-loading. It annotates candidate counts + and prefetches authors to avoid per-exam DB queries in the template. + """ + collection = get_object_or_404(ExamCollection, pk=collection_id) + + # Map exam_type to related name and model + EXAM_MAP = { + "anatomy": ("anatomy_exams", "anatomy"), + "longs": ("longs_exams", "longs"), + "rapids": ("rapids_exams", "rapids"), + "shorts": ("shorts_exams", "shorts"), + "physics": ("physics_exams", "physics"), + "sbas": ("sbas_exams", "sbas"), + } + + if exam_type not in EXAM_MAP: + return HttpResponse("Unknown exam type", status=400) + + rel_name, app_label = EXAM_MAP[exam_type] + + # Attempt to import the model class for the app to annotate counts + ExamModel = None + try: + if exam_type == "anatomy": + from anatomy.models import Exam as AnatomyExam + + ExamModel = AnatomyExam + elif exam_type == "longs": + from longs.models import Exam as LongsExam + + ExamModel = LongsExam + elif exam_type == "rapids": + from rapids.models import Exam as RapidsExam + + ExamModel = RapidsExam + elif exam_type == "shorts": + from shorts.models import Exam as ShortsExam + + ExamModel = ShortsExam + elif exam_type == "physics": + from physics.models import Exam as PhysicsExam + + ExamModel = PhysicsExam + elif exam_type == "sbas": + from sbas.models import Exam as SbasExam + + ExamModel = SbasExam + except Exception: + ExamModel = None + + # Build queryset on the related manager; filter archived false by default + related_qs = getattr(collection, rel_name).all().filter(archive=False) + + if ExamModel is not None: + # Avoid evaluating the related manager multiple times; use ids + pks = list(related_qs.values_list("pk", flat=True)) + related_qs = ( + ExamModel.objects.filter(pk__in=pks) + .annotate( + valid_cid_users_count=Count("valid_cid_users"), + valid_user_users_count=Count("valid_user_users"), + ) + .prefetch_related("author") + .order_by("name") + ) + + context = { + "exams": related_qs, + "app_name": app_label, + "marking": request.user.has_perm("generic.can_mark") if request.user.is_authenticated else False, + "collection": collection, + } + + # Render only the fragment template which includes the `exam-list` partialdef + html = render_to_string( + "generic/partials/exam_list_fragment.html", context, request=request + ) + return HttpResponse(html) + + from rapids.models import ( Rapid as RapidQuestion, ExamQuestionDetail as RapidsExamQuestionDetail, @@ -5875,68 +5958,34 @@ class ExamCollectionList(LoginRequiredMixin, ListView): class ExamCollectionDetail(AuthorRequiredMixin, DetailView): model = ExamCollection - + # Annotate counts for each exam type (non-archived) so template can check presence + # without evaluating entire related querysets. def get_queryset(self): - # Prefetch related exam sets and annotate candidate counts to avoid per-exam DB hits in templates qs = super().get_queryset() + counts = { + "anatomy_count": Count( + "anatomy_exams", filter=Q(anatomy_exams__archive=False), distinct=True + ), + "longs_count": Count( + "longs_exams", filter=Q(longs_exams__archive=False), distinct=True + ), + "physics_count": Count( + "physics_exams", filter=Q(physics_exams__archive=False), distinct=True + ), + "rapids_count": Count( + "rapids_exams", filter=Q(rapids_exams__archive=False), distinct=True + ), + "sbas_count": Count( + "sbas_exams", filter=Q(sbas_exams__archive=False), distinct=True + ), + "shorts_count": Count( + "shorts_exams", filter=Q(shorts_exams__archive=False), distinct=True + ), + } try: - from anatomy.models import Exam as AnatomyExam - from longs.models import Exam as LongsExam - from rapids.models import Exam as RapidsExam - from shorts.models import Exam as ShortsExam - from physics.models import Exam as PhysicsExam - from sbas.models import Exam as SbasExam - - qs = qs.prefetch_related( - Prefetch( - 'anatomy_exams', - queryset=AnatomyExam.objects.annotate( - valid_cid_users_count=Count('valid_cid_users'), - valid_user_users_count=Count('valid_user_users'), - ).prefetch_related('author'), - ), - Prefetch( - 'longs_exams', - queryset=LongsExam.objects.annotate( - valid_cid_users_count=Count('valid_cid_users'), - valid_user_users_count=Count('valid_user_users'), - ).prefetch_related('author'), - ), - Prefetch( - 'rapids_exams', - queryset=RapidsExam.objects.annotate( - valid_cid_users_count=Count('valid_cid_users'), - valid_user_users_count=Count('valid_user_users'), - ).prefetch_related('author'), - ), - Prefetch( - 'shorts_exams', - queryset=ShortsExam.objects.annotate( - valid_cid_users_count=Count('valid_cid_users'), - valid_user_users_count=Count('valid_user_users'), - ).prefetch_related('author'), - ), - Prefetch( - 'physics_exams', - queryset=PhysicsExam.objects.annotate( - valid_cid_users_count=Count('valid_cid_users'), - valid_user_users_count=Count('valid_user_users'), - ).prefetch_related('author'), - ), - Prefetch( - 'sbas_exams', - queryset=SbasExam.objects.annotate( - valid_cid_users_count=Count('valid_cid_users'), - valid_user_users_count=Count('valid_user_users'), - ).prefetch_related('author'), - ), - 'author', - ) + return qs.annotate(**counts) except Exception: - # If any app is missing or import fails, fall back to default queryset - pass - - return qs + return qs class ExamCollectionEdit(AuthorRequiredMixin, UpdateView):