Enhance exam search widget: refactor query handling to support wildcard searches and improve filter options rendering
This commit is contained in:
+65
-63
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user