Implement bulk archive functionality for exams; add HTMX endpoint and update UI controls for improved exam management
This commit is contained in:
@@ -5,8 +5,12 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-toggle-btn">Bulk edit</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-toggle-btn">Bulk edit</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="me-2">
|
||||||
|
<button id="toggle-archive-btn" type="button" class="btn btn-sm btn-outline-info">Show archived</button>
|
||||||
|
</div>
|
||||||
<div id="bulk-controls" class="d-none">
|
<div id="bulk-controls" class="d-none">
|
||||||
<div>
|
<div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="select-all-btn">Select all</button>
|
<button type="button" class="btn btn-sm btn-outline-primary" id="select-all-btn">Select all</button>
|
||||||
@@ -31,6 +35,16 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="btn btn-sm btn-danger" type="submit" onclick="return confirm('Delete selected exams? This action is irreversible.')">Delete selected</button>
|
<button class="btn btn-sm btn-danger" type="submit" onclick="return confirm('Delete selected exams? This action is irreversible.')">Delete selected</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form id="bulk-archive-form" class="mt-2" hx-post="{% url app_name|add:':exam_bulk_archive' %}" hx-target="#exam-list-container" hx-include="#exam-list-container input[name='exam_ids']" hx-indicator="#bulk-action-indicator">
|
||||||
|
{% for k, v in request.GET.items %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}" />
|
||||||
|
{% endfor %}
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button name="archive" value="true" class="btn btn-sm btn-warning" type="submit" onclick="return confirm('Archive selected exams?')">Archive selected</button>
|
||||||
|
<button name="archive" value="false" class="btn btn-sm btn-outline-warning" type="submit" onclick="return confirm('Unarchive selected exams?')">Unarchive selected</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Bulk action indicator shown while HTMX requests are running -->
|
<!-- Bulk action indicator shown while HTMX requests are running -->
|
||||||
<span id="bulk-action-indicator" class="ms-2" aria-hidden="true">
|
<span id="bulk-action-indicator" class="ms-2" aria-hidden="true">
|
||||||
@@ -67,6 +81,8 @@
|
|||||||
/* Defensive: hide bulk-controls unless bulk-mode is enabled */
|
/* Defensive: hide bulk-controls unless bulk-mode is enabled */
|
||||||
#exam-list-wrapper:not(.bulk-mode) #bulk-controls { display: none !important; }
|
#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 - hidden by default, shown while HTMX request is active */
|
||||||
#bulk-action-indicator { display: none; }
|
#bulk-action-indicator { display: none; }
|
||||||
#bulk-action-indicator.htmx-request { display: inline-block; }
|
#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.
|
// Make clicking an exam row toggle its checkbox when bulk-mode is enabled.
|
||||||
document.addEventListener('click', function(e){
|
document.addEventListener('click', function(e){
|
||||||
if(!examListWrapper.classList.contains('bulk-mode')) return;
|
if(!examListWrapper.classList.contains('bulk-mode')) return;
|
||||||
@@ -123,5 +191,49 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// Archive toggle button: toggle ?archive=1 while preserving other query params
|
||||||
|
(function(){
|
||||||
|
function getQueryParams(){
|
||||||
|
return new URLSearchParams(window.location.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButton(){
|
||||||
|
const btn = document.getElementById('toggle-archive-btn');
|
||||||
|
if(!btn) return;
|
||||||
|
const params = getQueryParams();
|
||||||
|
if(params.get('archive') && params.get('archive') !== '0'){
|
||||||
|
btn.textContent = 'Hide archived';
|
||||||
|
btn.classList.remove('btn-outline-info');
|
||||||
|
btn.classList.add('btn-outline-secondary');
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Show archived';
|
||||||
|
btn.classList.remove('btn-outline-secondary');
|
||||||
|
btn.classList.add('btn-outline-info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle toggle button click
|
||||||
|
document.addEventListener('click', function(e){
|
||||||
|
if(e.target && e.target.id === 'toggle-archive-btn'){
|
||||||
|
const params = getQueryParams();
|
||||||
|
if(params.get('archive') && params.get('archive') !== '0'){
|
||||||
|
params.delete('archive');
|
||||||
|
} else {
|
||||||
|
params.set('archive','1');
|
||||||
|
}
|
||||||
|
const q = params.toString();
|
||||||
|
const newUrl = window.location.pathname + (q ? ('?' + q) : '');
|
||||||
|
window.location.href = newUrl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// init on load
|
||||||
|
document.addEventListener('DOMContentLoaded', updateButton);
|
||||||
|
// also update button when HTMX swaps content
|
||||||
|
document.addEventListener('htmx:afterSwap', updateButton);
|
||||||
|
document.addEventListener('htmx:afterOnLoad', updateButton);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
|||||||
generic_exam_view.exam_bulk_update,
|
generic_exam_view.exam_bulk_update,
|
||||||
name="exam_bulk_update",
|
name="exam_bulk_update",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"exam/bulk_archive/",
|
||||||
|
generic_exam_view.exam_bulk_archive,
|
||||||
|
name="exam_bulk_archive",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"exam/bulk_delete/",
|
"exam/bulk_delete/",
|
||||||
generic_exam_view.exam_bulk_delete,
|
generic_exam_view.exam_bulk_delete,
|
||||||
|
|||||||
@@ -1117,6 +1117,58 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
|
|
||||||
return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
|
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)
|
@method_decorator(login_required)
|
||||||
def exam_toggle_results_published(self, request, pk):
|
def exam_toggle_results_published(self, request, pk):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|||||||
Reference in New Issue
Block a user