Compare commits
18
Commits
87dde70fd3
...
7fa08037f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa08037f0 | ||
|
|
f786e88de9 | ||
|
|
9441541feb | ||
|
|
5cca1b079a | ||
|
|
2e551d411a | ||
|
|
d8055a509c | ||
|
|
208252a858 | ||
|
|
ea9dca5804 | ||
|
|
da04138861 | ||
|
|
ec0245fe7c | ||
|
|
c39d30c746 | ||
|
|
d781508364 | ||
|
|
06ea87be14 | ||
|
|
ce1b439681 | ||
|
|
c8375a28fa | ||
|
|
6be39ab69d | ||
|
|
e9d0e402be | ||
|
|
dc23dffaf9 |
@@ -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">
|
||||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
<h2 class="mb-0">Question Answers</h2>
|
||||||
<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>
|
<small class="text-muted">Manage answer keys for auto-marking</small>
|
||||||
</details>
|
</div>
|
||||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
<div class="card-body">
|
||||||
{% csrf_token %}
|
<details class="help-text mb-3">
|
||||||
<h3>Answers:</h3>
|
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||||
<div id="form_set">
|
<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>
|
||||||
{% for form in answer_formset %}
|
</details>
|
||||||
<ul class="no-error answer-formset">
|
|
||||||
{{form.non_field_errors}}
|
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||||
{{form.errors}}
|
{% csrf_token %}
|
||||||
{{ form.as_ul }}
|
|
||||||
</ul>
|
<h3 class="h5 mt-2">Answers</h3>
|
||||||
{% endfor %}
|
<div id="form_set" class="mt-3">
|
||||||
|
{% for form in 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 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ answer_formset.management_form }}
|
||||||
|
|
||||||
|
<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 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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'>
|
|
||||||
{{ answer_formset.empty_form.as_ul }}
|
|
||||||
</ul>
|
|
||||||
</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
@@ -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"]
|
||||||
|
|||||||
@@ -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):
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="exam-list-wrapper">
|
<div id="exam-list-wrapper">
|
||||||
<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,9 +86,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
<div id="add-user">
|
<div id="bulk-add-groups">
|
||||||
<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 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{% include 'exam_overview_js.html' %}
|
{% include 'exam_overview_js.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user