diff --git a/generic/forms.py b/generic/forms.py
index 5e55644b..5fa091e1 100755
--- a/generic/forms.py
+++ b/generic/forms.py
@@ -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(
diff --git a/generic/templates/generic/partials/exam_search_widget_results.html b/generic/templates/generic/partials/exam_search_widget_results.html
new file mode 100644
index 00000000..ec27e0bc
--- /dev/null
+++ b/generic/templates/generic/partials/exam_search_widget_results.html
@@ -0,0 +1,21 @@
+{% if results %}
+
+ {% if results %}
+
+
+
+ {% endif %}
+{% else %}
+ No exams found
+{% endif %}
diff --git a/generic/urls.py b/generic/urls.py
index 7e7464bb..729c10ba 100755
--- a/generic/urls.py
+++ b/generic/urls.py
@@ -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//update", views.CidUserUpdate.as_view(), name="update_cid"
diff --git a/generic/views.py b/generic/views.py
index 223dae71..a85b56ef 100644
--- a/generic/views.py
+++ b/generic/views.py
@@ -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.
diff --git a/generic/widgets.py b/generic/widgets.py
index caaff6d6..8fa170fb 100644
--- a/generic/widgets.py
+++ b/generic/widgets.py
@@ -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 '
+ selected_html += (
+ f''
+ f'{label}'
+ f''
+ f''
+ )
+ except Exception:
+ options_html = ""
+ selected_html = ""
+
+ url = reverse("generic:exam_search_widget") if reverse else ""
+
+ html = f"""
+
+ """
+
+ 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