diff --git a/generic/templates/generic/exam_index.html b/generic/templates/generic/exam_index.html
index 6e33966f..0c4ad510 100644
--- a/generic/templates/generic/exam_index.html
+++ b/generic/templates/generic/exam_index.html
@@ -1,26 +1,33 @@
{% extends app_name|add:'/base.html' %}
{% block content %}
-
-
-
Authors: {{exam.get_authors}}
- {% if exam.exam_mode %}
- {% if app_name in "rapids longs anatomy" %}
- {% if request.user.is_staff %}
Mark answers{% endif %}
- {% endif %}
- {% if request.user.is_staff %}
Scores{% endif %}
- {% endif %}
+
+
+
+
- {% endfor %}
+
+
+
+ {% include 'generic/partials/_exam_list.html' with filter=filter app_name=app_name %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
{% endblock %}
@@ -37,3 +44,23 @@
{% endblock css %}
+{% block extra_js %}
+
+{% endblock %}
+
diff --git a/generic/templates/generic/partials/_exam_list.html b/generic/templates/generic/partials/_exam_list.html
new file mode 100644
index 00000000..d405fa1e
--- /dev/null
+++ b/generic/templates/generic/partials/_exam_list.html
@@ -0,0 +1,26 @@
+{# Partial: list of exams used by HTMX bulk actions #}
+
+
{{ filter.qs|length }} exams found.
+
+ {% for exam in filter.qs %}
+
+
+
+
+
+
+
+
Authors: {{ exam.get_authors }}
+ {% if exam.exam_mode %}
+ {% if app_name in "rapids longs anatomy" %}
+ {% if request.user.is_staff %}
Mark answers{% endif %}
+ {% endif %}
+ {% if request.user.is_staff %}
Scores{% endif %}
+ {% endif %}
+
+
+
+ {% endfor %}
+
diff --git a/generic/urls.py b/generic/urls.py
index d3258535..1b2ac1dd 100755
--- a/generic/urls.py
+++ b/generic/urls.py
@@ -333,6 +333,16 @@ def generic_view_urls(generic_views: GenericViewBase):
def generic_exam_urls(generic_exam_view: GenericExamViews):
urlpatterns = [
+ path(
+ "exam/bulk_update/",
+ generic_exam_view.exam_bulk_update,
+ name="exam_bulk_update",
+ ),
+ path(
+ "exam/bulk_delete/",
+ generic_exam_view.exam_bulk_delete,
+ name="exam_bulk_delete",
+ ),
path("", generic_exam_view.index, name="index"),
path("exam/
/", generic_exam_view.exam_overview, name="exam_overview"),
path(
diff --git a/generic/views.py b/generic/views.py
index 027ab42c..8ed1217f 100644
--- a/generic/views.py
+++ b/generic/views.py
@@ -988,6 +988,117 @@ class ExamViews(View, LoginRequiredMixin):
)
)
+ @method_decorator(login_required)
+ def exam_bulk_update(self, request):
+ """HTMX endpoint to bulk-update exams (currently supports updating the date).
+
+ Expects POST with 'exam_ids' (multiple) and optional 'date' (YYYY-MM-DD).
+ Returns the updated exam list partial.
+ """
+ if request.method != "POST":
+ return HttpResponse(status=405)
+
+ exam_ids = request.POST.getlist("exam_ids")
+ date_str = request.POST.get("date", "")
+
+ # Parse date if provided
+ new_date = None
+ if date_str:
+ try:
+ from datetime import date as _date
+
+ new_date = _date.fromisoformat(date_str)
+ except Exception:
+ new_date = None
+
+ # Find the queryset for the current user as index() does
+ if (
+ request.user.is_superuser
+ or (
+ self.app_name == "rapids"
+ and request.user.groups.filter(name="rapid_checker").exists()
+ )
+ or (
+ self.app_name == "anatomy"
+ and request.user.groups.filter(name="anatomy_checker").exists()
+ )
+ or (
+ self.app_name == "longs"
+ and request.user.groups.filter(name="long_checker").exists()
+ )
+ ):
+ exams_qs = self.Exam.objects.all()
+ filter = self.ExtraExamFilter(request.GET, queryset=exams_qs)
+ else:
+ exams_qs = self.Exam.objects.filter(author__id=request.user.id) | self.Exam.objects.filter(open_access=True)
+ filter = self.BasicExamFilter(request.GET, queryset=exams_qs)
+
+ # Apply changes
+ for eid in exam_ids:
+ try:
+ exam = self.Exam.objects.get(pk=eid)
+ except self.Exam.DoesNotExist:
+ continue
+
+ if not self.check_user_edit_access(request.user, exam_id=exam.pk):
+ # Skip exams the user cannot edit
+ continue
+
+ if new_date is not None:
+ exam.date = new_date
+ exam.save()
+
+ return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
+
+ @method_decorator(login_required)
+ def exam_bulk_delete(self, request):
+ """HTMX endpoint to bulk-delete selected exams. Expects POST 'exam_ids'.
+
+ Returns the updated exam list partial.
+ """
+ if request.method != "POST":
+ return HttpResponse(status=405)
+
+ exam_ids = request.POST.getlist("exam_ids")
+
+ if (
+ request.user.is_superuser
+ or (
+ self.app_name == "rapids"
+ and request.user.groups.filter(name="rapid_checker").exists()
+ )
+ or (
+ self.app_name == "anatomy"
+ and request.user.groups.filter(name="anatomy_checker").exists()
+ )
+ or (
+ self.app_name == "longs"
+ and request.user.groups.filter(name="long_checker").exists()
+ )
+ ):
+ exams_qs = self.Exam.objects.all()
+ filter = self.ExtraExamFilter(request.GET, queryset=exams_qs)
+ else:
+ exams_qs = self.Exam.objects.filter(author__id=request.user.id) | self.Exam.objects.filter(open_access=True)
+ filter = self.BasicExamFilter(request.GET, queryset=exams_qs)
+
+ for eid in exam_ids:
+ try:
+ exam = self.Exam.objects.get(pk=eid)
+ except self.Exam.DoesNotExist:
+ continue
+
+ if not self.check_user_edit_access(request.user, exam_id=exam.pk):
+ continue
+
+ try:
+ exam.delete()
+ except Exception:
+ # ignore individual delete failures
+ continue
+
+ return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
+
@method_decorator(login_required)
def exam_toggle_results_published(self, request, pk):
if request.method == "POST":