Add bulk add functionality for user and CID groups to exam collections

This commit is contained in:
Ross
2025-11-12 21:19:09 +00:00
parent c39d30c746
commit ec0245fe7c
7 changed files with 207 additions and 3 deletions
+38
View File
@@ -2,6 +2,7 @@ from django.forms import (
Form, Form,
ModelForm, ModelForm,
ModelMultipleChoiceField, ModelMultipleChoiceField,
MultipleChoiceField,
ModelChoiceField, ModelChoiceField,
ChoiceField, ChoiceField,
CharField, CharField,
@@ -291,6 +292,43 @@ class ExamGroupsFormMixin(ModelForm):
self.fields["user_user_groups"].widget.attrs["size"] = 8 self.fields["user_user_groups"].widget.attrs["size"] = 8
class ExamCollectionBulkAddGroupsForm(Form):
"""Form used by the HTMX modal to bulk-add CID/User groups to an ExamCollection.
Accepts `user` kwarg in __init__ to limit available groups based on permissions
(mirrors behaviour of `ExamGroupsFormMixin`).
"""
cid_user_groups = ModelMultipleChoiceField(required=False, queryset=CidUserGroup.objects.none(), label="CID User Groups")
user_user_groups = ModelMultipleChoiceField(required=False, queryset=UserUserGroup.objects.none(), label="User Groups")
EXAM_TYPE_CHOICES = [
("all", "All"),
("anatomy", "Anatomy"),
("longs", "Longs"),
("rapids", "Rapids"),
("shorts", "Shorts"),
("physics", "Physics"),
("sbas", "SBAs"),
]
exam_types = MultipleChoiceField(required=False, choices=EXAM_TYPE_CHOICES, label="Exam types (leave empty for all)")
def __init__(self, *args, user=None, **kwargs):
# keep compatibility with other forms that pass user as kwarg
# This is a plain Form (not a ModelForm) so call Form.__init__
Form.__init__(self, *args, **kwargs)
# choose queryset depending on permissions
if user and (user.is_superuser or user.groups.filter(name="cid_user_manager").exists()):
cid_qs = CidUserGroup.objects.filter(archive=False)
user_qs = UserUserGroup.objects.filter(archive=False)
else:
cid_qs = CidUserGroup.objects.filter(archive=False, open_access=True)
user_qs = UserUserGroup.objects.filter(archive=False, open_access=True)
self.fields["cid_user_groups"].queryset = cid_qs
self.fields["user_user_groups"].queryset = user_qs
self.fields["cid_user_groups"].widget.attrs["size"] = 8
self.fields["user_user_groups"].widget.attrs["size"] = 8
class ExaminationForm(ModelForm): class ExaminationForm(ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Where is this coming from? # Where is this coming from?
+86
View File
@@ -1946,6 +1946,92 @@ class ExamCollection(models.Model, AuthorMixin):
exam.add_markers(users) exam.add_markers(users)
#return True #return True
def bulk_add_user_user_groups(self, groups, exam_types: None | list[str] = None) -> int:
"""Bulk-add one or more UserUserGroup objects (or PKs) to all exams in this
collection.
Args:
groups: iterable of UserUserGroup instances or an iterable of PKs (ints),
or a Django QuerySet of UserUserGroup.
exam_types: optional list of exam type keys to limit the operation. Keys
are the same as returned by `get_exams()` (e.g. 'physics', 'anatomy').
Returns:
Number of exams that were updated (int).
Note: permission checks are not performed here; callers should ensure the
current user is allowed to modify these exams.
"""
# import locally to avoid circular import surprises when module is imported
UserUserGroup = globals().get("UserUserGroup")
if UserUserGroup is None:
raise RuntimeError("UserUserGroup model not available")
# Normalize groups to a list of model instances
if groups is None:
return 0
# If groups is a queryset or list of model instances, materialize it
try:
# QuerySet-ish
groups_qs = list(groups)
except TypeError:
# single value provided
groups_qs = [groups]
# If provided values were ints (PKs), resolve them to model instances
if groups_qs and all(isinstance(g, int) for g in groups_qs):
groups_qs = list(UserUserGroup.objects.filter(pk__in=groups_qs))
if not groups_qs:
return 0
updated = 0
for exam_type, exams in self.get_exams().items():
if exam_types is not None and exam_type not in exam_types:
continue
for exam in exams:
# Each exam model exposes a `user_user_groups` ManyToManyField
exam.user_user_groups.add(*groups_qs)
updated += 1
return updated
def bulk_add_cid_user_groups(self, groups, exam_types: None | list[str] = None) -> int:
"""Bulk-add one or more CidUserGroup objects (or PKs) to all exams in this
collection. Returns number of exams updated.
"""
CidUserGroup = globals().get("CidUserGroup")
if CidUserGroup is None:
raise RuntimeError("CidUserGroup model not available")
if groups is None:
return 0
try:
groups_qs = list(groups)
except TypeError:
groups_qs = [groups]
if groups_qs and all(isinstance(g, int) for g in groups_qs):
groups_qs = list(CidUserGroup.objects.filter(pk__in=groups_qs))
if not groups_qs:
return 0
updated = 0
for exam_type, exams in self.get_exams().items():
if exam_types is not None and exam_type not in exam_types:
continue
for exam in exams:
# Each exam model exposes a `cid_user_groups` ManyToManyField
exam.cid_user_groups.add(*groups_qs)
updated += 1
return updated
#class Presentation(models.Model, AuthorMixin): #class Presentation(models.Model, AuthorMixin):
# #
@@ -86,9 +86,13 @@
{% endif %} {% endif %}
<br/> <br/>
<div id="add-user"> <div id="bulk-add-groups">
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button> <button class="btn-sm" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
</div> </div>
<div id="add-user">
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
</div>
{% include 'exam_overview_js.html' %} {% include 'exam_overview_js.html' %}
@@ -0,0 +1,19 @@
<form hx-post="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML" class="bulk-add-groups-form">
{% csrf_token %}
<div>
{{ form.cid_user_groups.label_tag }}<br/>
{{ form.cid_user_groups }}
</div>
<div>
{{ form.user_user_groups.label_tag }}<br/>
{{ form.user_user_groups }}
</div>
<div>
{{ form.exam_types.label_tag }}<br/>
{{ form.exam_types }}
</div>
<div class="mt-2">
<button type="submit" class="btn btn-primary btn-sm">Add groups</button>
<button type="button" class="btn btn-secondary btn-sm" hx-get="{% url 'generic:examcollection_detail' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Cancel</button>
</div>
</form>
@@ -0,0 +1,11 @@
<div class="bulk-add-result">
{% if error %}
<div class="alert alert-danger">Error: {{ error }}</div>
{% else %}
<p>Added user groups to {{ total_user }} exams.</p>
<p>Added CID groups to {{ total_cid }} exams.</p>
{% endif %}
<div class="mt-2">
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add more</button>
</div>
</div>
+1
View File
@@ -255,6 +255,7 @@ urlpatterns = [
name="examcollection_delete", name="examcollection_delete",
), ),
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>/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( path(
"share_with_supervisor_toggle/<int:pk>/", "share_with_supervisor_toggle/<int:pk>/",
+45
View File
@@ -86,6 +86,7 @@ from .forms import (
UserUserForm, UserUserForm,
UserUserGroupForm, UserUserGroupForm,
UsersAutocompleteForm, # Added import UsersAutocompleteForm, # Added import
ExamCollectionBulkAddGroupsForm,
) )
from .models import ( from .models import (
@@ -144,6 +145,7 @@ from shorts.models import Question as ShortsQuestion, ExamQuestionDetail as Shor
from django.db.models import Case, When from django.db.models import Case, When
from django.conf import settings from django.conf import settings
from django.db import transaction
from datetime import datetime from datetime import datetime
from django.utils import timezone from django.utils import timezone
@@ -378,6 +380,49 @@ def create_examination(request):
) )
@login_required
@user_passes_test(lambda u: u.is_superuser or u.groups.filter(name="cid_user_manager").exists())
def exam_collection_bulk_add_groups(request, collection_id):
"""HTMX endpoint: show form (GET) and process bulk-adding of CID/User groups (POST).
Renders small partials suitable for HTMX swaps.
"""
collection = get_object_or_404(ExamCollection, pk=collection_id)
if request.method == "GET":
form = ExamCollectionBulkAddGroupsForm(user=request.user)
return render(request, "generic/partials/examcollection_bulk_add_groups_form.html", {"form": form, "collection": collection})
# POST
form = ExamCollectionBulkAddGroupsForm(request.POST, user=request.user)
if not form.is_valid():
return render(request, "generic/partials/examcollection_bulk_add_groups_form.html", {"form": form, "collection": collection})
cid_groups = form.cleaned_data.get("cid_user_groups")
user_groups = form.cleaned_data.get("user_user_groups")
exam_types = form.cleaned_data.get("exam_types")
# If 'all' is selected (or no selection), treat as None -> all exams
if exam_types:
if "all" in exam_types:
exam_types = None
else:
exam_types = None
total_user = 0
total_cid = 0
try:
with transaction.atomic():
if user_groups:
total_user = collection.bulk_add_user_user_groups(user_groups, exam_types=exam_types)
if cid_groups:
total_cid = collection.bulk_add_cid_user_groups(cid_groups, exam_types=exam_types)
except Exception as e:
# render an error fragment
return render(request, "generic/partials/examcollection_bulk_add_groups_result.html", {"error": str(e), "collection": collection})
return render(request, "generic/partials/examcollection_bulk_add_groups_result.html", {"total_user": total_user, "total_cid": total_cid, "collection": collection})
@login_required @login_required
@user_passes_test(lambda u: u.is_superuser) @user_passes_test(lambda u: u.is_superuser)
def examination_detail(request, pk): def examination_detail(request, pk):