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 %}
{% include 'anatomy/question_link_header.html' %}
{% endif %}
<h2>Question Answers</h2>
<details class="help-text">
<div class="card mb-4">
<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>
<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>
</details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
<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>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% 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 %}
<ul class="no-error answer-formset">
<div class="answer-card mb-3 p-3 border rounded bg-body-secondary">
{{form.non_field_errors}}
{{form.errors}}
<div class="answer-fields">
{{ form.as_ul }}
</ul>
</div>
</div>
{% endfor %}
</div>
{{ answer_formset.management_form }}
<input type="button" value="Add More Answers" id="add_more">
<p>
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
</p>
</form>
<div id="empty_form" style="display:none">
<ul class='no_error answer-formset'>
<div class="d-flex gap-2 align-items-center mt-3">
<button type="button" id="add_more" class="btn btn-outline-secondary btn-sm">Add another answer</button>
<input type="submit" class="submit-button btn btn-primary btn-sm ms-auto" value="Save answers" name="submit">
</div>
</form>
<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 }}
</ul>
</div>
</div>
</div>
</div>
{% 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>
<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 class="text-end">
<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,
ModelForm,
ModelMultipleChoiceField,
MultipleChoiceField,
ModelChoiceField,
ChoiceField,
CharField,
@@ -77,6 +78,9 @@ from crispy_forms.bootstrap import Accordion, AccordionGroup
from dal import autocomplete
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):
@@ -291,6 +295,70 @@ class ExamGroupsFormMixin(ModelForm):
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):
def __init__(self, *args, **kwargs):
# Where is this coming from?
@@ -489,12 +557,257 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
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(
required=False,
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:
model = UserUserGroup
fields = ["name", "archive", "open_access", "users"]
+132
View File
@@ -1946,6 +1946,138 @@ class ExamCollection(models.Model, AuthorMixin):
exam.add_markers(users)
#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):
#
+112
View File
@@ -5,8 +5,12 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-toggle-btn">Bulk edit</button>
</div>
<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>
<button type="button" class="btn btn-sm btn-outline-primary" id="select-all-btn">Select all</button>
@@ -31,6 +35,16 @@
{% csrf_token %}
<button class="btn btn-sm btn-danger" type="submit" onclick="return confirm('Delete selected exams? This action is irreversible.')">Delete selected</button>
</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 -->
<span id="bulk-action-indicator" class="ms-2" aria-hidden="true">
@@ -67,6 +81,8 @@
/* Defensive: hide bulk-controls unless bulk-mode is enabled */
#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 { display: none; }
#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.
document.addEventListener('click', function(e){
if(!examListWrapper.classList.contains('bulk-mode')) return;
@@ -123,5 +191,49 @@
});
});
</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 %}
@@ -86,6 +86,10 @@
{% endif %}
<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">
<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>
@@ -4,6 +4,14 @@
<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 %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% for collection in object_list %}
@@ -30,12 +38,13 @@
{# 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>
{# 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 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 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 sh %}<span class="badge bg-primary ms-1">Shorts {{ sh }}</span>{% endif %}
{% endwith %}
</div>
@@ -13,6 +13,9 @@
<a href="{% url app_name|add:':exam_overview' pk=exam.pk %}">
{% if exam.exam_mode %}Exam:{% else %}Packet:{% endif %} {{ exam }}
</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">
<span class="badge bg-secondary">Questions: {{ exam.get_questions|length }}</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",
),
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(
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
@@ -255,6 +256,7 @@ urlpatterns = [
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>/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(
"share_with_supervisor_toggle/<int:pk>/",
@@ -338,6 +340,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
generic_exam_view.exam_bulk_update,
name="exam_bulk_update",
),
path(
"exam/bulk_archive/",
generic_exam_view.exam_bulk_archive,
name="exam_bulk_archive",
),
path(
"exam/bulk_delete/",
generic_exam_view.exam_bulk_delete,
+295 -12
View File
@@ -86,6 +86,7 @@ from .forms import (
UserUserForm,
UserUserGroupForm,
UsersAutocompleteForm, # Added import
ExamCollectionBulkAddGroupsForm,
)
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.conf import settings
from django.db import transaction
from datetime import datetime
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
@user_passes_test(lambda u: u.is_superuser)
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})
@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)
def exam_toggle_results_published(self, request, pk):
if request.method == "POST":
@@ -5290,21 +5410,70 @@ class ExamCollectionList(ListView):
def get_queryset(self):
"""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 = (
ExamCollection.objects.all()
.annotate(
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)),
)
base_qs
.annotate(**counts)
.annotate(
total_count=F("anatomy_count")
+F("longs_count")
+F("physics_count")
+F("rapids_count")
+F("sbas_count")
+ F("longs_count")
+ F("physics_count")
+ F("rapids_count")
+ F("sbas_count")
+ F("shorts_count")
)
.prefetch_related("author")
.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})
@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
def supervisor_search(request):
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.