From 6e30fcc19f757ac40bcc2cbdc329832908928c07 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 16 Feb 2026 13:46:09 +0000 Subject: [PATCH] Enhance exam search widget: refactor query handling to support wildcard searches and improve filter options rendering --- generic/views.py | 128 +++++++++++++++++++++++---------------------- generic/widgets.py | 115 ++++++++++++++++++++++++++++------------ 2 files changed, 147 insertions(+), 96 deletions(-) diff --git a/generic/views.py b/generic/views.py index 1ee4a5b8..5abf9171 100644 --- a/generic/views.py +++ b/generic/views.py @@ -6661,10 +6661,6 @@ def exam_search_widget(request): try: model = apps.get_model(model_label) if model_label else None except Exception: - model = None - - # If we couldn't resolve a model, return empty fragment rather than raising - if model is None: return HttpResponse("") # Helper to parse optional boolean GET params @@ -6687,10 +6683,6 @@ def exam_search_widget(request): return False # Build queryset - qs = model.objects.none() - - # Support field-prefixed queries like `name:term`, `code:term`, `id:123`, `date:YYYY-MM-DD`, `type:app` - field_match = re.match(r'^(?Pname|code|id|pk|date|type):\s*(?P.+)$', q, flags=re.I) def _field_exists(mdl, field_name): try: mdl._meta.get_field(field_name) @@ -6698,62 +6690,72 @@ def exam_search_widget(request): except Exception: return False - if field_match: - f = field_match.group('field').lower() - term = field_match.group('term').strip() - try: - if f in ('id', 'pk'): - qs = model.objects.filter(pk=int(term)) - elif f == 'name': - qs = model.objects.filter(name__icontains=term) - elif f == 'code': - # try a few plausible code field names if present - lookups = [] - for fn in ('code', 'short_code', 'identifier'): - if _field_exists(model, fn): - lookups.append(f"{fn}__icontains") - if lookups: - from django.db.models import Q - qobj = Q() - for lk in lookups: - qobj |= Q(**{lk: term}) - qs = model.objects.filter(qobj) - else: - qs = model.objects.none() - elif f == 'date': - # try a few common date field names - date_lookups = [] - for fn in ('date', 'exam_date', 'start_date'): - if _field_exists(model, fn): - date_lookups.append(fn) - if date_lookups: - from django.db.models import Q - qobj = Q() - for fn in date_lookups: - qobj |= Q(**{f"{fn}__startswith": term}) - qs = model.objects.filter(qobj) - else: - qs = model.objects.none() - elif f == 'type': - # restrict by app_label or model name match - if term.lower() in (model._meta.app_label.lower(), model._meta.model_name.lower()): - qs = model.objects.all() - else: - qs = model.objects.none() - except Exception: - qs = model.objects.none() - else: - # Default: search name and, where available, code-like fields - field_lookups = ["name__icontains"] - for fn in ('code', 'short_code', 'identifier'): - if _field_exists(model, fn): - field_lookups.append(f"{fn}__icontains") + qs = model.objects.none() - from django.db.models import Q - qobj = Q() - for lk in field_lookups: - qobj |= Q(**{lk: q}) - qs = model.objects.filter(qobj) + # Support field-prefixed queries like `name:term`, `code:term`, `id:123`, `date:YYYY-MM-DD`. + # Treat '*' or '%' as a wildcard meaning "match everything" for the model. + field_match = re.match(r'^(?Pname|code|id|pk|date|type):\s*(?P.+)$', q, flags=re.I) + + # Wildcard query: return all objects for this model (filters still apply later) + if q in ("*", "%"): + qs = model.objects.all() + else: + if field_match: + f = field_match.group('field').lower() + term = field_match.group('term').strip() + try: + if f in ('id', 'pk'): + qs = model.objects.filter(pk=int(term)) + elif f == 'name': + qs = model.objects.filter(name__icontains=term) + elif f == 'code': + # try a few plausible code field names if present + lookups = [] + for fn in ('code', 'short_code', 'identifier'): + if _field_exists(model, fn): + lookups.append(f"{fn}__icontains") + if lookups: + from django.db.models import Q + qobj = Q() + for lk in lookups: + qobj |= Q(**{lk: term}) + qs = model.objects.filter(qobj) + else: + qs = model.objects.none() + elif f == 'date': + # try a few common date field names + date_lookups = [] + for fn in ('date', 'exam_date', 'start_date'): + if _field_exists(model, fn): + date_lookups.append(fn) + if date_lookups: + from django.db.models import Q + qobj = Q() + for fn in date_lookups: + qobj |= Q(**{f"{fn}__startswith": term}) + qs = model.objects.filter(qobj) + else: + qs = model.objects.none() + elif f == 'type': + # restrict by app_label or model name match + if term.lower() in (model._meta.app_label.lower(), model._meta.model_name.lower()): + qs = model.objects.all() + else: + qs = model.objects.none() + except Exception: + qs = model.objects.none() + else: + # Default: search name and, where available, code-like fields + field_lookups = ["name__icontains"] + for fn in ('code', 'short_code', 'identifier'): + if _field_exists(model, fn): + field_lookups.append(f"{fn}__icontains") + + from django.db.models import Q + qobj = Q() + for lk in field_lookups: + qobj |= Q(**{lk: q}) + qs = model.objects.filter(qobj) # Limit results limit = 50 if q in ("*", "%") else 10 diff --git a/generic/widgets.py b/generic/widgets.py index 2d913a45..5142ae53 100644 --- a/generic/widgets.py +++ b/generic/widgets.py @@ -382,6 +382,32 @@ class ExamSearchSelectMultipleWidget: # default filters for this widget instance; fall back to module defaults self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS + def _archive_select_options(self): + # Render a three-state select for archive filtering: Any / Not archived / Archived only + df = self.default_filters or {} + sel = None + if isinstance(df, dict) and 'archive' in df: + if df['archive'] is True: + sel = '1' + elif df['archive'] is False: + sel = '0' + parts = [] + parts.append(f"") + parts.append(f"") + parts.append(f"") + return ''.join(parts) + + def _binary_select_options(self, key, true_label, false_label): + df = self.default_filters or {} + sel = None + if isinstance(df, dict) and key in df: + sel = '1' if df[key] else '0' + parts = [] + parts.append(f"") + parts.append(f"") + parts.append(f"") + return ''.join(parts) + def render(self): field_id = f"id_{self.name}" search_id = f"exam_search_{self.name}" @@ -454,22 +480,30 @@ class ExamSearchSelectMultipleWidget: Advanced search tips
Filters -
-
- - +
+
+ +
-
- - +
+ +
-
- - +
+ +
-
- - +
+ +
@@ -478,10 +512,8 @@ class ExamSearchSelectMultipleWidget:
  • Field-specific searches: prefix with field:term to restrict the search. Supported fields:
    • name: — matches the exam name.
    • -
    • code: — matches an exam code or short identifier.
    • id: or pk: — match by numeric id.
    • date: — match by date (YYYY-MM-DD) where available.
    • -
    • type: — narrow to an exam type (e.g. type:anatomy).
  • To add exams, click the Add button beside a result. Use Add all results to import visible matches.
  • @@ -546,12 +578,6 @@ class ExamSearchSelectMultipleWidget: function fetchResults(rawQ) {{ const parsed = parseFilterTokens(rawQ || ''); const q = parsed.cleaned; - if (!q || q.trim().length === 0) {{ - // still respect filters even when query is empty: show results for wildcard if filters present - // but don't send empty q param; use '*' to trigger wildcard behaviour if any filter present - const anyFilter = Object.keys(parsed.filters).length > 0; - if (!anyFilter) {{ results.innerHTML = ''; return; }} - }} const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : ''; const f_archive = document.getElementById('filter_archive_{self.name}'); @@ -560,22 +586,45 @@ class ExamSearchSelectMultipleWidget: const f_candidates = document.getElementById('filter_candidates_only_{self.name}'); let extra = ''; - // checkboxes are authoritative; do not mirror tokens into the inputs + // still respect filters even when query is empty: show results for wildcard if filters present + // but don't send empty q param; use '*' to trigger wildcard behaviour if any filter present + if (!q || q.trim().length === 0) {{ + let anyFilter = Object.keys(parsed.filters).length > 0; + if (!anyFilter) {{ + try {{ + if (f_archive) {{ + const tag = f_archive.tagName ? f_archive.tagName.toLowerCase() : ''; + if (tag === 'select') {{ + if (f_archive.value === '1' || f_archive.value === '0') anyFilter = true; + }} else if (f_archive.checked) anyFilter = true; + }} + if (f_open && f_open.checked) anyFilter = true; + if (f_exam_mode && f_exam_mode.checked) anyFilter = true; + if (f_candidates && f_candidates.checked) anyFilter = true; + }} catch(e) {{ /* ignore */ }} + }} + if (!anyFilter) {{ results.innerHTML = ''; return; }} + }} - // For each supported filter, send the param if there's a default or the checkbox exists. - function appendFilterParam(name, checkboxEl, defaultVal) {{ - if (typeof defaultVal !== 'undefined') {{ - const val = checkboxEl ? checkboxEl.checked : defaultVal; - extra += '&' + encodeURIComponent(name) + '=' + (val ? '1' : '0'); - }} else if (checkboxEl && checkboxEl.checked) {{ - extra += '&' + encodeURIComponent(name) + '=1'; + // Only send filter params when their controls are present. + // - `archive`: explicit when present (1 = archived only, 0 = not archived) + // - other filters: only active when checked (send name=1) + function appendFilterParam(name, el) {{ + if (!el) return; + const tag = el.tagName ? el.tagName.toLowerCase() : ''; + if (tag === 'select') {{ + const v = el.value; + if (v === '1' || v === '0') extra += '&' + encodeURIComponent(name) + '=' + v; + }} else {{ + // checkbox fallback: send explicit 1/0 when present + extra += '&' + encodeURIComponent(name) + '=' + (el.checked ? '1' : '0'); }} }} - appendFilterParam('archive', f_archive, defaultFilters.archive); - appendFilterParam('open_access', f_open, defaultFilters.open_access); - appendFilterParam('exam_mode', f_exam_mode, defaultFilters.exam_mode); - appendFilterParam('candidates_only', f_candidates, defaultFilters.candidates_only); + appendFilterParam('archive', f_archive); + appendFilterParam('open_access', f_open); + appendFilterParam('exam_mode', f_exam_mode); + appendFilterParam('candidates_only', f_candidates); const sendQ = (q && q.length) ? q : '*'; const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(sendQ) + modelParam + extra;