Add grade filter to user search widget and enhance logging for bulk add operations

This commit is contained in:
Ross
2025-11-12 21:48:50 +00:00
parent ea9dca5804
commit 208252a858
4 changed files with 169 additions and 65 deletions
+97 -62
View File
@@ -593,77 +593,112 @@ class UserUserGroupForm(ModelForm):
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="user-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html}
</select>
<div class="mb-2">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
<div id="{results_id}" class="mt-2"></div>
</div>
<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>
<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}');
<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) {{
if (!q || q.trim().length === 0) {{
results.innerHTML = '';
return;
}}
fetch('{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q))
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
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);
}});
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
window.addUserToField = function(fieldName, id, text) {{
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);
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
// re-run search with same query when grade changes
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 50);
}});
// add to visible list
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;
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.addUserToField = function(fieldName, id, text) {{
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);
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);
}}
}})();
</script>
</div>
"""
// add to visible list
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;
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();
addUserToField(fieldName, id, text);
}});
}}
}})();
</script>
</div>
"""
return mark_safe(html)
+37
View File
@@ -1967,6 +1967,13 @@ class ExamCollection(models.Model, AuthorMixin):
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
@@ -1994,9 +2001,21 @@ class ExamCollection(models.Model, AuthorMixin):
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)
@@ -2011,6 +2030,13 @@ class ExamCollection(models.Model, AuthorMixin):
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
@@ -2033,8 +2059,19 @@ class ExamCollection(models.Model, AuthorMixin):
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)
@@ -1,7 +1,7 @@
{% if results %}
<ul class="list-unstyled mb-0">
<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">
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}">
<div class="flex-grow-1">{{ item.text|escape }}</div>
<div>
<button type="button" class="btn btn-sm btn-primary" onclick="addUserToField('{{ field }}', {{ item.id }}, '{{ item.text|escapejs }}')">Add</button>
@@ -9,6 +9,11 @@
</li>
{% endfor %}
</ul>
{% if results %}
<div class="mt-2 text-end">
<button type="button" class="btn btn-sm btn-outline-success" onclick="addAllFromResults('{{ field }}')">Add all results</button>
</div>
{% endif %}
{% else %}
<div><em>No users found</em></div>
{% endif %}
+28 -1
View File
@@ -408,6 +408,23 @@ def exam_collection_bulk_add_groups(request, collection_id):
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:
@@ -5930,7 +5947,17 @@ def user_search_widget(request):
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(username__icontains=q)
).order_by("last_name", "first_name")[:10]
)
# optional grade filter (UserProfile.grade FK)
grade = request.GET.get('grade')
if grade:
try:
users_qs = users_qs.filter(userprofile__grade_id=int(grade))
except Exception:
pass
users_qs = users_qs.order_by("last_name", "first_name")[:10]
results = [{"id": u.pk, "text": f"{u.get_full_name() or u.username} - {u.email or ''}"} for u in users_qs]