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 dal import autocomplete
|
||||||
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
|
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):
|
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
||||||
@@ -554,12 +557,186 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
|
|||||||
|
|
||||||
|
|
||||||
class UserUserGroupForm(ModelForm):
|
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(
|
users = UserUserGroupModelChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=User.objects.all(),
|
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:
|
class Meta:
|
||||||
model = UserUserGroup
|
model = UserUserGroup
|
||||||
fields = ["name", "archive", "open_access", "users"]
|
fields = ["name", "archive", "open_access", "users"]
|
||||||
|
|||||||
+13
-4
@@ -1993,8 +1993,13 @@ class ExamCollection(models.Model, AuthorMixin):
|
|||||||
|
|
||||||
for exam in exams:
|
for exam in exams:
|
||||||
# Each exam model exposes a `user_user_groups` ManyToManyField
|
# Each exam model exposes a `user_user_groups` ManyToManyField
|
||||||
exam.user_user_groups.add(*groups_qs)
|
try:
|
||||||
updated += 1
|
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
|
return updated
|
||||||
|
|
||||||
@@ -2027,8 +2032,12 @@ class ExamCollection(models.Model, AuthorMixin):
|
|||||||
|
|
||||||
for exam in exams:
|
for exam in exams:
|
||||||
# Each exam model exposes a `cid_user_groups` ManyToManyField
|
# Each exam model exposes a `cid_user_groups` ManyToManyField
|
||||||
exam.cid_user_groups.add(*groups_qs)
|
try:
|
||||||
updated += 1
|
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
|
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",
|
name="supervisor-autocomplete",
|
||||||
),
|
),
|
||||||
path("user-search/", views.user_search, name="user_search"),
|
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("supervisor-search/", views.supervisor_search, name="supervisor_search"),
|
||||||
path(
|
path(
|
||||||
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
|
"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})
|
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
|
@login_required
|
||||||
def supervisor_search(request):
|
def supervisor_search(request):
|
||||||
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
||||||
|
|||||||
Reference in New Issue
Block a user