Implement HTMX lazy-loading for exam collections and optimize queryset annotations for performance
This commit is contained in:
@@ -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 %}
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user