diff --git a/generic/forms.py b/generic/forms.py index 34ea0f73..20d81691 100755 --- a/generic/forms.py +++ b/generic/forms.py @@ -78,6 +78,9 @@ from crispy_forms.bootstrap import Accordion, AccordionGroup from dal import autocomplete from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register +from django.utils.safestring import mark_safe +from django.urls import reverse +from django.contrib.auth.models import User class SplitDateTimeFieldDefaultTime(SplitDateTimeField): @@ -554,12 +557,186 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField): class UserUserGroupForm(ModelForm): + class UserSearchSelectMultiple(object): + """ + Lightweight widget renderer for a searchable multi-user selector. + Renders a hidden + {options_html} + + +
+ +
+
+ +
+ + +
+ + + +""" + + return mark_safe(html) + users = UserUserGroupModelChoiceField( required=False, queryset=User.objects.all(), - widget=FilteredSelectMultiple(verbose_name="Users", is_stacked=False), + # use our lightweight widget rendering + widget=None, ) + # monkey-patch the field's widget rendering when the form is instantiated + def __init__(self, *args, **kwargs): + ModelForm.__init__(self, *args, **kwargs) + # replace widget with callable that renders our HTML + if 'users' in self.fields: + orig_field = self.fields['users'] + + class RenderWrapper: + def __init__(self, field): + self.field = field + + # Minimal widget-compatible wrapper used by Django templates and + # form machinery. We implement the small subset of the Widget + # API that the templates/checks expect: `is_hidden`, + # `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`. + is_hidden = False + needs_multipart_form = False + use_fieldset = False + # attrs is expected by BoundField.id_for_label (widget.attrs.get('id')) + attrs = {} + + def render(self, name, value, attrs=None, renderer=None): + # store attrs so template helpers can inspect them + self.attrs = attrs or {} + value = value or [] + widget = UserUserGroupForm.UserSearchSelectMultiple(name, value) + return widget.render() + + def value_from_datadict(self, data, files, name): + # data is usually QueryDict with getlist + if hasattr(data, 'getlist'): + return data.getlist(name) + # fallback + val = data.get(name) + if val is None: + return [] + return [val] + + def id_for_label(self, id_): + # Called by BoundField.id_for_label; prefer explicit id in attrs + try: + if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'): + return self.attrs.get('id') + except Exception: + pass + return id_ + + def use_required_attribute(self, initial): + # Called by Django to decide whether to add the required HTML attribute. + # We defer to the underlying field/widget if available; otherwise + # return False to avoid unexpected 'required' attributes. + try: + if hasattr(self, 'field') and getattr(self, 'field') is not None: + # If original field/widget had such method, prefer it. + orig_widget = getattr(self.field, 'widget', None) + if orig_widget and hasattr(orig_widget, 'use_required_attribute'): + return orig_widget.use_required_attribute(initial) + except Exception: + pass + return False + + self.fields['users'].widget = RenderWrapper(orig_field) + class Meta: model = UserUserGroup fields = ["name", "archive", "open_access", "users"] diff --git a/generic/models.py b/generic/models.py index 49b6b20e..c69d8740 100644 --- a/generic/models.py +++ b/generic/models.py @@ -1993,8 +1993,13 @@ class ExamCollection(models.Model, AuthorMixin): for exam in exams: # Each exam model exposes a `user_user_groups` ManyToManyField - exam.user_user_groups.add(*groups_qs) - updated += 1 + try: + exam.user_user_groups.add(*groups_qs) + # Ensure any downstream signals / caches see the change + exam.save() + updated += 1 + except Exception as e: + logger.exception("Failed to add user_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e) return updated @@ -2027,8 +2032,12 @@ class ExamCollection(models.Model, AuthorMixin): for exam in exams: # Each exam model exposes a `cid_user_groups` ManyToManyField - exam.cid_user_groups.add(*groups_qs) - updated += 1 + try: + exam.cid_user_groups.add(*groups_qs) + exam.save() + updated += 1 + except Exception as e: + logger.exception("Failed to add cid_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e) return updated diff --git a/generic/templates/generic/partials/user_search_widget_results.html b/generic/templates/generic/partials/user_search_widget_results.html new file mode 100644 index 00000000..5ea7b4d3 --- /dev/null +++ b/generic/templates/generic/partials/user_search_widget_results.html @@ -0,0 +1,14 @@ +{% if results %} + +{% else %} +
No users found
+{% endif %} diff --git a/generic/urls.py b/generic/urls.py index 6facc40f..7e7464bb 100755 --- a/generic/urls.py +++ b/generic/urls.py @@ -58,6 +58,7 @@ urlpatterns = [ name="supervisor-autocomplete", ), path("user-search/", views.user_search, name="user_search"), + path("user-search-widget/", views.user_search_widget, name="user_search_widget"), path("supervisor-search/", views.supervisor_search, name="supervisor_search"), path( "cids/manage//update", views.CidUserUpdate.as_view(), name="update_cid" diff --git a/generic/views.py b/generic/views.py index 81e96b8e..16b9745e 100644 --- a/generic/views.py +++ b/generic/views.py @@ -5908,6 +5908,35 @@ def user_search(request): return render(request, "generic/partials/user_search_results.html", {"results": results, "row": row, "q": q}) +@login_required +def user_search_widget(request): + """HTMX/JS endpoint to search users for generic selectors. + + Accepts GET params: + - q: query string + - field: the form field name to return with the results (so the + template can call addUserToField(field, id, text)). + + Returns a small HTML fragment listing up to 10 matching users. Each + result will call `addUserToField(field, id, text)` when clicked. + """ + q = (request.GET.get("q") or "").strip() + field = request.GET.get("field") or request.GET.get("row") + if not q: + return HttpResponse("") + + users_qs = User.objects.filter( + Q(first_name__icontains=q) + | Q(last_name__icontains=q) + | Q(email__icontains=q) + | Q(username__icontains=q) + ).order_by("last_name", "first_name")[:10] + + results = [{"id": u.pk, "text": f"{u.get_full_name() or u.username} - {u.email or ''}"} for u in users_qs] + + return render(request, "generic/partials/user_search_widget_results.html", {"results": results, "field": field, "q": q}) + + @login_required def supervisor_search(request): """HTMX endpoint to search supervisors for the trainees bulk-update UI.