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.
    """ 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"""
    {f'
    Exam type: {self.exam_model._meta.verbose_name.title()}
    ' if self.exam_model is not None else ''}
    Advanced search tips
    Filters
    • Basic: type any part of an exam's name, code or identifier (case-insensitive substring match).
    • Field-specific searches: prefix with field:term to restrict the search. Supported fields:
      • name: — matches the exam name.
      • id: or pk: — match by numeric id.
      • date: — match by date (YYYY-MM-DD) where available.
    • To add exams, click the Add button beside a result. Use Add all results to import visible matches.
    • Wildcard: use * to broaden results; wildcards may return more results and are rate-limited.
    """ 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