Implement HTMX lazy-loading for exam collections and optimize queryset annotations for performance

This commit is contained in:
Ross
2025-12-29 12:26:56 +00:00
parent 768e1bf8c5
commit 4ecc32f837
4 changed files with 132 additions and 88 deletions
@@ -22,7 +22,7 @@
{% if object.anatomy_exams.all %} {% if object.anatomy_count %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Anatomy Exams</strong> <strong>Anatomy Exams</strong>
@@ -31,16 +31,14 @@
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button> <button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div> </div>
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'anatomy' %}" hx-trigger="revealed" hx-swap="innerHTML">
{% with exams=object.anatomy_exams.all app_name="anatomy" %} <div class="placeholder small text-muted">Loading anatomy exams…</div>
{% include "exam_list.html#exam-list" %}
{% endwith %}
</div> </div>
<div class="card-footer add-marker small"></div> <div class="card-footer add-marker small"></div>
</div> </div>
{% endif %} {% endif %}
{% if object.longs_exams.all %} {% if object.longs_count %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Longs Exams</strong> <strong>Longs Exams</strong>
@@ -49,16 +47,14 @@
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button> <button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div> </div>
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'longs' %}" hx-trigger="revealed" hx-swap="innerHTML">
{% with exams=object.longs_exams.all app_name="longs" %} <div class="placeholder small text-muted">Loading longs exams…</div>
{% include "exam_list.html#exam-list" %}
{% endwith %}
</div> </div>
<div class="card-footer add-marker small"></div> <div class="card-footer add-marker small"></div>
</div> </div>
{% endif %} {% endif %}
{% if object.rapids_exams.all %} {% if object.rapids_count %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Rapids Exams</strong> <strong>Rapids Exams</strong>
@@ -67,16 +63,14 @@
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button> <button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div> </div>
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'rapids' %}" hx-trigger="revealed" hx-swap="innerHTML">
{% with exams=object.rapids_exams.all app_name="rapids" %} <div class="placeholder small text-muted">Loading rapids exams…</div>
{% include "exam_list.html#exam-list" %}
{% endwith %}
</div> </div>
<div class="card-footer add-marker small"></div> <div class="card-footer add-marker small"></div>
</div> </div>
{% endif %} {% endif %}
{% if object.shorts_exams.all %} {% if object.shorts_count %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Shorts Exams</strong> <strong>Shorts Exams</strong>
@@ -85,39 +79,33 @@
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button> <button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div> </div>
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'shorts' %}" hx-trigger="revealed" hx-swap="innerHTML">
{% with exams=object.shorts_exams.all app_name="shorts" %} <div class="placeholder small text-muted">Loading shorts exams…</div>
{% include "exam_list.html#exam-list" %}
{% endwith %}
</div> </div>
<div class="card-footer add-marker small"></div> <div class="card-footer add-marker small"></div>
</div> </div>
{% endif %} {% endif %}
{% if object.physics_exams.all %} {% if object.physics_count %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Physics Exams</strong> <strong>Physics Exams</strong>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a>
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'physics' %}" hx-trigger="revealed" hx-swap="innerHTML">
{% with exams=object.physics_exams.all app_name="physics" %} <div class="placeholder small text-muted">Loading physics exams…</div>
{% include "exam_list.html#exam-list" %}
{% endwith %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if object.sbas_exams.all %} {% if object.sbas_count %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>SBAs Exams</strong> <strong>SBAs Exams</strong>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a>
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'sbas' %}" hx-trigger="revealed" hx-swap="innerHTML">
{% with exams=object.sbas_exams.all app_name="sbas" %} <div class="placeholder small text-muted">Loading SBAs exams…</div>
{% include "exam_list.html#exam-list" %}
{% endwith %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -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 %}
+1
View File
@@ -286,6 +286,7 @@ urlpatterns = [
path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"), path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"),
path("collection/<int:collection_id>/bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"), path("collection/<int:collection_id>/bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"),
path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"), path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"),
path("collection/<int:collection_id>/exams/<str:exam_type>/partial", views.exam_collection_exams_partial, name="exam_collection_exams_partial"),
path( path(
"share_with_supervisor_toggle/<int:pk>/", "share_with_supervisor_toggle/<int:pk>/",
views.toggle_share_with_supervisor, views.toggle_share_with_supervisor,
+107 -58
View File
@@ -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}) 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 ( from rapids.models import (
Rapid as RapidQuestion, Rapid as RapidQuestion,
ExamQuestionDetail as RapidsExamQuestionDetail, ExamQuestionDetail as RapidsExamQuestionDetail,
@@ -5875,68 +5958,34 @@ class ExamCollectionList(LoginRequiredMixin, ListView):
class ExamCollectionDetail(AuthorRequiredMixin, DetailView): class ExamCollectionDetail(AuthorRequiredMixin, DetailView):
model = ExamCollection model = ExamCollection
# Annotate counts for each exam type (non-archived) so template can check presence
# without evaluating entire related querysets.
def get_queryset(self): def get_queryset(self):
# Prefetch related exam sets and annotate candidate counts to avoid per-exam DB hits in templates
qs = super().get_queryset() 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: try:
from anatomy.models import Exam as AnatomyExam return qs.annotate(**counts)
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',
)
except Exception: except Exception:
# If any app is missing or import fails, fall back to default queryset return qs
pass
return qs
class ExamCollectionEdit(AuthorRequiredMixin, UpdateView): class ExamCollectionEdit(AuthorRequiredMixin, UpdateView):