Implement user search widget with HTMX for dynamic user selection
This commit is contained in:
+178
-1
@@ -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 <select multiple> containing selected user ids and
|
||||
a search box with results. Uses a small inline script to fetch
|
||||
results from the `generic:user_search_widget` endpoint and add/remove
|
||||
users from the selection.
|
||||
This keeps implementation simple and avoids external JS dependencies.
|
||||
"""
|
||||
|
||||
def __init__(self, name, value):
|
||||
self.name = name
|
||||
# value is a list of selected user ids
|
||||
self.value = value or []
|
||||
|
||||
def render(self):
|
||||
field_id = f"id_{self.name}"
|
||||
search_id = f"user_search_{self.name}"
|
||||
results_id = f"user_search_results_{self.name}"
|
||||
selected_list_id = f"selected_users_{self.name}"
|
||||
|
||||
# Build initial options for selected users
|
||||
users = User.objects.filter(pk__in=self.value) if self.value else []
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
for u in users:
|
||||
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
||||
selected_html += (
|
||||
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small></span>'
|
||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
||||
f'</li>'
|
||||
)
|
||||
|
||||
url = reverse("generic:user_search_widget")
|
||||
|
||||
html = f"""
|
||||
<div class="user-search-widget">
|
||||
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
||||
{options_html}
|
||||
</select>
|
||||
|
||||
<div class="mb-2">
|
||||
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
|
||||
<div id="{results_id}" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label small">Selected users</label>
|
||||
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
||||
{selected_html}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
const search = document.getElementById('{search_id}');
|
||||
const results = document.getElementById('{results_id}');
|
||||
const select = document.getElementById('{field_id}');
|
||||
const selectedList = document.getElementById('{selected_list_id}');
|
||||
|
||||
let timeout = null;
|
||||
function fetchResults(q) {{
|
||||
if (!q || q.trim().length === 0) {{
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}}
|
||||
fetch('{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q))
|
||||
.then(r => r.text())
|
||||
.then(html => {{ results.innerHTML = html; }});
|
||||
}}
|
||||
|
||||
search.addEventListener('keyup', function(e) {{
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||
}});
|
||||
|
||||
window.addUserToField = function(fieldName, id, text) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
// prevent duplicate
|
||||
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
||||
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
||||
sel.appendChild(opt);
|
||||
|
||||
// add to visible list
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = 'selected_user_' + fieldName + '_' + id;
|
||||
const span = document.createElement('span'); span.innerHTML = text;
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
||||
li.appendChild(span); li.appendChild(btn);
|
||||
selectedList.appendChild(li);
|
||||
}}
|
||||
|
||||
window.removeUserFromField = function(fieldName, id) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
||||
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
|
||||
if (li && li.parentNode) li.parentNode.removeChild(li);
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
|
||||
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"]
|
||||
|
||||
+13
-4
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{% if results %}
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for item in results %}
|
||||
<li class="py-1 d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1">{{ item.text|escape }}</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="addUserToField('{{ field }}', {{ item.id }}, '{{ item.text|escapejs }}')">Add</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div><em>No users found</em></div>
|
||||
{% endif %}
|
||||
@@ -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/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user