From 64103cac1e85ac41dd652fe46f2abc9a59084e45 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 16 Feb 2026 13:17:08 +0000 Subject: [PATCH] Enhance exam search widget: improve filter handling and parsing in JavaScript --- generic/views.py | 12 +++++- generic/widgets.py | 96 +++++++++++++++++++++++++++++++--------------- 2 files changed, 76 insertions(+), 32 deletions(-) diff --git a/generic/views.py b/generic/views.py index 6d12c07e..1ee4a5b8 100644 --- a/generic/views.py +++ b/generic/views.py @@ -6761,11 +6761,19 @@ def exam_search_widget(request): # Apply optional filters from GET params to the per-model queryset + # Only include filters that were supplied in GET _and_ where the model + # actually exposes the field. This prevents models without a given + # attribute from being accidentally excluded when a default filter + # value is always sent by the widget JS. extra_filters = {} for pname in ("archive", "open_access", "exam_mode", "candidates_only"): val = _get_bool_param(pname) - if val is not None: - extra_filters[pname] = val + if val is None: + continue + # Only include the filter if the model defines the field + if not _field_exists(model, pname): + continue + extra_filters[pname] = val results = [] logger.error(f"Exam search widget: model={model}, q='{q}', extra_filters={extra_filters}, initial_count={qs.count() if qs is not None else 'N/A'}") diff --git a/generic/widgets.py b/generic/widgets.py index 7f89b17f..2d913a45 100644 --- a/generic/widgets.py +++ b/generic/widgets.py @@ -516,50 +516,80 @@ class ExamSearchSelectMultipleWidget: const widgetFieldName = '{self.name}'; let timeout = null; - function fetchResults(q) {{ + + // Parse filter tokens embedded in the search box, e.g. "archive:1 open_access:0" + const FILTER_NAMES = ['archive','open_access','exam_mode','candidates_only']; + const tokenRe = new RegExp('(?:^|\\s)(' + FILTER_NAMES.join('|') + '):\\s*(1|0|true|false|yes|no)\\b','gi'); + + function parseFilterTokens(q) {{ + const filters = {{}}; + if (!q) return {{ cleaned: '', filters: filters }}; + let cleaned = q.replace(tokenRe, function(m, name, val) {{ + filters[name] = /^(1|true|yes)$/i.test(val); + return ' '; + }}); + cleaned = cleaned.replace(/\s+/g,' ').trim(); + return {{ cleaned: cleaned, filters: filters }}; + }} + + function parseFilterTokens(q) {{ + const filters = {{}}; + if (!q) return {{ cleaned: '', filters: filters }}; + let cleaned = q.replace(tokenRe, function(m, name, val) {{ + filters[name] = /^(1|true|yes)$/i.test(val); + return ' '; + }}); + cleaned = cleaned.replace(/\s+/g,' ').trim(); + return {{ cleaned: cleaned, filters: filters }}; + }} + + function fetchResults(rawQ) {{ + const parsed = parseFilterTokens(rawQ || ''); + const q = parsed.cleaned; if (!q || q.trim().length === 0) {{ - results.innerHTML = ''; - return; + // 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}'); - const f_open = document.getElementById('filter_open_access_{self.name}'); - const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}'); - const f_candidates = document.getElementById('filter_candidates_only_{self.name}'); - let extra = ''; - // Initialize checkbox states from defaults (if provided) - try {{ - if (f_archive && defaultFilters.hasOwnProperty('archive')) f_archive.checked = !!defaultFilters.archive; - if (f_open && defaultFilters.hasOwnProperty('open_access')) f_open.checked = !!defaultFilters.open_access; - if (f_exam_mode && defaultFilters.hasOwnProperty('exam_mode')) f_exam_mode.checked = !!defaultFilters.exam_mode; - if (f_candidates && defaultFilters.hasOwnProperty('candidates_only')) f_candidates.checked = !!defaultFilters.candidates_only; - }} catch (e) {{ /* ignore */ }} + const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : ''; + const f_archive = document.getElementById('filter_archive_{self.name}'); + const f_open = document.getElementById('filter_open_access_{self.name}'); + const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}'); + const f_candidates = document.getElementById('filter_candidates_only_{self.name}'); + let extra = ''; - // 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'; - }} + // checkboxes are authoritative; do not mirror tokens into the inputs + + // 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'; }} + }} - 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, 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); - const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(q) + modelParam + extra; + const sendQ = (q && q.length) ? q : '*'; + const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(sendQ) + modelParam + extra; fetch(url) .then(r => r.text()) .then(html => {{ results.innerHTML = html; }}); }} + // wire up input events search.addEventListener('keyup', function(e) {{ clearTimeout(timeout); - timeout = setTimeout(() => fetchResults(search.value), 300); + timeout = setTimeout(() => {{ + fetchResults(search.value); + }}, 300); }}); // Prevent Enter from submitting the surrounding form; run the search instead @@ -572,6 +602,12 @@ class ExamSearchSelectMultipleWidget: }} }}); + // update search input when filters change + ['filter_archive_{self.name}','filter_open_access_{self.name}','filter_exam_mode_{self.name}','filter_candidates_only_{self.name}'].forEach(function(id) {{ + const el = document.getElementById(id); + if (el) el.addEventListener('change', function() {{ clearTimeout(timeout); timeout = setTimeout(() => fetchResults(search.value), 50); }}); + }}); + // Toggle help panel visibility if (helpBtn && helpPanel) {{ helpBtn.addEventListener('click', function(e) {{