839 lines
46 KiB
Python
839 lines
46 KiB
Python
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:
|
|
"""Reusable lightweight widget renderer for a searchable multi-user selector.
|
|
|
|
Usage: instantiate with (name, value) where value is an iterable of user ids
|
|
and call .render() to get the HTML fragment.
|
|
"""
|
|
|
|
def __init__(self, name, value):
|
|
self.name = name
|
|
if value is None:
|
|
self.value = []
|
|
elif hasattr(value, "values_list"):
|
|
self.value = list(value.values_list("pk", flat=True))
|
|
elif isinstance(value, (list, tuple, set)):
|
|
self.value = [v.pk if hasattr(v, "pk") else v for v in value]
|
|
elif hasattr(value, "pk"):
|
|
self.value = [value.pk]
|
|
elif isinstance(value, (int, str)):
|
|
self.value = [value]
|
|
elif hasattr(value, "__iter__") and not isinstance(value, (str, bytes)):
|
|
self.value = [v.pk if hasattr(v, "pk") else v for v in value]
|
|
else:
|
|
self.value = [value]
|
|
|
|
def render(self):
|
|
field_id = f"id_{self.name}"
|
|
search_id = f"user_search_{self.name}"
|
|
results_id = f"user_search_results_{self.name}"
|
|
selected_list_id = f"selected_users_{self.name}"
|
|
|
|
users = User.objects.filter(pk__in=self.value) if self.value else []
|
|
options_html = ""
|
|
selected_html = ""
|
|
for u in users:
|
|
# determine grade_text first, then include data-user-grade attribute on option
|
|
grade_text = ""
|
|
try:
|
|
up = getattr(u, "userprofile", None)
|
|
if up and getattr(up, "grade", None):
|
|
g = up.grade
|
|
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
|
except Exception:
|
|
grade_text = ""
|
|
safe_grade_attr = (grade_text.replace('"', '"') if grade_text else '')
|
|
options_html += f'<option value="{u.pk}" selected data-user-grade="{safe_grade_attr}">{u.get_full_name() or u.username} - {u.email}</option>'
|
|
|
|
# Build the selected item HTML incrementally to avoid accidental
|
|
# mixing of boolean values into string concatenation (was causing
|
|
# a TypeError in some environments).
|
|
selected_html += (
|
|
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
|
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
|
)
|
|
if grade_text:
|
|
selected_html += f' <span class="badge bg-light text-dark text-muted ms-2 small border">{grade_text}</span>'
|
|
selected_html += (
|
|
'</span>'
|
|
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
|
'</li>'
|
|
)
|
|
|
|
url = reverse("generic:user_search_widget")
|
|
|
|
grades_options = '<option value="">All grades</option>'
|
|
try:
|
|
from generic.models import UserGrades
|
|
|
|
grades_qs = UserGrades.objects.all()
|
|
for g in grades_qs:
|
|
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
|
except Exception:
|
|
grades_options = '<option value="">All grades</option>'
|
|
|
|
html = f"""
|
|
<div class="user-search-widget">
|
|
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
|
{options_html}
|
|
</select>
|
|
|
|
<div class="mb-2">
|
|
<div class="d-flex gap-2 align-items-start">
|
|
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
|
|
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
|
{grades_options}
|
|
</select>
|
|
<!-- Help button for advanced search features -->
|
|
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
|
|
</div>
|
|
|
|
<div id="{results_id}" class="mt-2"></div>
|
|
|
|
<!-- Collapsible help panel (hidden by default) -->
|
|
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
|
|
<strong>Advanced search tips</strong>
|
|
<ul class="mb-0 mt-1">
|
|
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
|
|
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
|
|
<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> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
|
|
<li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
|
|
<li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
|
|
<li><code>email:</code> — matches email address.</li>
|
|
<li><code>username:</code> — matches username.</li>
|
|
<li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
|
|
<li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
|
|
</ul>
|
|
</li>
|
|
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
|
|
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
|
|
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="form-label small">Selected users</label>
|
|
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
|
{selected_html}
|
|
</ul>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {{
|
|
const search = document.getElementById('{search_id}');
|
|
const results = document.getElementById('{results_id}');
|
|
const select = document.getElementById('{field_id}');
|
|
const selectedList = document.getElementById('{selected_list_id}');
|
|
const gradeSelect = document.getElementById('grade_filter_{self.name}');
|
|
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
|
|
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
|
|
|
|
// Expose helper functions immediately so clicks won't fail if
|
|
// earlier listeners throw an exception during setup.
|
|
window.addUserToField = function(fieldName, id, text, grade) {{
|
|
const sel = document.getElementById('id_' + fieldName);
|
|
if (!sel) return;
|
|
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
|
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
|
sel.appendChild(opt);
|
|
const listEl = document.getElementById('selected_users_' + fieldName);
|
|
if (!listEl) return;
|
|
const li = document.createElement('li');
|
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
|
li.id = 'selected_user_' + fieldName + '_' + id;
|
|
const span = document.createElement('span');
|
|
span.innerHTML = text + (grade ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
|
|
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
|
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
|
li.appendChild(span); li.appendChild(btn);
|
|
listEl.appendChild(li);
|
|
}}
|
|
|
|
window.removeUserFromField = function(fieldName, id) {{
|
|
const sel = document.getElementById('id_' + fieldName);
|
|
if (!sel) return;
|
|
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
|
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
|
|
if (li && li.parentNode) li.parentNode.removeChild(li);
|
|
}}
|
|
|
|
window.addAllFromResults = function(fieldName) {{
|
|
const list = document.getElementById('user_search_results_list_' + fieldName);
|
|
if (!list) return;
|
|
const items = list.querySelectorAll('li[data-user-id]');
|
|
items.forEach(function(it) {{
|
|
const id = it.getAttribute('data-user-id');
|
|
const text = it.getAttribute('data-user-text') || it.textContent.trim();
|
|
const grade = it.getAttribute('data-user-grade') || '';
|
|
addUserToField(fieldName, id, text, grade);
|
|
}});
|
|
}}
|
|
|
|
let timeout = null;
|
|
function fetchResults(q) {{
|
|
const grade = gradeSelect ? gradeSelect.value : '';
|
|
if (!q || q.trim().length === 0) {{
|
|
results.innerHTML = '';
|
|
return;
|
|
}}
|
|
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
|
|
fetch(url)
|
|
.then(r => r.text())
|
|
.then(html => {{ results.innerHTML = html; }});
|
|
}}
|
|
|
|
search.addEventListener('keyup', function(e) {{
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => fetchResults(search.value), 300);
|
|
}});
|
|
|
|
// Prevent Enter from submitting the surrounding form; run the search instead
|
|
search.addEventListener('keydown', function(e) {{
|
|
if (e.key === 'Enter' || e.keyCode === 13) {{
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
clearTimeout(timeout);
|
|
fetchResults(search.value);
|
|
}}
|
|
}});
|
|
|
|
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
|
// re-run search with same query when grade changes
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => fetchResults(search.value), 50);
|
|
}});
|
|
|
|
// Toggle help panel visibility
|
|
if (helpBtn && helpPanel) {{
|
|
helpBtn.addEventListener('click', function(e) {{
|
|
e.preventDefault();
|
|
helpPanel.classList.toggle('d-none');
|
|
const expanded = !helpPanel.classList.contains('d-none');
|
|
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
}});
|
|
}}
|
|
|
|
// fieldName for this widget instance
|
|
const widgetFieldName = '{self.name}';
|
|
|
|
// Expose helper functions early so they're available even if
|
|
// a later part of this IIFE throws (avoids ReferenceError on click).
|
|
|
|
|
|
// Delegate clicks from results: add single or add-all (works for dynamically inserted content)
|
|
results.addEventListener('click', function(e) {{
|
|
const addBtn = e.target.closest('.user-add-btn');
|
|
if (addBtn) {{
|
|
const id = addBtn.getAttribute('data-user-id');
|
|
const text = addBtn.getAttribute('data-user-text') || addBtn.textContent.trim();
|
|
// Try button attribute, then list-item dataset, then visible badge text
|
|
let grade = addBtn.getAttribute('data-user-grade') || '';
|
|
if (!grade) {{
|
|
const li = addBtn.closest('li[data-user-id]');
|
|
if (li && li.dataset && li.dataset.userGrade) grade = li.dataset.userGrade;
|
|
else {{
|
|
const badge = addBtn.closest('li') ? addBtn.closest('li').querySelector('.badge') : null;
|
|
if (badge) grade = badge.textContent.trim();
|
|
}}
|
|
}}
|
|
addUserToField(widgetFieldName, id, text, grade);
|
|
return;
|
|
}}
|
|
const addAllBtnDynamic = e.target.closest('.user-add-all-btn');
|
|
if (addAllBtnDynamic) {{
|
|
e.preventDefault();
|
|
addAllFromResults(widgetFieldName);
|
|
return;
|
|
}}
|
|
}});
|
|
|
|
// Wire up the "Add all results" button if present
|
|
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
|
|
if (addAllBtn) {{
|
|
addAllBtn.addEventListener('click', function(e) {{
|
|
e.preventDefault();
|
|
addAllFromResults(widgetFieldName);
|
|
}});
|
|
}}
|
|
|
|
|
|
|
|
// Initialize visible selected-list from any pre-existing <option selected> entries
|
|
(function initSelectedListFromOptions() {{
|
|
try {{
|
|
const sel = document.getElementById('{field_id}');
|
|
const listEl = document.getElementById('{selected_list_id}');
|
|
if (!sel || !listEl) return;
|
|
for (let i=0;i<sel.options.length;i++) {{
|
|
const opt = sel.options[i];
|
|
const id = opt.value;
|
|
if (!id) continue;
|
|
const liId = 'selected_user_' + '{self.name}' + '_' + id;
|
|
const existingLi = document.getElementById(liId);
|
|
const grade = opt.getAttribute('data-user-grade') || '';
|
|
|
|
if (existingLi) {{
|
|
// If server-rendered li exists but has no badge, add one from the option attribute
|
|
const hasBadge = existingLi.querySelector('.badge');
|
|
if (!hasBadge && grade) {{
|
|
const span = existingLi.querySelector('span');
|
|
if (span) {{
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge bg-secondary ms-2';
|
|
badge.textContent = grade;
|
|
span.appendChild(badge);
|
|
}}
|
|
}}
|
|
continue;
|
|
}}
|
|
|
|
// build li from option when not rendered server-side
|
|
const text = opt.textContent || opt.innerText || opt.text;
|
|
const li = document.createElement('li');
|
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
|
li.id = liId;
|
|
const span = document.createElement('span');
|
|
span.innerHTML = text + (grade ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
|
|
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
|
btn.addEventListener('click', function() {{ removeUserFromField('{self.name}', id); }});
|
|
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
|
|
}}
|
|
}} catch (e) {{ /* ignore init errors */ }}
|
|
}})();
|
|
}})();
|
|
</script>
|
|
</div>
|
|
"""
|
|
|
|
return mark_safe(html)
|
|
|
|
|
|
class UserSearchWidget:
|
|
"""Django-widget-compatible lightweight wrapper around
|
|
`UserSearchSelectMultipleWidget` so it can be reused as a field widget.
|
|
|
|
Implements the small subset of the Widget API that Django's forms
|
|
and templates expect: `render`, `value_from_datadict`, `id_for_label`,
|
|
`use_required_attribute`, and a couple of simple attributes.
|
|
"""
|
|
|
|
is_hidden = False
|
|
needs_multipart_form = False
|
|
use_fieldset = False
|
|
|
|
def __init__(self, field=None):
|
|
# keep a reference to the original field if available (used for
|
|
# delegation such as `use_required_attribute`).
|
|
self.field = field
|
|
self.attrs = {}
|
|
|
|
def render(self, name, value, attrs=None, renderer=None):
|
|
self.attrs = attrs or {}
|
|
value = value or []
|
|
widget = UserSearchSelectMultipleWidget(name, value)
|
|
return widget.render()
|
|
|
|
def value_from_datadict(self, data, files, name):
|
|
# Most form backends provide getlist
|
|
if hasattr(data, "getlist"):
|
|
return data.getlist(name)
|
|
val = data.get(name)
|
|
if val is None:
|
|
return []
|
|
return [val]
|
|
|
|
def id_for_label(self, id_):
|
|
try:
|
|
if hasattr(self, "attrs") and self.attrs and self.attrs.get("id"):
|
|
return self.attrs.get("id")
|
|
except Exception:
|
|
pass
|
|
return id_
|
|
|
|
def use_required_attribute(self, initial):
|
|
try:
|
|
if hasattr(self, "field") and getattr(self, "field") is not None:
|
|
orig_widget = getattr(self.field, "widget", None)
|
|
if orig_widget and hasattr(orig_widget, "use_required_attribute"):
|
|
return orig_widget.use_required_attribute(initial)
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
class ExamSearchSelectMultipleWidget:
|
|
"""Lightweight renderer for selecting exams across types.
|
|
|
|
Parameters
|
|
- name, value: same as form widget usage
|
|
- exam_model: optional Django model class for an exam type (used to
|
|
adapt display and search behaviour)
|
|
- display_fields: optional list of extra fields to show in results
|
|
"""
|
|
|
|
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 _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}"
|
|
results_id = f"exam_search_results_{self.name}"
|
|
selected_list_id = f"selected_exams_{self.name}"
|
|
|
|
# Render currently selected exams as <option>s
|
|
options_html = ""
|
|
selected_html = ""
|
|
try:
|
|
if self.value:
|
|
from django.apps import apps
|
|
|
|
# Accept either pks or model instances
|
|
instances = []
|
|
for v in self.value:
|
|
if hasattr(v, "pk"):
|
|
instances.append(v)
|
|
else:
|
|
# try to resolve by pk across possible models
|
|
# when exam_model is provided prefer that
|
|
if self.exam_model is not None:
|
|
try:
|
|
inst = self.exam_model.objects.filter(pk=v).first()
|
|
if inst:
|
|
instances.append(inst)
|
|
continue
|
|
except Exception:
|
|
pass
|
|
# fallback: try generic lookup across known exam models is expensive;
|
|
# just attempt to treat v as a pk for display-less option
|
|
instances.append(type("_", (), {"pk": v, "__str__": lambda s: str(v)}))
|
|
|
|
for ex in instances:
|
|
label = str(ex)
|
|
pk = getattr(ex, "pk", "")
|
|
options_html += f'<option value="{pk}" selected>{label}</option>'
|
|
# badges for metadata where available
|
|
# use smaller, less vibrant badges: low-opacity background + matching text color
|
|
archive_badge = '<span class="badge small border bg-secondary bg-opacity-10 text-secondary ms-2">Archived</span>' if getattr(ex, 'archive', False) else ''
|
|
open_badge = '<span class="badge small border bg-success bg-opacity-10 text-success ms-2">Open</span>' if getattr(ex, 'open_access', False) else ''
|
|
mode_badge = '<span class="badge small border bg-info bg-opacity-10 text-info ms-2">Exam mode</span>' if getattr(ex, 'exam_mode', False) else ''
|
|
cand_badge = '<span class="badge small border bg-warning bg-opacity-10 text-warning ms-2">Candidates only</span>' if getattr(ex, 'candidates_only', False) else ''
|
|
selected_html += (
|
|
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:
|
|
options_html = ""
|
|
selected_html = ""
|
|
|
|
url = reverse("generic:exam_search_widget") if reverse else ""
|
|
|
|
html = f"""
|
|
<div class="exam-search-widget">
|
|
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
|
{options_html}
|
|
</select>
|
|
|
|
<div class="mb-2">
|
|
<div class="d-flex gap-2 align-items-start">
|
|
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search exams by name or code" />
|
|
<!-- optional type indicator -->
|
|
{f'<div class="ms-2"><span class="badge bg-primary text-white small fw-semibold" aria-hidden="true">{self.exam_model._meta.verbose_name.title()}</span><span class="visually-hidden">Exam type: {self.exam_model._meta.verbose_name.title()}</span></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="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">
|
|
<strong class="small">Filters</strong>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<ul class="mb-0 mt-1">
|
|
<li>Basic: type any part of an exam's name, code or identifier (case-insensitive substring match).</li>
|
|
<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>id:</code> or <code>pk:</code> — match by numeric id.</li>
|
|
<li><code>date:</code> — match by date (YYYY-MM-DD) where available.</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>
|
|
<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>
|
|
<label class="form-label small">Selected exams</label>
|
|
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
|
{selected_html}
|
|
</ul>
|
|
<div class="mt-2 text-end">
|
|
<button type="button" id="exam_remove_all_{self.name}" class="btn btn-sm btn-outline-danger exam-remove-all-btn d-none py-0 px-1 small" data-field="{self.name}">Remove all</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {{
|
|
const search = document.getElementById('{search_id}');
|
|
const results = document.getElementById('{results_id}');
|
|
const select = document.getElementById('{field_id}');
|
|
const selectedList = document.getElementById('{selected_list_id}');
|
|
const helpBtn = document.getElementById('exam_search_help_btn_{self.name}');
|
|
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;
|
|
|
|
// 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 fetchResults(rawQ) {{
|
|
const parsed = parseFilterTokens(rawQ || '');
|
|
const q = parsed.cleaned;
|
|
|
|
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 = '';
|
|
|
|
// 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; }}
|
|
}}
|
|
|
|
// 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);
|
|
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;
|
|
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);
|
|
}});
|
|
|
|
// Prevent Enter from submitting the surrounding form; run the search instead
|
|
search.addEventListener('keydown', function(e) {{
|
|
if (e.key === 'Enter' || e.keyCode === 13) {{
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
clearTimeout(timeout);
|
|
fetchResults(search.value);
|
|
}}
|
|
}});
|
|
|
|
// 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) {{
|
|
e.preventDefault();
|
|
helpPanel.classList.toggle('d-none');
|
|
const expanded = !helpPanel.classList.contains('d-none');
|
|
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
}});
|
|
}}
|
|
|
|
// Delegate clicks from results: add single or add-all (works for dynamically inserted content)
|
|
results.addEventListener('click', function(e) {{
|
|
const addBtn = e.target.closest('.exam-add-btn');
|
|
if (addBtn) {{
|
|
const id = addBtn.getAttribute('data-exam-id');
|
|
const text = addBtn.getAttribute('data-exam-text') || addBtn.textContent.trim();
|
|
addExamToField(widgetFieldName, id, text);
|
|
return;
|
|
}}
|
|
const addAllBtnDynamic = e.target.closest('.exam-add-all-btn');
|
|
if (addAllBtnDynamic) {{
|
|
e.preventDefault();
|
|
addAllExamsFromResults(widgetFieldName);
|
|
return;
|
|
}}
|
|
}});
|
|
|
|
|
|
// Wire up "Add all results" static button if present (fallback)
|
|
const addAllBtn = results.parentElement.querySelector('.exam-add-all-btn');
|
|
if (addAllBtn) {{
|
|
addAllBtn.addEventListener('click', function(e) {{
|
|
e.preventDefault();
|
|
addAllExamsFromResults(widgetFieldName);
|
|
}});
|
|
}}
|
|
|
|
// Initialize remove-all visibility based on any pre-populated selections
|
|
try {{
|
|
var initList = document.getElementById('selected_exams_' + widgetFieldName);
|
|
var remBtnInit = document.getElementById('exam_remove_all_' + widgetFieldName);
|
|
if (remBtnInit && initList) {{
|
|
remBtnInit.classList.toggle('d-none', initList.querySelectorAll('li').length < 2);
|
|
}}
|
|
}} catch (e) {{ /* ignore in older browsers */ }}
|
|
|
|
// Wire up "Remove all" button for this widget instance
|
|
const removeAllBtn = document.getElementById('exam_remove_all_' + widgetFieldName);
|
|
if (removeAllBtn) {{
|
|
removeAllBtn.addEventListener('click', function(e) {{
|
|
e.preventDefault();
|
|
removeAllExamsFromField(widgetFieldName);
|
|
}});
|
|
}}
|
|
|
|
window.addExamToField = function(fieldName, id, text) {{
|
|
const sel = document.getElementById('id_' + fieldName);
|
|
if (!sel) return;
|
|
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
|
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
|
sel.appendChild(opt);
|
|
const listEl = document.getElementById('selected_exams_' + fieldName);
|
|
if (!listEl) return;
|
|
const li = document.createElement('li');
|
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
|
li.id = 'selected_exam_' + fieldName + '_' + id;
|
|
const span = document.createElement('span'); span.innerHTML = text;
|
|
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
|
btn.addEventListener('click', function() {{ removeExamFromField(fieldName, id); }});
|
|
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
|
|
// update the remove-all button visibility
|
|
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
|
|
if (remBtn) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
|
|
}}
|
|
|
|
window.removeExamFromField = function(fieldName, id) {{
|
|
const sel = document.getElementById('id_' + fieldName);
|
|
if (!sel) return;
|
|
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
|
const li = document.getElementById('selected_exam_' + fieldName + '_' + id);
|
|
if (li && li.parentNode) li.parentNode.removeChild(li);
|
|
// update the remove-all button visibility
|
|
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
|
|
var listEl = document.getElementById('selected_exams_' + fieldName);
|
|
if (remBtn && listEl) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
|
|
}}
|
|
|
|
window.addAllExamsFromResults = function(fieldName) {{
|
|
const list = document.getElementById('exam_search_results_list_' + fieldName);
|
|
if (!list) return;
|
|
const items = list.querySelectorAll('li[data-exam-id]');
|
|
items.forEach(function(it) {{
|
|
const id = it.getAttribute('data-exam-id');
|
|
const text = it.getAttribute('data-exam-text') || it.textContent.trim();
|
|
addExamToField(fieldName, id, text);
|
|
}});
|
|
// ensure remove-all button is visible if more than one
|
|
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
|
|
var listEl = document.getElementById('selected_exams_' + fieldName);
|
|
if (remBtn && listEl) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
|
|
}}
|
|
|
|
window.removeAllExamsFromField = function(fieldName) {{
|
|
const sel = document.getElementById('id_' + fieldName);
|
|
if (!sel) return;
|
|
// remove all options
|
|
for (let i=sel.options.length-1;i>=0;i--) {{ sel.remove(i); }}
|
|
// clear visible list
|
|
const listEl = document.getElementById('selected_exams_' + fieldName);
|
|
if (listEl) {{ listEl.innerHTML = ''; }}
|
|
// hide remove-all button
|
|
const remBtn = document.getElementById('exam_remove_all_' + fieldName);
|
|
if (remBtn) {{ remBtn.classList.add('d-none'); }}
|
|
}}
|
|
}})();
|
|
</script>
|
|
</div>
|
|
"""
|
|
|
|
return mark_safe(html)
|
|
|
|
|
|
class ExamSearchWidget:
|
|
"""Widget wrapper that is compatible with Django form machinery.
|
|
|
|
Accepts `exam_model` to specialise behaviour/display.
|
|
"""
|
|
|
|
is_hidden = False
|
|
needs_multipart_form = False
|
|
use_fieldset = False
|
|
|
|
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,
|
|
default_filters=self.default_filters,
|
|
)
|
|
return widget.render()
|
|
|
|
def value_from_datadict(self, data, files, name):
|
|
if hasattr(data, 'getlist'):
|
|
return data.getlist(name)
|
|
val = data.get(name)
|
|
if val is None:
|
|
return []
|
|
return [val]
|
|
|
|
def id_for_label(self, id_):
|
|
try:
|
|
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
|
|
return self.attrs.get('id')
|
|
except Exception:
|
|
pass
|
|
return id_
|
|
|
|
def use_required_attribute(self, initial):
|
|
try:
|
|
if hasattr(self, 'field') and getattr(self, 'field') is not None:
|
|
orig_widget = getattr(self.field, 'widget', None)
|
|
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
|
|
return orig_widget.use_required_attribute(initial)
|
|
except Exception:
|
|
pass
|
|
return False
|