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") 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""" html = f"""
<div class="user-search-widget"> <div class="user-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none"> <select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html} {options_html}
</select> </select>
<div class="mb-2"> <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 class="d-flex gap-2">
<div id="{results_id}" class="mt-2"></div> <input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
</div> <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> <div>
<label class="form-label small">Selected users</label> <label class="form-label small">Selected users</label>
<ul id="{selected_list_id}" class="list-group list-group-flush"> <ul id="{selected_list_id}" class="list-group list-group-flush">
{selected_html} {selected_html}
</ul> </ul>
</div> </div>
<script> <script>
(function() {{ (function() {{
const search = document.getElementById('{search_id}'); const search = document.getElementById('{search_id}');
const results = document.getElementById('{results_id}'); const results = document.getElementById('{results_id}');
const select = document.getElementById('{field_id}'); const select = document.getElementById('{field_id}');
const selectedList = document.getElementById('{selected_list_id}'); const selectedList = document.getElementById('{selected_list_id}');
const gradeSelect = document.getElementById('grade_filter_{self.name}');
let timeout = null; let timeout = null;
function fetchResults(q) {{ function fetchResults(q) {{
if (!q || q.trim().length === 0) {{ const grade = gradeSelect ? gradeSelect.value : '';
results.innerHTML = ''; if (!q || q.trim().length === 0) {{
return; results.innerHTML = '';
}} return;
fetch('{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q)) }}
.then(r => r.text()) const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
.then(html => {{ results.innerHTML = html; }}); fetch(url)
}} .then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{ search.addEventListener('keyup', function(e) {{
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300); timeout = setTimeout(() => fetchResults(search.value), 300);
}}); }});
window.addUserToField = function(fieldName, id, text) {{ gradeSelect && gradeSelect.addEventListener('change', function(e) {{
const sel = document.getElementById('id_' + fieldName); // re-run search with same query when grade changes
if (!sel) return; clearTimeout(timeout);
// prevent duplicate timeout = setTimeout(() => fetchResults(search.value), 50);
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 window.addUserToField = function(fieldName, id, text) {{
const li = document.createElement('li'); const sel = document.getElementById('id_' + fieldName);
li.className = 'list-group-item d-flex justify-content-between align-items-center'; if (!sel) return;
li.id = 'selected_user_' + fieldName + '_' + id; // prevent duplicate
const span = document.createElement('span'); span.innerHTML = text; for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove'; const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }}); sel.appendChild(opt);
li.appendChild(span); li.appendChild(btn);
selectedList.appendChild(li);
}}
window.removeUserFromField = function(fieldName, id) {{ // add to visible list
const sel = document.getElementById('id_' + fieldName); const li = document.createElement('li');
if (!sel) return; li.className = 'list-group-item d-flex justify-content-between align-items-center';
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }} li.id = 'selected_user_' + fieldName + '_' + id;
const li = document.getElementById('selected_user_' + fieldName + '_' + id); const span = document.createElement('span'); span.innerHTML = text;
if (li && li.parentNode) li.parentNode.removeChild(li); 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);
</script> selectedList.appendChild(li);
</div> }}
"""
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) return mark_safe(html)
+37
View File
@@ -1967,6 +1967,13 @@ class ExamCollection(models.Model, AuthorMixin):
if UserUserGroup is None: if UserUserGroup is None:
raise RuntimeError("UserUserGroup model not available") 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 # Normalize groups to a list of model instances
if groups is None: if groups is None:
return 0 return 0
@@ -1994,9 +2001,21 @@ class ExamCollection(models.Model, AuthorMixin):
for exam in exams: for exam in exams:
# Each exam model exposes a `user_user_groups` ManyToManyField # Each exam model exposes a `user_user_groups` ManyToManyField
try: try:
before_pks = set(exam.user_user_groups.values_list('pk', flat=True))
exam.user_user_groups.add(*groups_qs) exam.user_user_groups.add(*groups_qs)
# Ensure any downstream signals / caches see the change # Ensure any downstream signals / caches see the change
exam.save() 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 updated += 1
except Exception as e: except Exception as e:
logger.exception("Failed to add user_user_groups to exam %s: %s", getattr(exam, 'pk', exam), 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: if CidUserGroup is None:
raise RuntimeError("CidUserGroup model not available") 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: if groups is None:
return 0 return 0
@@ -2033,8 +2059,19 @@ class ExamCollection(models.Model, AuthorMixin):
for exam in exams: for exam in exams:
# Each exam model exposes a `cid_user_groups` ManyToManyField # Each exam model exposes a `cid_user_groups` ManyToManyField
try: try:
before_pks = set(exam.cid_user_groups.values_list('pk', flat=True))
exam.cid_user_groups.add(*groups_qs) exam.cid_user_groups.add(*groups_qs)
exam.save() 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 updated += 1
except Exception as e: except Exception as e:
logger.exception("Failed to add cid_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e) logger.exception("Failed to add cid_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e)
@@ -1,7 +1,7 @@
{% if results %} {% if results %}
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
{% for item in results %} {% 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 class="flex-grow-1">{{ item.text|escape }}</div>
<div> <div>
<button type="button" class="btn btn-sm btn-primary" onclick="addUserToField('{{ field }}', {{ item.id }}, '{{ item.text|escapejs }}')">Add</button> <button type="button" class="btn btn-sm btn-primary" onclick="addUserToField('{{ field }}', {{ item.id }}, '{{ item.text|escapejs }}')">Add</button>
@@ -9,6 +9,11 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </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 %} {% else %}
<div><em>No users found</em></div> <div><em>No users found</em></div>
{% endif %} {% endif %}
+28 -1
View File
@@ -408,6 +408,23 @@ def exam_collection_bulk_add_groups(request, collection_id):
else: else:
exam_types = None 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_user = 0
total_cid = 0 total_cid = 0
try: try:
@@ -5930,7 +5947,17 @@ def user_search_widget(request):
| Q(last_name__icontains=q) | Q(last_name__icontains=q)
| Q(email__icontains=q) | Q(email__icontains=q)
| Q(username__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] results = [{"id": u.pk, "text": f"{u.get_full_name() or u.username} - {u.email or ''}"} for u in users_qs]