diff --git a/generic/forms.py b/generic/forms.py index 613ca8a8..acc489a2 100755 --- a/generic/forms.py +++ b/generic/forms.py @@ -2,6 +2,7 @@ from django.forms import ( Form, ModelForm, ModelMultipleChoiceField, + MultipleChoiceField, ModelChoiceField, ChoiceField, CharField, @@ -291,6 +292,43 @@ class ExamGroupsFormMixin(ModelForm): 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): def __init__(self, *args, **kwargs): # Where is this coming from? diff --git a/generic/models.py b/generic/models.py index 2182a030..49b6b20e 100644 --- a/generic/models.py +++ b/generic/models.py @@ -1946,6 +1946,92 @@ class ExamCollection(models.Model, AuthorMixin): exam.add_markers(users) #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): # diff --git a/generic/templates/generic/examcollection_detail.html b/generic/templates/generic/examcollection_detail.html index 306ab4bb..2f91165e 100755 --- a/generic/templates/generic/examcollection_detail.html +++ b/generic/templates/generic/examcollection_detail.html @@ -86,9 +86,13 @@ {% endif %}
-
- -
+
+ +
+ +
+ +
{% include 'exam_overview_js.html' %} diff --git a/generic/templates/generic/partials/examcollection_bulk_add_groups_form.html b/generic/templates/generic/partials/examcollection_bulk_add_groups_form.html new file mode 100644 index 00000000..675185ab --- /dev/null +++ b/generic/templates/generic/partials/examcollection_bulk_add_groups_form.html @@ -0,0 +1,19 @@ +
+ {% csrf_token %} +
+ {{ form.cid_user_groups.label_tag }}
+ {{ form.cid_user_groups }} +
+
+ {{ form.user_user_groups.label_tag }}
+ {{ form.user_user_groups }} +
+
+ {{ form.exam_types.label_tag }}
+ {{ form.exam_types }} +
+
+ + +
+
diff --git a/generic/templates/generic/partials/examcollection_bulk_add_groups_result.html b/generic/templates/generic/partials/examcollection_bulk_add_groups_result.html new file mode 100644 index 00000000..4a6633ff --- /dev/null +++ b/generic/templates/generic/partials/examcollection_bulk_add_groups_result.html @@ -0,0 +1,11 @@ +
+ {% if error %} +
Error: {{ error }}
+ {% else %} +

Added user groups to {{ total_user }} exams.

+

Added CID groups to {{ total_cid }} exams.

+ {% endif %} +
+ +
+
diff --git a/generic/urls.py b/generic/urls.py index fadc802c..6facc40f 100755 --- a/generic/urls.py +++ b/generic/urls.py @@ -255,6 +255,7 @@ urlpatterns = [ name="examcollection_delete", ), path("collection//add_author", views.exam_collection_add_author, name="exam_collection_add_author"), + path("collection//bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"), path("collection//add_marker/", views.exam_collection_add_marker, name="exam_collection_add_marker"), path( "share_with_supervisor_toggle//", diff --git a/generic/views.py b/generic/views.py index 9d4df40b..81e96b8e 100644 --- a/generic/views.py +++ b/generic/views.py @@ -86,6 +86,7 @@ from .forms import ( UserUserForm, UserUserGroupForm, UsersAutocompleteForm, # Added import + ExamCollectionBulkAddGroupsForm, ) 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.conf import settings +from django.db import transaction from datetime import datetime 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 @user_passes_test(lambda u: u.is_superuser) def examination_detail(request, pk):