Implement bulk update and delete functionality for exams with HTMX support
This commit is contained in:
@@ -1,26 +1,33 @@
|
||||
{% extends app_name|add:'/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="exam-number-title">{{filter.qs|length}} exams found.</h4>
|
||||
{% for exam in filter.qs %}
|
||||
<div class="">
|
||||
<h1><a href="{% url app_name|add:':exam_overview' pk=exam.pk %}">
|
||||
|
||||
{% if exam.exam_mode %}
|
||||
Exam:
|
||||
{% else %}
|
||||
Packet:
|
||||
{% endif %}
|
||||
{{ exam }} </a></h1>
|
||||
<span class="authors">Authors: {{exam.get_authors}}</span>
|
||||
{% if exam.exam_mode %}
|
||||
{% if app_name in "rapids longs anatomy" %}
|
||||
{% if request.user.is_staff %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}">Mark answers</a>{% endif %}
|
||||
{% endif %}
|
||||
{% if request.user.is_staff %}<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}">Scores</a>{% endif %}
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<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-secondary" id="clear-selection-btn">Clear</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="d-flex align-items-center">
|
||||
<form id="bulk-date-form" hx-post="{% url app_name|add:':exam_bulk_update' %}" hx-target="#exam-list-container" hx-include="#exam-list-container input[name='exam_ids']">
|
||||
{% for k, v in request.GET.items %}
|
||||
<input type="hidden" name="{{ k }}" value="{{ v }}" />
|
||||
{% endfor %}
|
||||
{% csrf_token %}
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="date" class="form-control" name="date" aria-label="Set date">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Update date</button>
|
||||
</div>
|
||||
</form>
|
||||
<form id="bulk-delete-form" class="ms-2" hx-post="{% url app_name|add:':exam_bulk_delete' %}" hx-target="#exam-list-container" hx-include="#exam-list-container input[name='exam_ids']">
|
||||
{% for k, v in request.GET.items %}
|
||||
<input type="hidden" name="{{ k }}" value="{{ v }}" />
|
||||
{% endfor %}
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm btn-danger" type="submit" onclick="return confirm('Delete selected exams? This action is irreversible.')">Delete selected</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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 @@
|
||||
</style>
|
||||
{% endblock css %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const selectAllBtn = document.getElementById('select-all-btn');
|
||||
const clearBtn = document.getElementById('clear-selection-btn');
|
||||
|
||||
function setAllCheckboxes(checked){
|
||||
document.querySelectorAll('#exam-list-container input.exam-select-checkbox').forEach(cb => cb.checked = checked);
|
||||
}
|
||||
|
||||
if(selectAllBtn){
|
||||
selectAllBtn.addEventListener('click', function(){ setAllCheckboxes(true); });
|
||||
}
|
||||
if(clearBtn){
|
||||
clearBtn.addEventListener('click', function(){ setAllCheckboxes(false); });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{# Partial: list of exams used by HTMX bulk actions #}
|
||||
<div id="exam-list-container">
|
||||
<h4 class="exam-number-title">{{ filter.qs|length }} exams found.</h4>
|
||||
|
||||
{% for exam in filter.qs %}
|
||||
<div class="mb-2" id="exam-{{ exam.pk }}">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="form-check me-2">
|
||||
<input class="form-check-input exam-select-checkbox" type="checkbox" name="exam_ids" value="{{ exam.pk }}" id="select-exam-{{ exam.pk }}">
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-0"><a href="{% url app_name|add:':exam_overview' pk=exam.pk %}">
|
||||
{% if exam.exam_mode %}Exam:{% else %}Packet:{% endif %} {{ exam }}
|
||||
</a></h5>
|
||||
<small class="text-muted">Authors: {{ exam.get_authors }}</small>
|
||||
{% if exam.exam_mode %}
|
||||
{% if app_name in "rapids longs anatomy" %}
|
||||
{% if request.user.is_staff %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}">Mark answers</a>{% endif %}
|
||||
{% endif %}
|
||||
{% if request.user.is_staff %}<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}">Scores</a>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -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/<int:pk>/", generic_exam_view.exam_overview, name="exam_overview"),
|
||||
path(
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user