diff --git a/generic/templates/generic/exam_index.html b/generic/templates/generic/exam_index.html
index fe40c4da..d98822a5 100644
--- a/generic/templates/generic/exam_index.html
+++ b/generic/templates/generic/exam_index.html
@@ -3,10 +3,14 @@
{% block content %}
-
-
-
+
+
+
+
+
+
+
@@ -31,6 +35,16 @@
{% csrf_token %}
+
@@ -67,6 +81,8 @@
/* Defensive: hide bulk-controls unless bulk-mode is enabled */
#exam-list-wrapper:not(.bulk-mode) #bulk-controls { display: none !important; }
+ /* (bulk-only top-level controls removed) */
+
/* Bulk action indicator - hidden by default, shown while HTMX request is active */
#bulk-action-indicator { display: none; }
#bulk-action-indicator.htmx-request { display: inline-block; }
@@ -106,6 +122,58 @@
});
}
+ // Top-level archive/unarchive buttons: make visible and usable without hunting inside bulk panel
+ const topArchiveBtn = document.getElementById('top-archive-btn');
+ const topUnarchiveBtn = document.getElementById('top-unarchive-btn');
+ function ensureBulkModeVisible(){
+ if(!examListWrapper.classList.contains('bulk-mode')){
+ examListWrapper.classList.add('bulk-mode');
+ bulkControls.classList.remove('d-none');
+ }
+ }
+
+ function submitArchiveAction(isArchive){
+ // find the bulk-archive form already in the DOM
+ const form = document.getElementById('bulk-archive-form');
+ if(!form){
+ alert('Bulk archive form not found.');
+ return;
+ }
+
+ // if no checkboxes selected, open bulk-mode and focus user to select
+ const checked = Array.from(document.querySelectorAll('#exam-list-container input.exam-select-checkbox:checked'));
+ if(checked.length === 0){
+ ensureBulkModeVisible();
+ // scroll to controls so user can pick
+ bulkControls.scrollIntoView({behavior:'smooth', block:'center'});
+ // highlight controls briefly
+ bulkControls.classList.add('border', 'border-3', 'border-primary');
+ setTimeout(()=> bulkControls.classList.remove('border','border-3','border-primary'), 2000);
+ return;
+ }
+
+ // set the archive input value on the appropriate button and submit
+ const btn = form.querySelector('button[name="archive"][value="' + (isArchive ? 'true' : 'false') + '"]');
+ if(btn){
+ btn.click();
+ } else {
+ // fallback: add hidden input and submit
+ const hidden = document.createElement('input');
+ hidden.type = 'hidden';
+ hidden.name = 'archive';
+ hidden.value = isArchive ? 'true' : 'false';
+ form.appendChild(hidden);
+ form.requestSubmit();
+ }
+ }
+
+ if(topArchiveBtn){
+ topArchiveBtn.addEventListener('click', function(){ submitArchiveAction(true); });
+ }
+ if(topUnarchiveBtn){
+ topUnarchiveBtn.addEventListener('click', function(){ submitArchiveAction(false); });
+ }
+
// Make clicking an exam row toggle its checkbox when bulk-mode is enabled.
document.addEventListener('click', function(e){
if(!examListWrapper.classList.contains('bulk-mode')) return;
@@ -123,5 +191,49 @@
});
});
+
{% endblock %}
diff --git a/generic/urls.py b/generic/urls.py
index 1a026d02..fadc802c 100755
--- a/generic/urls.py
+++ b/generic/urls.py
@@ -338,6 +338,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
generic_exam_view.exam_bulk_update,
name="exam_bulk_update",
),
+ path(
+ "exam/bulk_archive/",
+ generic_exam_view.exam_bulk_archive,
+ name="exam_bulk_archive",
+ ),
path(
"exam/bulk_delete/",
generic_exam_view.exam_bulk_delete,
diff --git a/generic/views.py b/generic/views.py
index 6c164409..7412f453 100644
--- a/generic/views.py
+++ b/generic/views.py
@@ -1117,6 +1117,58 @@ class ExamViews(View, LoginRequiredMixin):
return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
+ @method_decorator(login_required)
+ def exam_bulk_archive(self, request):
+ """HTMX endpoint to bulk-archive or unarchive selected exams.
+
+ Expects POST with 'exam_ids' (multiple) and 'archive' ('true' or 'false').
+ Returns the updated exam list partial.
+ """
+ if request.method != "POST":
+ return HttpResponse(status=405)
+
+ exam_ids = request.POST.getlist("exam_ids")
+ archive_val = request.POST.get("archive", "true")
+ set_archive = True if archive_val == "true" else False
+
+ 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.archive = set_archive
+ exam.save()
+ except Exception:
+ 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":