Enhance ExamSearch widgets to support customizable default filters and improve metadata display

This commit is contained in:
Ross
2025-12-08 12:11:24 +00:00
parent 6daa643df8
commit 4ab2e3a15e
+59 -13
View File
@@ -1,6 +1,15 @@
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.conf import settings
import json
# Default filters for exam search widget. Can be overridden in Django settings
# as `GENERIC_EXAM_SEARCH_DEFAULT_FILTERS` mapping, e.g.:
# GENERIC_EXAM_SEARCH_DEFAULT_FILTERS = { 'archive': False, 'exam_mode': True }
DEFAULT_EXAM_SEARCH_FILTERS = getattr(
settings, "GENERIC_EXAM_SEARCH_DEFAULT_FILTERS", {"archive": False, "exam_mode": True}
)
class UserSearchSelectMultipleWidget:
@@ -282,11 +291,13 @@ class ExamSearchSelectMultipleWidget:
- display_fields: optional list of extra fields to show in results
"""
def __init__(self, name, value, exam_model=None, display_fields=None):
def __init__(self, name, value, exam_model=None, display_fields=None, default_filters=None):
self.name = name
self.value = value or []
self.exam_model = exam_model
self.display_fields = display_fields or []
# 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 render(self):
field_id = f"id_{self.name}"
@@ -323,11 +334,17 @@ class ExamSearchSelectMultipleWidget:
for ex in instances:
label = str(ex)
options_html += f'<option value="{getattr(ex, "pk", "")}" selected>{label}</option>'
pk = getattr(ex, "pk", "")
options_html += f'<option value="{pk}" selected>{label}</option>'
# badges for metadata where available
archive_badge = '<span class="badge bg-secondary ms-2">Archived</span>' if getattr(ex, 'archive', False) else ''
open_badge = '<span class="badge bg-success ms-2">Open</span>' if getattr(ex, 'open_access', False) else ''
mode_badge = '<span class="badge bg-info text-dark ms-2">Exam mode</span>' if getattr(ex, 'exam_mode', False) else ''
cand_badge = '<span class="badge bg-warning text-dark ms-2">Candidates only</span>' if getattr(ex, 'candidates_only', False) else ''
selected_html += (
f'<li id="selected_exam_{self.name}_{getattr(ex, "pk", "")}" class="list-group-item d-flex justify-content-between align-items-center">'
f'<span>{label}</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeExamFromField(\'{self.name}\', {getattr(ex, "pk", "")})">Remove</button>'
f'<li id="selected_exam_{self.name}_{pk}" class="list-group-item d-flex justify-content-between align-items-center">'
f'<span>{label}{archive_badge}{open_badge}{mode_badge}{cand_badge}</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeExamFromField(\'{self.name}\', {pk})">Remove</button>'
f'</li>'
)
except Exception:
@@ -349,8 +366,6 @@ class ExamSearchSelectMultipleWidget:
{f'<div class="badge bg-secondary small">{self.exam_model._meta.verbose_name.title()}</div>' if self.exam_model is not None else ''}
<button type="button" id="exam_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="exam_search_help_panel_{self.name}" title="Advanced search help">?</button>
</div>
<div id="{results_id}" class="mt-2"></div>
<div id="exam_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong>
<div class="mb-2 mt-2">
@@ -389,6 +404,8 @@ class ExamSearchSelectMultipleWidget:
<li>Wildcard: use <code>*</code> to broaden results; wildcards may return more results and are rate-limited.</li>
</ul>
</div>
<div id="{results_id}" class="mt-2"></div>
</div>
<div>
@@ -408,6 +425,7 @@ class ExamSearchSelectMultipleWidget:
const helpPanel = document.getElementById('exam_search_help_panel_{self.name}');
const defaultModel = '{self.exam_model._meta.label if self.exam_model is not None else ''}';
const defaultFilters = {json.dumps(self.default_filters)};
const widgetFieldName = '{self.name}';
let timeout = null;
@@ -422,10 +440,30 @@ class ExamSearchSelectMultipleWidget:
const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}');
const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
let extra = '';
if (f_archive && f_archive.checked) extra += '&archive=1';
if (f_open && f_open.checked) extra += '&open_access=1';
if (f_exam_mode && f_exam_mode.checked) extra += '&exam_mode=1';
if (f_candidates && f_candidates.checked) extra += '&candidates_only=1';
// 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 */ }}
// 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);
const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(q) + modelParam + extra;
fetch(url)
.then(r => r.text())
@@ -518,16 +556,24 @@ class ExamSearchWidget:
needs_multipart_form = False
use_fieldset = False
def __init__(self, field=None, exam_model=None, display_fields=None):
def __init__(self, field=None, exam_model=None, display_fields=None, default_filters=None):
self.field = field
self.attrs = {}
self.exam_model = exam_model
self.display_fields = display_fields or []
# allow overriding defaults per widget instance
self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS
def render(self, name, value, attrs=None, renderer=None):
self.attrs = attrs or {}
value = value or []
widget = ExamSearchSelectMultipleWidget(name, value, exam_model=self.exam_model, display_fields=self.display_fields)
widget = ExamSearchSelectMultipleWidget(
name,
value,
exam_model=self.exam_model,
display_fields=self.display_fields,
default_filters=self.default_filters,
)
return widget.render()
def value_from_datadict(self, data, files, name):