Enhance exam search widget: improve filter handling and parsing in JavaScript

This commit is contained in:
Ross
2026-02-16 13:17:08 +00:00
parent 990f4534bd
commit 64103cac1e
2 changed files with 76 additions and 32 deletions
+10 -2
View File
@@ -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'}")
+66 -30
View File
@@ -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) {{