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''
# 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'
'
f'{u.get_full_name() or u.username} {u.email}'
)
if grade_text:
selected_html += f' {grade_text}'
selected_html += (
''
f''
'
'
)
url = reverse("generic:user_search_widget")
grades_options = ''
try:
from generic.models import UserGrades
grades_qs = UserGrades.objects.all()
for g in grades_qs:
grades_options += f''
except Exception:
grades_options = ''
html = f"""
Advanced search tips
Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: smith or joe@example.com.
Wildcards: use * or % as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.
Field-specific searches: prefix with field:term to restrict the search. Supported fields:
name: or full_name: — matches first OR last name (e.g. name:smith).
first: or first_name: — matches first name.
last: or last_name: — matches last name.
email: — matches email address.
username: — matches username.
id: or pk: — match by numeric user id (e.g. id:123).
grade: — match by grade name or grade id (e.g. grade:ST3 or grade:2).
Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.
To add users, click the Add button beside a result. If an Add all results button appears, it will add all currently visible results.
If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.
{selected_html}
"""
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"")
parts.append(f"")
parts.append(f"")
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"")
parts.append(f"")
parts.append(f"")
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 '
# badges for metadata where available
# use smaller, less vibrant badges: low-opacity background + matching text color
archive_badge = 'Archived' if getattr(ex, 'archive', False) else ''
open_badge = 'Open' if getattr(ex, 'open_access', False) else ''
mode_badge = 'Exam mode' if getattr(ex, 'exam_mode', False) else ''
cand_badge = 'Candidates only' if getattr(ex, 'candidates_only', False) else ''
selected_html += (
f'
'
f'{label}{archive_badge}{open_badge}{mode_badge}{cand_badge}'
f''
f'
'
)
except Exception:
options_html = ""
selected_html = ""
url = reverse("generic:exam_search_widget") if reverse else ""
html = f"""