Add bulk add functionality for user and CID groups to exam collections
This commit is contained in:
@@ -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?
|
||||||
|
|||||||
@@ -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,6 +86,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
<div id="bulk-add-groups">
|
||||||
|
<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 id="add-user">
|
<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>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>/",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user