Compare commits

...
18 Commits
Author SHA1 Message Date
Ross 7fa08037f0 Remove unnecessary comments from bulk add groups form template 2025-11-12 22:23:50 +00:00
Ross f786e88de9 Refactor bulk add groups form to use HTMX for submission and enhance button functionality 2025-11-12 22:15:10 +00:00
Ross 9441541feb Add grade display to selected users in UserUserGroupForm 2025-11-12 22:11:43 +00:00
Ross 5cca1b079a Fix syntax error in user group form and enhance logging in bulk add groups view 2025-11-12 22:06:09 +00:00
Ross 2e551d411a Add grade information to user search results and enhance user addition functionality 2025-11-12 21:59:20 +00:00
Ross d8055a509c Enhance user search widget with wildcard support and field-specific queries; improve logging in bulk add groups 2025-11-12 21:54:51 +00:00
Ross 208252a858 Add grade filter to user search widget and enhance logging for bulk add operations 2025-11-12 21:48:50 +00:00
Ross ea9dca5804 Implement user search widget with HTMX for dynamic user selection 2025-11-12 21:40:24 +00:00
Ross da04138861 Enhance bulk add groups form with crispy forms for improved styling and usability 2025-11-12 21:23:41 +00:00
Ross ec0245fe7c Add bulk add functionality for user and CID groups to exam collections 2025-11-12 21:19:09 +00:00
Ross c39d30c746 Refactor anatomy question answer form layout and improve styling for better usability 2025-11-11 21:48:01 +00:00
Ross d781508364 Update exam counts to use distinct counts for archived and non-archived exams 2025-11-11 21:31:07 +00:00
Ross 06ea87be14 Add shorts exam count to collection annotations and update display logic 2025-11-11 21:26:05 +00:00
Ross ce1b439681 Refactor exam counts annotation to respect archived status toggle 2025-11-11 21:09:00 +00:00
Ross c8375a28fa Add functionality to filter exam collections by archived status 2025-11-11 21:07:25 +00:00
Ross 6be39ab69d Add archived badge to exam list for better visibility of archived exams 2025-11-11 20:55:57 +00:00
Ross e9d0e402be Implement bulk archive functionality for exams; add HTMX endpoint and update UI controls for improved exam management 2025-11-11 20:52:44 +00:00
Ross dc23dffaf9 Remove instructional text for HTMX marking UI from Mark2 overview page for cleaner interface 2025-11-11 19:51:52 +00:00
13 changed files with 983 additions and 47 deletions
@@ -28,32 +28,60 @@
{% if object %} {% if object %}
{% include 'anatomy/question_link_header.html' %} {% include 'anatomy/question_link_header.html' %}
{% endif %} {% endif %}
<h2>Question Answers</h2> <div class="card mb-4">
<details class="help-text"> <div class="card-header d-flex justify-content-between align-items-center">
<h2 class="mb-0">Question Answers</h2>
<small class="text-muted">Manage answer keys for auto-marking</small>
</div>
<div class="card-body">
<details class="help-text mb-3">
<summary><i class="bi bi-info-circle"></i> Help</summary> <summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)</p> <p class="mb-0">This page can be used to add and modify answers associated with a question. Answers here are used for auto-marking submitted responses; avoid removing entries unless you intend to re-score submissions.</p>
</details> </details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %} {% csrf_token %}
<h3>Answers:</h3>
<div id="form_set"> <h3 class="h5 mt-2">Answers</h3>
<div id="form_set" class="mt-3">
{% for form in answer_formset %} {% for form in answer_formset %}
<ul class="no-error answer-formset"> <div class="answer-card mb-3 p-3 border rounded bg-body-secondary">
{{form.non_field_errors}} {{form.non_field_errors}}
{{form.errors}} {{form.errors}}
<div class="answer-fields">
{{ form.as_ul }} {{ form.as_ul }}
</ul> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
{{ answer_formset.management_form }} {{ answer_formset.management_form }}
<input type="button" value="Add More Answers" id="add_more">
<p> <div class="d-flex gap-2 align-items-center mt-3">
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit"> <button type="button" id="add_more" class="btn btn-outline-secondary btn-sm">Add another answer</button>
</p> <input type="submit" class="submit-button btn btn-primary btn-sm ms-auto" value="Save answers" name="submit">
</form> </div>
<div id="empty_form" style="display:none"> </form>
<ul class='no_error answer-formset'>
<div id="empty_form" class="d-none">
<div class='answer-card p-3 mb-3 border rounded bg-body-secondary'>
{{ answer_formset.empty_form.as_ul }} {{ answer_formset.empty_form.as_ul }}
</ul> </div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block css %}
<style>
/* Answer card layout */
.answer-card label { font-weight: 600; }
.answer-card li { list-style: none; margin-bottom: .5rem; }
.answer-card input[type="text"], .answer-card textarea, .answer-card select { width: 100%; }
.help-text summary { cursor: pointer; font-weight:600; }
/* Make the Add button more prominent on small screens */
@media (max-width: 576px) {
.submit-button { width: 100%; }
}
</style>
{% endblock %}
@@ -5,7 +5,6 @@
<div class="d-flex justify-content-between align-items-start mb-3"> <div class="d-flex justify-content-between align-items-start mb-3">
<div> <div>
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2> <h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
<p class="small text-muted mb-0">Use the improved HTMX marking UI by opening a question below.</p>
</div> </div>
<div class="text-end"> <div class="text-end">
<div class="btn-group me-2" role="group" aria-label="listing actions"> <div class="btn-group me-2" role="group" aria-label="listing actions">
+314 -1
View File
@@ -2,6 +2,7 @@ from django.forms import (
Form, Form,
ModelForm, ModelForm,
ModelMultipleChoiceField, ModelMultipleChoiceField,
MultipleChoiceField,
ModelChoiceField, ModelChoiceField,
ChoiceField, ChoiceField,
CharField, CharField,
@@ -77,6 +78,9 @@ from crispy_forms.bootstrap import Accordion, AccordionGroup
from dal import autocomplete from dal import autocomplete
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.contrib.auth.models import User
class SplitDateTimeFieldDefaultTime(SplitDateTimeField): class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
@@ -291,6 +295,70 @@ class ExamGroupsFormMixin(ModelForm):
self.fields["user_user_groups"].widget.attrs["size"] = 8 self.fields["user_user_groups"].widget.attrs["size"] = 8
class ExamCollectionBulkAddGroupsForm(Form):
"""Form used by the HTMX modal to bulk-add CID/User groups to an ExamCollection.
Accepts `user` kwarg in __init__ to limit available groups based on permissions
(mirrors behaviour of `ExamGroupsFormMixin`).
"""
cid_user_groups = ModelMultipleChoiceField(required=False, queryset=CidUserGroup.objects.none(), label="CID User Groups")
user_user_groups = ModelMultipleChoiceField(required=False, queryset=UserUserGroup.objects.none(), label="User Groups")
EXAM_TYPE_CHOICES = [
("all", "All"),
("anatomy", "Anatomy"),
("longs", "Longs"),
("rapids", "Rapids"),
("shorts", "Shorts"),
("physics", "Physics"),
("sbas", "SBAs"),
]
exam_types = MultipleChoiceField(required=False, choices=EXAM_TYPE_CHOICES, label="Exam types (leave empty for all)")
def __init__(self, *args, user=None, **kwargs):
# keep compatibility with other forms that pass user as kwarg
# This is a plain Form (not a ModelForm) so call Form.__init__
Form.__init__(self, *args, **kwargs)
# choose queryset depending on permissions
if user and (user.is_superuser or user.groups.filter(name="cid_user_manager").exists()):
cid_qs = CidUserGroup.objects.filter(archive=False)
user_qs = UserUserGroup.objects.filter(archive=False)
else:
cid_qs = CidUserGroup.objects.filter(archive=False, open_access=True)
user_qs = UserUserGroup.objects.filter(archive=False, open_access=True)
self.fields["cid_user_groups"].queryset = cid_qs
self.fields["user_user_groups"].queryset = user_qs
self.fields["cid_user_groups"].widget.attrs["size"] = 8
self.fields["user_user_groups"].widget.attrs["size"] = 8
# Crispy helper: render fields prettily in templates. We set form_tag=False
# because the HTMX partial includes the <form> tag with hx attributes.
try:
self.helper = FormHelper()
# render only the fields (the template contains the form element)
self.helper.form_tag = False
# use bootstrap5 templates if available in the project
self.helper.template_pack = "bootstrap5"
self.helper.form_class = "form-vertical"
self.helper.layout = Layout(
Div(
Field("cid_user_groups", css_class="form-select", template="bootstrap5/layout/multi_select.html"),
css_class="mb-3",
),
Div(
Field("user_user_groups", css_class="form-select", template="bootstrap5/layout/multi_select.html"),
css_class="mb-3",
),
Div(
Field("exam_types", css_class="form-select"),
css_class="mb-3",
),
)
except Exception:
# If crispy isn't available for some reason, degrade gracefully.
self.helper = None
class ExaminationForm(ModelForm): class ExaminationForm(ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Where is this coming from? # Where is this coming from?
@@ -489,12 +557,257 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
class UserUserGroupForm(ModelForm): class UserUserGroupForm(ModelForm):
class UserSearchSelectMultiple(object):
"""
Lightweight widget renderer for a searchable multi-user selector.
Renders a hidden <select multiple> containing selected user ids and
a search box with results. Uses a small inline script to fetch
results from the `generic:user_search_widget` endpoint and add/remove
users from the selection.
This keeps implementation simple and avoids external JS dependencies.
"""
def __init__(self, name, value):
self.name = name
# value is a list of selected user ids
self.value = value or []
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}"
# Build initial options for selected users
users = User.objects.filter(pk__in=self.value) if self.value else []
options_html = ""
selected_html = ""
for u in users:
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
# include grade display in the selected list if available
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 = ""
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>'
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
+ '</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
f'</li>'
)
url = reverse("generic:user_search_widget")
# Build a small grade filter select
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">
<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>
</div>
<div id="{results_id}" class="mt-2"></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}');
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);
}});
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
// re-run search with same query when grade changes
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 50);
}});
// fieldName for this widget instance
const widgetFieldName = '{self.name}';
// Add button click delegation: results list buttons have data attributes
results.addEventListener('click', function(e) {{
const btn = e.target.closest('.user-add-btn');
if (!btn) return;
const id = btn.getAttribute('data-user-id');
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
const grade = btn.getAttribute('data-user-grade') || '';
addUserToField(widgetFieldName, id, text, grade);
}});
// 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);
}});
}}
window.addUserToField = function(fieldName, id, text, grade) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
// prevent duplicate
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);
// add to visible list (include grade if provided)
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 ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
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);
selectedList.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);
}});
}}
}})();
</script>
</div>
"""
return mark_safe(html)
users = UserUserGroupModelChoiceField( users = UserUserGroupModelChoiceField(
required=False, required=False,
queryset=User.objects.all(), queryset=User.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Users", is_stacked=False), # use our lightweight widget rendering
widget=None,
) )
# monkey-patch the field's widget rendering when the form is instantiated
def __init__(self, *args, **kwargs):
ModelForm.__init__(self, *args, **kwargs)
# replace widget with callable that renders our HTML
if 'users' in self.fields:
orig_field = self.fields['users']
class RenderWrapper:
def __init__(self, field):
self.field = field
# Minimal widget-compatible wrapper used by Django templates and
# form machinery. We implement the small subset of the Widget
# API that the templates/checks expect: `is_hidden`,
# `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`.
is_hidden = False
needs_multipart_form = False
use_fieldset = False
# attrs is expected by BoundField.id_for_label (widget.attrs.get('id'))
attrs = {}
def render(self, name, value, attrs=None, renderer=None):
# store attrs so template helpers can inspect them
self.attrs = attrs or {}
value = value or []
widget = UserUserGroupForm.UserSearchSelectMultiple(name, value)
return widget.render()
def value_from_datadict(self, data, files, name):
# data is usually QueryDict with getlist
if hasattr(data, 'getlist'):
return data.getlist(name)
# fallback
val = data.get(name)
if val is None:
return []
return [val]
def id_for_label(self, id_):
# Called by BoundField.id_for_label; prefer explicit id in attrs
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):
# Called by Django to decide whether to add the required HTML attribute.
# We defer to the underlying field/widget if available; otherwise
# return False to avoid unexpected 'required' attributes.
try:
if hasattr(self, 'field') and getattr(self, 'field') is not None:
# If original field/widget had such method, prefer it.
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
self.fields['users'].widget = RenderWrapper(orig_field)
class Meta: class Meta:
model = UserUserGroup model = UserUserGroup
fields = ["name", "archive", "open_access", "users"] fields = ["name", "archive", "open_access", "users"]
+132
View File
@@ -1946,6 +1946,138 @@ class ExamCollection(models.Model, AuthorMixin):
exam.add_markers(users) exam.add_markers(users)
#return True #return True
def bulk_add_user_user_groups(self, groups, exam_types: None | list[str] = None) -> int:
"""Bulk-add one or more UserUserGroup objects (or PKs) to all exams in this
collection.
Args:
groups: iterable of UserUserGroup instances or an iterable of PKs (ints),
or a Django QuerySet of UserUserGroup.
exam_types: optional list of exam type keys to limit the operation. Keys
are the same as returned by `get_exams()` (e.g. 'physics', 'anatomy').
Returns:
Number of exams that were updated (int).
Note: permission checks are not performed here; callers should ensure the
current user is allowed to modify these exams.
"""
# import locally to avoid circular import surprises when module is imported
UserUserGroup = globals().get("UserUserGroup")
if UserUserGroup is None:
raise RuntimeError("UserUserGroup model not available")
# Debug: log what we received
try:
group_pks = [g.pk for g in groups] if groups is not None else []
except Exception:
group_pks = []
logger.debug("bulk_add_user_user_groups called on collection=%s with groups=%s exam_types=%s", self.pk, group_pks, exam_types)
# Normalize groups to a list of model instances
if groups is None:
return 0
# If groups is a queryset or list of model instances, materialize it
try:
# QuerySet-ish
groups_qs = list(groups)
except TypeError:
# single value provided
groups_qs = [groups]
# If provided values were ints (PKs), resolve them to model instances
if groups_qs and all(isinstance(g, int) for g in groups_qs):
groups_qs = list(UserUserGroup.objects.filter(pk__in=groups_qs))
if not groups_qs:
return 0
updated = 0
for exam_type, exams in self.get_exams().items():
if exam_types is not None and exam_type not in exam_types:
continue
for exam in exams:
# Each exam model exposes a `user_user_groups` ManyToManyField
try:
before_pks = set(exam.user_user_groups.values_list('pk', flat=True))
exam.user_user_groups.add(*groups_qs)
# Ensure any downstream signals / caches see the change
exam.save()
after_pks = set(exam.user_user_groups.values_list('pk', flat=True))
# log if expected group pks are not present after add
try:
expected_pks = {g.pk for g in groups_qs}
except Exception:
expected_pks = set()
if not expected_pks.issubset(after_pks):
logger.warning(
"After add, exam %s missing expected user_user_groups; before=%s after=%s expected=%s",
getattr(exam, 'pk', exam), sorted(before_pks), sorted(after_pks), sorted(expected_pks),
)
updated += 1
except Exception as e:
logger.exception("Failed to add user_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e)
return updated
def bulk_add_cid_user_groups(self, groups, exam_types: None | list[str] = None) -> int:
"""Bulk-add one or more CidUserGroup objects (or PKs) to all exams in this
collection. Returns number of exams updated.
"""
CidUserGroup = globals().get("CidUserGroup")
if CidUserGroup is None:
raise RuntimeError("CidUserGroup model not available")
# Debug: log what we received
try:
group_pks = [g.pk for g in groups] if groups is not None else []
except Exception:
group_pks = []
logger.debug("bulk_add_cid_user_groups called on collection=%s with groups=%s exam_types=%s", self.pk, group_pks, exam_types)
if groups is None:
return 0
try:
groups_qs = list(groups)
except TypeError:
groups_qs = [groups]
if groups_qs and all(isinstance(g, int) for g in groups_qs):
groups_qs = list(CidUserGroup.objects.filter(pk__in=groups_qs))
if not groups_qs:
return 0
updated = 0
for exam_type, exams in self.get_exams().items():
if exam_types is not None and exam_type not in exam_types:
continue
for exam in exams:
# Each exam model exposes a `cid_user_groups` ManyToManyField
try:
before_pks = set(exam.cid_user_groups.values_list('pk', flat=True))
exam.cid_user_groups.add(*groups_qs)
exam.save()
after_pks = set(exam.cid_user_groups.values_list('pk', flat=True))
try:
expected_pks = {g.pk for g in groups_qs}
except Exception:
expected_pks = set()
if not expected_pks.issubset(after_pks):
logger.warning(
"After add, exam %s missing expected cid_user_groups; before=%s after=%s expected=%s",
getattr(exam, 'pk', exam), sorted(before_pks), sorted(after_pks), sorted(expected_pks),
)
updated += 1
except Exception as e:
logger.exception("Failed to add cid_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e)
return updated
#class Presentation(models.Model, AuthorMixin): #class Presentation(models.Model, AuthorMixin):
# #
+112
View File
@@ -5,8 +5,12 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-toggle-btn">Bulk edit</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-toggle-btn">Bulk edit</button>
</div> </div>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="me-2">
<button id="toggle-archive-btn" type="button" class="btn btn-sm btn-outline-info">Show archived</button>
</div>
<div id="bulk-controls" class="d-none"> <div id="bulk-controls" class="d-none">
<div> <div>
<button type="button" class="btn btn-sm btn-outline-primary" id="select-all-btn">Select all</button> <button type="button" class="btn btn-sm btn-outline-primary" id="select-all-btn">Select all</button>
@@ -31,6 +35,16 @@
{% csrf_token %} {% csrf_token %}
<button class="btn btn-sm btn-danger" type="submit" onclick="return confirm('Delete selected exams? This action is irreversible.')">Delete selected</button> <button class="btn btn-sm btn-danger" type="submit" onclick="return confirm('Delete selected exams? This action is irreversible.')">Delete selected</button>
</form> </form>
<form id="bulk-archive-form" class="mt-2" hx-post="{% url app_name|add:':exam_bulk_archive' %}" hx-target="#exam-list-container" hx-include="#exam-list-container input[name='exam_ids']" hx-indicator="#bulk-action-indicator">
{% for k, v in request.GET.items %}
<input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endfor %}
{% csrf_token %}
<div class="btn-group" role="group">
<button name="archive" value="true" class="btn btn-sm btn-warning" type="submit" onclick="return confirm('Archive selected exams?')">Archive selected</button>
<button name="archive" value="false" class="btn btn-sm btn-outline-warning" type="submit" onclick="return confirm('Unarchive selected exams?')">Unarchive selected</button>
</div>
</form>
<!-- Bulk action indicator shown while HTMX requests are running --> <!-- Bulk action indicator shown while HTMX requests are running -->
<span id="bulk-action-indicator" class="ms-2" aria-hidden="true"> <span id="bulk-action-indicator" class="ms-2" aria-hidden="true">
@@ -67,6 +81,8 @@
/* Defensive: hide bulk-controls unless bulk-mode is enabled */ /* Defensive: hide bulk-controls unless bulk-mode is enabled */
#exam-list-wrapper:not(.bulk-mode) #bulk-controls { display: none !important; } #exam-list-wrapper:not(.bulk-mode) #bulk-controls { display: none !important; }
/* (bulk-only top-level controls removed) */
/* Bulk action indicator - hidden by default, shown while HTMX request is active */ /* Bulk action indicator - hidden by default, shown while HTMX request is active */
#bulk-action-indicator { display: none; } #bulk-action-indicator { display: none; }
#bulk-action-indicator.htmx-request { display: inline-block; } #bulk-action-indicator.htmx-request { display: inline-block; }
@@ -106,6 +122,58 @@
}); });
} }
// Top-level archive/unarchive buttons: make visible and usable without hunting inside bulk panel
const topArchiveBtn = document.getElementById('top-archive-btn');
const topUnarchiveBtn = document.getElementById('top-unarchive-btn');
function ensureBulkModeVisible(){
if(!examListWrapper.classList.contains('bulk-mode')){
examListWrapper.classList.add('bulk-mode');
bulkControls.classList.remove('d-none');
}
}
function submitArchiveAction(isArchive){
// find the bulk-archive form already in the DOM
const form = document.getElementById('bulk-archive-form');
if(!form){
alert('Bulk archive form not found.');
return;
}
// if no checkboxes selected, open bulk-mode and focus user to select
const checked = Array.from(document.querySelectorAll('#exam-list-container input.exam-select-checkbox:checked'));
if(checked.length === 0){
ensureBulkModeVisible();
// scroll to controls so user can pick
bulkControls.scrollIntoView({behavior:'smooth', block:'center'});
// highlight controls briefly
bulkControls.classList.add('border', 'border-3', 'border-primary');
setTimeout(()=> bulkControls.classList.remove('border','border-3','border-primary'), 2000);
return;
}
// set the archive input value on the appropriate button and submit
const btn = form.querySelector('button[name="archive"][value="' + (isArchive ? 'true' : 'false') + '"]');
if(btn){
btn.click();
} else {
// fallback: add hidden input and submit
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'archive';
hidden.value = isArchive ? 'true' : 'false';
form.appendChild(hidden);
form.requestSubmit();
}
}
if(topArchiveBtn){
topArchiveBtn.addEventListener('click', function(){ submitArchiveAction(true); });
}
if(topUnarchiveBtn){
topUnarchiveBtn.addEventListener('click', function(){ submitArchiveAction(false); });
}
// Make clicking an exam row toggle its checkbox when bulk-mode is enabled. // Make clicking an exam row toggle its checkbox when bulk-mode is enabled.
document.addEventListener('click', function(e){ document.addEventListener('click', function(e){
if(!examListWrapper.classList.contains('bulk-mode')) return; if(!examListWrapper.classList.contains('bulk-mode')) return;
@@ -123,5 +191,49 @@
}); });
}); });
</script> </script>
<script>
// Archive toggle button: toggle ?archive=1 while preserving other query params
(function(){
function getQueryParams(){
return new URLSearchParams(window.location.search);
}
function updateButton(){
const btn = document.getElementById('toggle-archive-btn');
if(!btn) return;
const params = getQueryParams();
if(params.get('archive') && params.get('archive') !== '0'){
btn.textContent = 'Hide archived';
btn.classList.remove('btn-outline-info');
btn.classList.add('btn-outline-secondary');
} else {
btn.textContent = 'Show archived';
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-outline-info');
}
}
// handle toggle button click
document.addEventListener('click', function(e){
if(e.target && e.target.id === 'toggle-archive-btn'){
const params = getQueryParams();
if(params.get('archive') && params.get('archive') !== '0'){
params.delete('archive');
} else {
params.set('archive','1');
}
const q = params.toString();
const newUrl = window.location.pathname + (q ? ('?' + q) : '');
window.location.href = newUrl;
}
});
// init on load
document.addEventListener('DOMContentLoaded', updateButton);
// also update button when HTMX swaps content
document.addEventListener('htmx:afterSwap', updateButton);
document.addEventListener('htmx:afterOnLoad', updateButton);
})();
</script>
{% endblock %} {% endblock %}
@@ -86,6 +86,10 @@
{% endif %} {% endif %}
<br/> <br/>
<div id="bulk-add-groups">
<button class="btn-sm" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
</div>
<div id="add-user"> <div id="add-user">
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button> <button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
</div> </div>
@@ -4,6 +4,14 @@
<h1 class="mb-4">Exam Collections</h1> <h1 class="mb-4">Exam Collections</h1>
<form method="get" class="mb-3" id="archive-filter-form">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="showArchived" name="show_archived" value="1" onchange="this.form.submit()"
{% if request.GET.show_archived %}checked{% endif %}>
<label class="form-check-label" for="showArchived">Show archived collections</label>
</div>
</form>
{% if object_list %} {% if object_list %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% for collection in object_list %} {% for collection in object_list %}
@@ -30,12 +38,13 @@
{# Use annotated counts provided by the view to avoid per-card COUNT queries #} {# Use annotated counts provided by the view to avoid per-card COUNT queries #}
<span class="badge bg-info text-dark ms-1" title="Total exams">{{ collection.total_count }} exams</span> <span class="badge bg-info text-dark ms-1" title="Total exams">{{ collection.total_count }} exams</span>
{# Per-type badges (only show non-zero) #} {# Per-type badges (only show non-zero) #}
{% with a=collection.anatomy_count l=collection.longs_count p=collection.physics_count r=collection.rapids_count s=collection.sbas_count %} {% with a=collection.anatomy_count l=collection.longs_count p=collection.physics_count r=collection.rapids_count s=collection.sbas_count sh=collection.shorts_count %}
{% if a %}<span class="badge bg-primary ms-1">Anatomy {{ a }}</span>{% endif %} {% if a %}<span class="badge bg-primary ms-1">Anatomy {{ a }}</span>{% endif %}
{% if l %}<span class="badge bg-primary ms-1">Longs {{ l }}</span>{% endif %} {% if l %}<span class="badge bg-primary ms-1">Longs {{ l }}</span>{% endif %}
{% if p %}<span class="badge bg-primary ms-1">Physics {{ p }}</span>{% endif %} {% if p %}<span class="badge bg-primary ms-1">Physics {{ p }}</span>{% endif %}
{% if r %}<span class="badge bg-primary ms-1">Rapids {{ r }}</span>{% endif %} {% if r %}<span class="badge bg-primary ms-1">Rapids {{ r }}</span>{% endif %}
{% if s %}<span class="badge bg-primary ms-1">SBAs {{ s }}</span>{% endif %} {% if s %}<span class="badge bg-primary ms-1">SBAs {{ s }}</span>{% endif %}
{% if sh %}<span class="badge bg-primary ms-1">Shorts {{ sh }}</span>{% endif %}
{% endwith %} {% endwith %}
</div> </div>
@@ -13,6 +13,9 @@
<a href="{% url app_name|add:':exam_overview' pk=exam.pk %}"> <a href="{% url app_name|add:':exam_overview' pk=exam.pk %}">
{% if exam.exam_mode %}Exam:{% else %}Packet:{% endif %} {{ exam }} {% if exam.exam_mode %}Exam:{% else %}Packet:{% endif %} {{ exam }}
</a> </a>
{% if exam.archive %}
<span class="badge bg-warning text-dark ms-2" title="This exam is archived">Archived</span>
{% endif %}
<small class="ms-2 text-muted"> <small class="ms-2 text-muted">
<span class="badge bg-secondary">Questions: {{ exam.get_questions|length }}</span> <span class="badge bg-secondary">Questions: {{ exam.get_questions|length }}</span>
<span class="badge bg-secondary ms-1">Candidates: {{ exam.get_candidate_count }}</span> <span class="badge bg-secondary ms-1">Candidates: {{ exam.get_candidate_count }}</span>
@@ -0,0 +1,11 @@
{% load crispy_forms_tags %}
<form method="post" action="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-post="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML" class="bulk-add-groups-form">
{% csrf_token %}
{{ form|crispy }}
<div class="mt-2">
<button type="button" class="btn btn-primary btn-sm" hx-post="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-include="input[name='csrfmiddlewaretoken'], select[name='cid_user_groups'], select[name='user_user_groups'], select[name='exam_types']" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups</button>
<button type="button" class="btn btn-secondary btn-sm" hx-get="{% url 'generic:examcollection_detail' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Cancel</button>
</div>
</form>
@@ -0,0 +1,11 @@
<div class="bulk-add-result">
{% if error %}
<div class="alert alert-danger">Error: {{ error }}</div>
{% else %}
<p>Added user groups to {{ total_user }} exams.</p>
<p>Added CID groups to {{ total_cid }} exams.</p>
{% endif %}
<div class="mt-2">
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add more</button>
</div>
</div>
@@ -0,0 +1,24 @@
{% if results %}
<ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">
<div class="flex-grow-1">
{{ item.text|escape }}
{% if item.grade %}
<small class="text-muted ms-2">{{ item.grade|escape }}</small>
{% endif %}
</div>
<div>
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">Add</button>
</div>
</li>
{% endfor %}
</ul>
{% if results %}
<div class="mt-2 text-end">
<button type="button" class="btn btn-sm btn-outline-success user-add-all-btn" data-field="{{ field }}">Add all results</button>
</div>
{% endif %}
{% else %}
<div><em>No users found</em></div>
{% endif %}
+7
View File
@@ -58,6 +58,7 @@ urlpatterns = [
name="supervisor-autocomplete", name="supervisor-autocomplete",
), ),
path("user-search/", views.user_search, name="user_search"), path("user-search/", views.user_search, name="user_search"),
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
path("supervisor-search/", views.supervisor_search, name="supervisor_search"), path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
path( path(
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid" "cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
@@ -255,6 +256,7 @@ urlpatterns = [
name="examcollection_delete", name="examcollection_delete",
), ),
path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"), path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"),
path("collection/<int:collection_id>/bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"),
path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"), path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"),
path( path(
"share_with_supervisor_toggle/<int:pk>/", "share_with_supervisor_toggle/<int:pk>/",
@@ -338,6 +340,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
generic_exam_view.exam_bulk_update, generic_exam_view.exam_bulk_update,
name="exam_bulk_update", name="exam_bulk_update",
), ),
path(
"exam/bulk_archive/",
generic_exam_view.exam_bulk_archive,
name="exam_bulk_archive",
),
path( path(
"exam/bulk_delete/", "exam/bulk_delete/",
generic_exam_view.exam_bulk_delete, generic_exam_view.exam_bulk_delete,
+295 -12
View File
@@ -86,6 +86,7 @@ from .forms import (
UserUserForm, UserUserForm,
UserUserGroupForm, UserUserGroupForm,
UsersAutocompleteForm, # Added import UsersAutocompleteForm, # Added import
ExamCollectionBulkAddGroupsForm,
) )
from .models import ( from .models import (
@@ -144,6 +145,7 @@ from shorts.models import Question as ShortsQuestion, ExamQuestionDetail as Shor
from django.db.models import Case, When from django.db.models import Case, When
from django.conf import settings from django.conf import settings
from django.db import transaction
from datetime import datetime from datetime import datetime
from django.utils import timezone from django.utils import timezone
@@ -378,6 +380,72 @@ def create_examination(request):
) )
@login_required
@user_passes_test(lambda u: u.is_superuser or u.groups.filter(name="cid_user_manager").exists())
def exam_collection_bulk_add_groups(request, collection_id):
"""HTMX endpoint: show form (GET) and process bulk-adding of CID/User groups (POST).
Renders small partials suitable for HTMX swaps.
"""
collection = get_object_or_404(ExamCollection, pk=collection_id)
logger.debug(f"exam_collection_bulk_add_groups called with method={request.method} by user={request.user.username}")
logger.debug(request)
if request.method == "GET":
logger.debug(f"exam_collection_bulk_add_groups GET request by user={request.user.username}")
form = ExamCollectionBulkAddGroupsForm(user=request.user)
return render(request, "generic/partials/examcollection_bulk_add_groups_form.html", {"form": form, "collection": collection})
# POST
form = ExamCollectionBulkAddGroupsForm(request.POST, user=request.user)
if not form.is_valid():
logger.debug(f"exam_collection_bulk_add_groups form invalid: {form.errors}")
return render(request, "generic/partials/examcollection_bulk_add_groups_form.html", {"form": form, "collection": collection})
cid_groups = form.cleaned_data.get("cid_user_groups")
user_groups = form.cleaned_data.get("user_user_groups")
exam_types = form.cleaned_data.get("exam_types")
# If 'all' is selected (or no selection), treat as None -> all exams
if exam_types:
if "all" in exam_types:
exam_types = None
else:
exam_types = None
# Debug: log what was submitted so we can trace why groups may not be applied
try:
cid_pks = [g.pk for g in cid_groups] if cid_groups is not None else []
except Exception:
cid_pks = []
try:
user_pks = [g.pk for g in user_groups] if user_groups is not None else []
except Exception:
user_pks = []
logger.debug(
"ExamCollection bulk-add POST: collection=%s cid_groups=%s user_groups=%s exam_types=%s",
collection.pk,
cid_pks,
user_pks,
exam_types,
)
total_user = 0
total_cid = 0
try:
with transaction.atomic():
if user_groups:
total_user = collection.bulk_add_user_user_groups(user_groups, exam_types=exam_types)
if cid_groups:
total_cid = collection.bulk_add_cid_user_groups(cid_groups, exam_types=exam_types)
except Exception as e:
# render an error fragment
return render(request, "generic/partials/examcollection_bulk_add_groups_result.html", {"error": str(e), "collection": collection})
return render(request, "generic/partials/examcollection_bulk_add_groups_result.html", {"total_user": total_user, "total_cid": total_cid, "collection": collection})
@login_required @login_required
@user_passes_test(lambda u: u.is_superuser) @user_passes_test(lambda u: u.is_superuser)
def examination_detail(request, pk): def examination_detail(request, pk):
@@ -1117,6 +1185,58 @@ class ExamViews(View, LoginRequiredMixin):
return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name}) return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
@method_decorator(login_required)
def exam_bulk_archive(self, request):
"""HTMX endpoint to bulk-archive or unarchive selected exams.
Expects POST with 'exam_ids' (multiple) and 'archive' ('true' or 'false').
Returns the updated exam list partial.
"""
if request.method != "POST":
return HttpResponse(status=405)
exam_ids = request.POST.getlist("exam_ids")
archive_val = request.POST.get("archive", "true")
set_archive = True if archive_val == "true" else False
if (
request.user.is_superuser
or (
self.app_name == "rapids"
and request.user.groups.filter(name="rapid_checker").exists()
)
or (
self.app_name == "anatomy"
and request.user.groups.filter(name="anatomy_checker").exists()
)
or (
self.app_name == "longs"
and request.user.groups.filter(name="long_checker").exists()
)
):
exams_qs = self.Exam.objects.all()
filter = self.ExtraExamFilter(request.GET, queryset=exams_qs)
else:
exams_qs = self.Exam.objects.filter(author__id=request.user.id) | self.Exam.objects.filter(open_access=True)
filter = self.BasicExamFilter(request.GET, queryset=exams_qs)
for eid in exam_ids:
try:
exam = self.Exam.objects.get(pk=eid)
except self.Exam.DoesNotExist:
continue
if not self.check_user_edit_access(request.user, exam_id=exam.pk):
continue
try:
exam.archive = set_archive
exam.save()
except Exception:
continue
return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
@method_decorator(login_required) @method_decorator(login_required)
def exam_toggle_results_published(self, request, pk): def exam_toggle_results_published(self, request, pk):
if request.method == "POST": if request.method == "POST":
@@ -5290,21 +5410,70 @@ class ExamCollectionList(ListView):
def get_queryset(self): def get_queryset(self):
"""Annotate exam counts per collection and prefetch authors to avoid N+1 queries in templates.""" """Annotate exam counts per collection and prefetch authors to avoid N+1 queries in templates."""
# allow toggling whether archived collections are included via ?show_archived=1
show_archived = self.request.GET.get("show_archived")
base_qs = ExamCollection.objects.all()
if not show_archived:
base_qs = base_qs.filter(archive=False)
# Build annotation kwargs dynamically so counts respect the show_archived toggle.
# Count exams regardless of exam_mode; include archived depending on the toggle.
if show_archived:
# include archived exams in counts (count all exams for each app)
counts = {
"anatomy_count": Count("anatomy_exams", distinct=True),
"longs_count": Count("longs_exams", distinct=True),
"physics_count": Count("physics_exams", distinct=True),
"rapids_count": Count("rapids_exams", distinct=True),
"sbas_count": Count("sbas_exams", distinct=True),
"shorts_count": Count("shorts_exams", distinct=True),
}
else:
# only count non-archived exams
counts = {
"anatomy_count": Count(
"anatomy_exams",
filter=Q(anatomy_exams__archive=False),
distinct=True,
),
"longs_count": Count(
"longs_exams",
filter=Q(longs_exams__archive=False),
distinct=True,
),
"physics_count": Count(
"physics_exams",
filter=Q(physics_exams__archive=False),
distinct=True,
),
"rapids_count": Count(
"rapids_exams",
filter=Q(rapids_exams__archive=False),
distinct=True,
),
"sbas_count": Count(
"sbas_exams",
filter=Q(sbas_exams__archive=False),
distinct=True,
),
"shorts_count": Count(
"shorts_exams",
filter=Q(shorts_exams__archive=False),
distinct=True,
),
}
qs = ( qs = (
ExamCollection.objects.all() base_qs
.annotate( .annotate(**counts)
anatomy_count=Count("anatomy_exams", filter=Q(anatomy_exams__archive=False)),
longs_count=Count("longs_exams", filter=Q(longs_exams__archive=False)),
physics_count=Count("physics_exams", filter=Q(physics_exams__archive=False)),
rapids_count=Count("rapids_exams", filter=Q(rapids_exams__archive=False)),
sbas_count=Count("sbas_exams", filter=Q(sbas_exams__archive=False)),
)
.annotate( .annotate(
total_count=F("anatomy_count") total_count=F("anatomy_count")
+F("longs_count") + F("longs_count")
+F("physics_count") + F("physics_count")
+F("rapids_count") + F("rapids_count")
+F("sbas_count") + F("sbas_count")
+ F("shorts_count")
) )
.prefetch_related("author") .prefetch_related("author")
.order_by("name") .order_by("name")
@@ -5762,6 +5931,120 @@ def user_search(request):
return render(request, "generic/partials/user_search_results.html", {"results": results, "row": row, "q": q}) return render(request, "generic/partials/user_search_results.html", {"results": results, "row": row, "q": q})
@login_required
def user_search_widget(request):
"""HTMX/JS endpoint to search users for generic selectors.
Accepts GET params:
- q: query string
- field: the form field name to return with the results (so the
template can call addUserToField(field, id, text)).
Returns a small HTML fragment listing up to 10 matching users. Each
result will call `addUserToField(field, id, text)` when clicked.
"""
import re
q_raw = (request.GET.get("q") or "").strip()
field = request.GET.get("field") or request.GET.get("row")
if not q_raw:
return HttpResponse("")
# Support wildcard '*' (return many results) and field-scoped queries like 'name:smith' or 'email:foo'
q = q_raw
# If user explicitly asks for wildcard, return an unfiltered queryset (limited)
if q in ("*", "%"):
users_qs = User.objects.all()
else:
# field-specific search syntax: field:term
m = re.match(r"^(?P<field>\w+):(?P<term>.+)$", q)
if m:
f = m.group("field").lower()
term = m.group("term").strip()
if term in ("*", "%"):
users_qs = User.objects.all()
else:
if f in ("name", "fullname", "full_name"):
users_qs = User.objects.filter(
Q(first_name__icontains=term) | Q(last_name__icontains=term)
)
elif f in ("first", "first_name"):
users_qs = User.objects.filter(first_name__icontains=term)
elif f in ("last", "last_name"):
users_qs = User.objects.filter(last_name__icontains=term)
elif f == "email":
users_qs = User.objects.filter(email__icontains=term)
elif f == "username":
users_qs = User.objects.filter(username__icontains=term)
elif f in ("id", "pk"):
try:
users_qs = User.objects.filter(pk=int(term))
except Exception:
users_qs = User.objects.none()
elif f == "grade":
# allow grade name or id
try:
gid = int(term)
users_qs = User.objects.filter(userprofile__grade_id=gid)
except Exception:
users_qs = User.objects.filter(userprofile__grade__name__icontains=term)
else:
# unknown field: fallback to general search
users_qs = User.objects.filter(
Q(first_name__icontains=term)
| Q(last_name__icontains=term)
| Q(email__icontains=term)
| Q(username__icontains=term)
)
else:
# default substring search (icontains)
users_qs = User.objects.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(username__icontains=q)
)
# optional grade filter (UserProfile.grade FK) still applies and will narrow results
grade = request.GET.get("grade")
if grade:
try:
users_qs = users_qs.filter(userprofile__grade_id=int(grade))
except Exception:
pass
# Limit results to avoid returning too many rows; wildcard returns up to 50
limit = 50 if q in ("*", "%") or q_raw in ("*", "%") else 10
users_qs = users_qs.order_by("last_name", "first_name")[:limit]
results = []
for u in users_qs:
grade = ""
try:
up = getattr(u, "userprofile", None)
if up and getattr(up, "grade", None):
# grade may be a FK object or a simple string depending on model
g = up.grade
grade = getattr(g, "name", str(g)) if g is not None else ""
except Exception:
grade = ""
results.append(
{
"id": u.pk,
"text": f"{u.get_full_name() or u.username} - {u.email or ''}",
"grade": grade,
}
)
return render(
request,
"generic/partials/user_search_widget_results.html",
{"results": results, "field": field, "q": q_raw},
)
@login_required @login_required
def supervisor_search(request): def supervisor_search(request):
"""HTMX endpoint to search supervisors for the trainees bulk-update UI. """HTMX endpoint to search supervisors for the trainees bulk-update UI.