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
+39 -4
View File
@@ -593,14 +593,29 @@ 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">
<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" /> <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 id="{results_id}" class="mt-2"></div>
</div> </div>
@@ -617,14 +632,17 @@ class UserUserGroupForm(ModelForm):
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) {{
const grade = gradeSelect ? gradeSelect.value : '';
if (!q || q.trim().length === 0) {{ if (!q || q.trim().length === 0) {{
results.innerHTML = ''; results.innerHTML = '';
return; return;
}} }}
fetch('{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q)) const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
fetch(url)
.then(r => r.text()) .then(r => r.text())
.then(html => {{ results.innerHTML = html; }}); .then(html => {{ results.innerHTML = html; }});
}} }}
@@ -634,6 +652,12 @@ class UserUserGroupForm(ModelForm):
timeout = setTimeout(() => fetchResults(search.value), 300); 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);
}});
window.addUserToField = function(fieldName, id, text) {{ window.addUserToField = function(fieldName, id, text) {{
const sel = document.getElementById('id_' + fieldName); const sel = document.getElementById('id_' + fieldName);
if (!sel) return; if (!sel) return;
@@ -660,10 +684,21 @@ class UserUserGroupForm(ModelForm):
const li = document.getElementById('selected_user_' + fieldName + '_' + id); const li = document.getElementById('selected_user_' + fieldName + '_' + id);
if (li && li.parentNode) li.parentNode.removeChild(li); 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> </script>
</div> </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]