Enhance exam search widget: refactor query handling to support wildcard searches and improve filter options rendering

This commit is contained in:
Ross
2026-02-16 13:46:09 +00:00
parent 36da045d71
commit 6e30fcc19f
2 changed files with 147 additions and 96 deletions
+65 -63
View File
@@ -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'^(?P<field>name|code|id|pk|date|type):\s*(?P<term>.+)$', 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'^(?P<field>name|code|id|pk|date|type):\s*(?P<term>.+)$', 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
+82 -33
View File
@@ -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"<option value=\"\"{' selected' if sel is None else ''}>Any</option>")
parts.append(f"<option value=\"0\"{' selected' if sel == '0' else ''}>Not archived</option>")
parts.append(f"<option value=\"1\"{' selected' if sel == '1' else ''}>Archived only</option>")
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"<option value=\"\"{' selected' if sel is None else ''}>Any</option>")
parts.append(f"<option value=\"0\"{' selected' if sel == '0' else ''}>{false_label}</option>")
parts.append(f"<option value=\"1\"{' selected' if sel == '1' else ''}>{true_label}</option>")
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:
<strong>Advanced search tips</strong>
<div class="mb-2 mt-2">
<strong class="small">Filters</strong>
<div class="d-flex gap-2 mt-1">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_archive_{self.name}" />
<label class="form-check-label small" for="filter_archive_{self.name}">Archived only</label>
<div class="d-flex gap-2 mt-1 align-items-center">
<div>
<label class="form-label small mb-0 me-2" for="filter_archive_{self.name}">Archive</label>
<select id="filter_archive_{self.name}" class="form-select form-select-sm" style="max-width:160px">
{self._archive_select_options() if hasattr(self, '_archive_select_options') else ''}
</select>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_open_access_{self.name}" />
<label class="form-check-label small" for="filter_open_access_{self.name}">Open access only</label>
<div>
<label class="form-label small mb-0 me-2" for="filter_open_access_{self.name}">Open access</label>
<select id="filter_open_access_{self.name}" class="form-select form-select-sm" style="max-width:160px">
{self._binary_select_options('open_access', 'Open access only', 'Not open access') if hasattr(self, '_binary_select_options') else ''}
</select>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_exam_mode_{self.name}" />
<label class="form-check-label small" for="filter_exam_mode_{self.name}">Exam mode only</label>
<div>
<label class="form-label small mb-0 me-2" for="filter_exam_mode_{self.name}">Exam mode</label>
<select id="filter_exam_mode_{self.name}" class="form-select form-select-sm" style="max-width:160px">
{self._binary_select_options('exam_mode', 'Exam mode only', 'Not exam mode') if hasattr(self, '_binary_select_options') else ''}
</select>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_candidates_only_{self.name}" />
<label class="form-check-label small" for="filter_candidates_only_{self.name}">Candidates only</label>
<div>
<label class="form-label small mb-0 me-2" for="filter_candidates_only_{self.name}">Candidates only</label>
<select id="filter_candidates_only_{self.name}" class="form-select form-select-sm" style="max-width:160px">
{self._binary_select_options('candidates_only', 'Candidates only', 'Not candidates only') if hasattr(self, '_binary_select_options') else ''}
</select>
</div>
</div>
</div>
@@ -478,10 +512,8 @@ class ExamSearchSelectMultipleWidget:
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> — matches the exam name.</li>
<li><code>code:</code> — matches an exam code or short identifier.</li>
<li><code>id:</code> or <code>pk:</code> — match by numeric id.</li>
<li><code>date:</code> — match by date (YYYY-MM-DD) where available.</li>
<li><code>type:</code> — narrow to an exam type (e.g. <code>type:anatomy</code>).</li>
</ul>
</li>
<li>To add exams, click the <em>Add</em> button beside a result. Use <em>Add all results</em> to import visible matches.</li>
@@ -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;