This commit is contained in:
Ross
2025-12-08 11:10:55 +00:00
parent d96dd77cdc
commit 6fafccc907
5 changed files with 333 additions and 3 deletions
+2 -2
View File
@@ -81,7 +81,7 @@ from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidg
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.contrib.auth.models import User
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget, ExamSearchWidget
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
@@ -869,7 +869,7 @@ class ExamCollectionForm(ModelForm):
self.fields[reverse_prop] = ModelMultipleChoiceField(
required=False,
queryset=exam_obj.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False),
widget=ExamSearchWidget(exam_model=exam_obj),
)
self.fields["author"] = ModelMultipleChoiceField(
@@ -0,0 +1,21 @@
{% if results %}
<ul class="list-unstyled mb-0" id="exam_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}">
<div class="flex-grow-1">
{{ item.text|escape }}
</div>
<div>
<button type="button" class="btn btn-sm btn-primary exam-add-btn" data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}">Add</button>
</div>
</li>
{% endfor %}
</ul>
{% if results %}
<div class="mt-2 text-end">
<button type="button" class="btn btn-sm btn-outline-success exam-add-all-btn" data-field="{{ field }}">Add all results</button>
</div>
{% endif %}
{% else %}
<div><em>No exams found</em></div>
{% endif %}
+1
View File
@@ -59,6 +59,7 @@ urlpatterns = [
),
path("user-search/", views.user_search, name="user_search"),
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
path("exam-search-widget/", views.exam_search_widget, name="exam_search_widget"),
path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
path(
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
+107
View File
@@ -6045,6 +6045,113 @@ def user_search_widget(request):
)
@login_required
def exam_search_widget(request):
"""HTMX/JS endpoint to search exams for the exam-selection widget.
GET params:
- q: query string
- field: the form field name to return with the results (so the
template can call addExamToField(field, id, text)).
- model: optional model label ("app_label.ModelName") to restrict
search to a specific exam model.
Returns a small HTML fragment listing matching exams. Each result will
include a button with class `.exam-add-btn` and `data-exam-id`/`data-exam-text`.
"""
import re
from django.apps import apps
q_raw = (request.GET.get("q") or "").strip()
field = request.GET.get("field") or request.GET.get("row")
model_label = request.GET.get("model")
if not q_raw:
return HttpResponse("")
# Support wildcard queries
q = q_raw
model = None
if model_label:
try:
# model_label may be 'app_label.ModelName' or the model label
model = apps.get_model(model_label)
except Exception:
try:
# try splitting
app_label, model_name = model_label.split(".")
model = apps.get_model(app_label, model_name)
except Exception:
model = None
# Build queryset
qs = None
if model is not None:
# Try common name-like fields for exams
from django.db.models import Q
field_lookups = ["name__icontains", "title__icontains", "examination__icontains", "modality__icontains"]
for lookup in field_lookups:
try:
qs = model.objects.filter(**{lookup: q})
if qs.exists():
break
except Exception:
qs = None
# fallback: try icontains on str() via name if present
if qs is None or not qs.exists():
try:
qs = model.objects.filter(Q(name__icontains=q) | Q(title__icontains=q))
except Exception:
try:
qs = model.objects.all()
except Exception:
qs = model.objects.none()
else:
# If no model specified, try to search across several known exam models
possible_models = [
"anatomy.Exam",
"longs.Exam",
"rapids.Exam",
"shorts.Exam",
"physics.Exam",
"sbas.Exam",
]
results = []
for ml in possible_models:
try:
m = apps.get_model(*ml.split("."))
except Exception:
continue
try:
rqs = m.objects.filter(name__icontains=q)[:5]
except Exception:
try:
rqs = m.objects.filter(title__icontains=q)[:5]
except Exception:
rqs = m.objects.none()
for o in rqs:
results.append(o)
# Render combined results
rendered_results = []
for o in results[:50]:
rendered_results.append({"id": getattr(o, "pk", None), "text": str(o)})
return render(request, "generic/partials/exam_search_widget_results.html", {"results": rendered_results, "field": field, "q": q_raw})
# Limit results
limit = 50 if q in ("*", "%") else 10
try:
qs = qs.order_by("name")[:limit]
except Exception:
qs = qs[:limit]
results = []
for e in qs:
results.append({"id": e.pk, "text": str(e)})
return render(request, "generic/partials/exam_search_widget_results.html", {"results": results, "field": field, "q": q_raw})
@login_required
def supervisor_search(request):
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
+202 -1
View File
@@ -179,6 +179,8 @@ class UserSearchSelectMultipleWidget:
sel.appendChild(opt);
// add to visible list (include grade if provided)
const listEl = document.getElementById('selected_users_' + fieldName);
if (!listEl) return;
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.id = 'selected_user_' + fieldName + '_' + id;
@@ -187,7 +189,7 @@ class UserSearchSelectMultipleWidget:
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);
listEl.appendChild(li);
}}
window.removeUserFromField = function(fieldName, id) {{
@@ -268,3 +270,202 @@ class UserSearchWidget:
except Exception:
pass
return False
class ExamSearchSelectMultipleWidget:
"""Lightweight renderer for selecting exams across types.
Parameters
- name, value: same as form widget usage
- exam_model: optional Django model class for an exam type (used to
adapt display and search behaviour)
- display_fields: optional list of extra fields to show in results
"""
def __init__(self, name, value, exam_model=None, display_fields=None):
self.name = name
self.value = value or []
self.exam_model = exam_model
self.display_fields = display_fields or []
def render(self):
field_id = f"id_{self.name}"
search_id = f"exam_search_{self.name}"
results_id = f"exam_search_results_{self.name}"
selected_list_id = f"selected_exams_{self.name}"
# Render currently selected exams as <option>s
options_html = ""
selected_html = ""
try:
if self.value:
from django.apps import apps
# Accept either pks or model instances
instances = []
for v in self.value:
if hasattr(v, "pk"):
instances.append(v)
else:
# try to resolve by pk across possible models
# when exam_model is provided prefer that
if self.exam_model is not None:
try:
inst = self.exam_model.objects.filter(pk=v).first()
if inst:
instances.append(inst)
continue
except Exception:
pass
# fallback: try generic lookup across known exam models is expensive;
# just attempt to treat v as a pk for display-less option
instances.append(type("_", (), {"pk": v, "__str__": lambda s: str(v)}))
for ex in instances:
label = str(ex)
options_html += f'<option value="{getattr(ex, "pk", "")}" selected>{label}</option>'
selected_html += (
f'<li id="selected_exam_{self.name}_{getattr(ex, "pk", "")}" class="list-group-item d-flex justify-content-between align-items-center">'
f'<span>{label}</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeExamFromField(\'{self.name}\', {getattr(ex, "pk", "")})">Remove</button>'
f'</li>'
)
except Exception:
options_html = ""
selected_html = ""
url = reverse("generic:exam_search_widget") if reverse else ""
html = f"""
<div class="exam-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html}
</select>
<div class="mb-2">
<div class="d-flex gap-2 align-items-start">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search exams by name or code" />
<!-- optional type indicator -->
{f'<div class="badge bg-secondary small">{self.exam_model._meta.verbose_name.title()}</div>' if self.exam_model is not None else ''}
</div>
<div id="{results_id}" class="mt-2"></div>
</div>
<div>
<label class="form-label small">Selected exams</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;
}}
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + ("&model=" + encodeURIComponent('{self.exam_model._meta.label}') || '');
fetch(url)
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
results.addEventListener('click', function(e) {{
const btn = e.target.closest('.exam-add-btn');
if (!btn) return;
const id = btn.getAttribute('data-exam-id');
const text = btn.getAttribute('data-exam-text') || btn.textContent.trim();
addExamToField('{self.name}', id, text);
}});
window.addExamToField = function(fieldName, id, text) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
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);
const listEl = document.getElementById('selected_exams_' + fieldName);
if (!listEl) return;
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.id = 'selected_exam_' + 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() {{ removeExamFromField(fieldName, id); }});
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
}}
window.removeExamFromField = 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_exam_' + fieldName + '_' + id);
if (li && li.parentNode) li.parentNode.removeChild(li);
}}
}})();
</script>
</div>
"""
return mark_safe(html)
class ExamSearchWidget:
"""Widget wrapper that is compatible with Django form machinery.
Accepts `exam_model` to specialise behaviour/display.
"""
is_hidden = False
needs_multipart_form = False
use_fieldset = False
def __init__(self, field=None, exam_model=None, display_fields=None):
self.field = field
self.attrs = {}
self.exam_model = exam_model
self.display_fields = display_fields or []
def render(self, name, value, attrs=None, renderer=None):
self.attrs = attrs or {}
value = value or []
widget = ExamSearchSelectMultipleWidget(name, value, exam_model=self.exam_model, display_fields=self.display_fields)
return widget.render()
def value_from_datadict(self, data, files, name):
if hasattr(data, 'getlist'):
return data.getlist(name)
val = data.get(name)
if val is None:
return []
return [val]
def id_for_label(self, id_):
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):
try:
if hasattr(self, 'field') and getattr(self, 'field') is not None:
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