Add grade filter to user search widget and enhance logging for bulk add operations
This commit is contained in:
+97
-62
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user