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
+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})
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):