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""" +
    + + +
    +
    + + + {f'
    {self.exam_model._meta.verbose_name.title()}
    ' if self.exam_model is not None else ''} +
    +
    +
    + +
    + +
      + {selected_html} +
    +
    + + +
    + """ + + 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