Implement bulk user management for exams: add user bulk edit endpoint, update templates for user list rendering, and enhance toggle functionality with HTMX support
This commit is contained in:
@@ -1,103 +1,84 @@
|
||||
{% include "generic/exam_candidate_headers.html" %}
|
||||
<h3>Users</h3>
|
||||
{% if groups %}
|
||||
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
|
||||
{% else %}
|
||||
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.
|
||||
{% endif %}
|
||||
<ol>
|
||||
{% for user in current_user_users %}
|
||||
<div class="card mb-2 bg-dark text-light border-0">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-0">Users</h5>
|
||||
{% if groups %}
|
||||
<div class="small text-muted mt-1">Users from the following groups are available:
|
||||
{% for group in groups %}
|
||||
<a href='{{group.get_absolute_url}}' class="badge bg-light text-dark border ms-1">{{group}}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{% if current_user_users or available_user_users %}
|
||||
<button class="btn btn-sm btn-outline-light toggle-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Toggle all</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Add all</button>
|
||||
<button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Remove all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<li class="current">{{user.username}} /
|
||||
Email: {{user.email}}
|
||||
{% if user.userprofile.grade %}
|
||||
[{{user.userprofile.grade}}]
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
{% for user in available_user_users %}
|
||||
|
||||
<li>{{user.username}} /
|
||||
Email: {{user.email}}
|
||||
{% if user.userprofile.grade %}
|
||||
[{{user.userprofile.grade}}]
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
|
||||
<p>
|
||||
{% if current_user_users or available_user_users %}
|
||||
<button class="toggle-all-btn">Toggle all</button>
|
||||
{% endif %}
|
||||
<button class="add-all-btn">Add all</button>
|
||||
<button class="remove-all-btn">Remove all</button>
|
||||
|
||||
</p>
|
||||
{% include 'generic/partials/user_user_list.html' with exam=exam current_user_users=current_user_users available_user_users=available_user_users %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
$(".remove-all-btn").click((el) => {
|
||||
$(".current .toggle-btn").click();
|
||||
});
|
||||
$(".add-all-btn").click((el) => {
|
||||
$("li:not(.current) .toggle-btn").click();
|
||||
});
|
||||
$(".toggle-all-btn").click((el) => {
|
||||
$(".toggle-btn").click();
|
||||
});
|
||||
$(".toggle-btn").click((el) => {
|
||||
parent = $(el.target).parent()
|
||||
$.ajax({
|
||||
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{csrf_token}}",
|
||||
edit_user_user: el.target.dataset.pk,
|
||||
add: !parent.hasClass("current"),
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
|
||||
.done(function (data) {
|
||||
console.log(data);
|
||||
// Mirror the HTMX-enabled behavior used for CID users.
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
// Bulk form for users
|
||||
const bulkForm = document.createElement('form');
|
||||
bulkForm.id = 'bulk-user-form';
|
||||
bulkForm.className = 'd-none';
|
||||
bulkForm.setAttribute('hx-target', '#user-users-list');
|
||||
bulkForm.setAttribute('hx-swap', 'outerHTML');
|
||||
const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input_user'; bulkForm.appendChild(bulkInput);
|
||||
const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input_user'; bulkForm.appendChild(bulkAddInput);
|
||||
const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
|
||||
document.body.appendChild(bulkForm);
|
||||
|
||||
if (data.status == "success") {
|
||||
if (data.added) {
|
||||
parent.addClass("current");
|
||||
toastr.info(`User ${el.target.dataset.user} added to exam`)
|
||||
} else {
|
||||
parent.removeClass("current");
|
||||
toastr.info(`User ${el.target.dataset.user} removed from exam`)
|
||||
function setLoading(btn, loading){ if(!btn) return; try{ if(loading){ btn.dataset._origDisabled = btn.disabled ? '1':'0'; btn.disabled = true; const spinner = document.createElement('span'); spinner.className='btn-spinner spinner-border spinner-border-sm ms-2'; spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true'); btn.appendChild(spinner);} else { if(typeof btn.dataset._origDisabled !== 'undefined'){ btn.disabled = btn.dataset._origDisabled === '1'; delete btn.dataset._origDisabled;} else { btn.disabled = false;} const s = btn.querySelector('.btn-spinner'); if(s) s.remove(); } }catch(e){} }
|
||||
|
||||
}
|
||||
}
|
||||
})
|
||||
.always(function () {
|
||||
})
|
||||
function submitBulk(url, pks, add, triggeringButton){
|
||||
if (window.htmx && typeof htmx.submit === 'function'){
|
||||
if(triggeringButton) setLoading(triggeringButton, true);
|
||||
bulkForm.setAttribute('hx-post', url);
|
||||
bulkInput.value = JSON.stringify(pks);
|
||||
if (add === null) bulkAddInput.disabled = true; else { bulkAddInput.disabled = false; bulkAddInput.value = add ? 'true' : 'false'; }
|
||||
htmx.submit(bulkForm); return;
|
||||
}
|
||||
if(triggeringButton) setLoading(triggeringButton, true);
|
||||
const fd = new FormData(); fd.append('bulk_pks', JSON.stringify(pks)); if (add !== null) fd.append('add', add ? 'true' : 'false'); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value; const headers = { 'X-CSRFToken': csrf };
|
||||
fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' })
|
||||
.then(resp => resp.text().then(text => ({ resp, text })))
|
||||
.then(({ resp, text }) => { try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newList = doc.querySelector('#user-users-list'); if (newList){ const old = document.querySelector('#user-users-list'); old.replaceWith(newList); } }catch(e){ console.error('bulk swap failed', e);} const trigger = resp.headers.get('HX-Trigger'); if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} } })
|
||||
.catch(err => console.error('bulk request failed', err))
|
||||
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
|
||||
}
|
||||
|
||||
});
|
||||
const defaultBulkUrl = "{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}";
|
||||
document.querySelectorAll('.add-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ const url = btn.dataset.bulkUrl || defaultBulkUrl; const items = document.querySelectorAll('#user-users-list li:not(.current)'); const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number); if (!pks.length) return; submitBulk(url, pks, true, btn); }); });
|
||||
document.querySelectorAll('.remove-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ if(!confirm('Remove all selected users from the exam?')) return; const url = btn.dataset.bulkUrl || defaultBulkUrl; const items = document.querySelectorAll('#user-users-list li.current'); const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number); if (!pks.length) return; submitBulk(url, pks, false, btn); }); });
|
||||
|
||||
})
|
||||
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl || defaultBulkUrl; const items = Array.from(document.querySelectorAll('#user-users-list li')).map(function(li){ return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') }; }); if (!items.length) return; submitBulk(url, items, null, btn); }); });
|
||||
|
||||
// Listen for single-item toggle events to update counts and show toasts
|
||||
document.body.addEventListener('user_toggled', function(evt){ try { const detail = evt.detail || {}; const added = detail.added; if (window.toastr) { if (added) toastr.info('User added to exam'); else toastr.info('User removed from exam'); } const addedEl = document.getElementById('user-count-added'); const availEl = document.getElementById('user-count-available'); if (addedEl && availEl){ let addedCount = parseInt(addedEl.textContent || '0') || 0; let availCount = parseInt(availEl.textContent || '0') || 0; if (added){ addedCount += 1; availCount = Math.max(0, availCount - 1); } else { addedCount = Math.max(0, addedCount - 1); availCount += 1; } addedEl.textContent = String(addedCount); availEl.textContent = String(availCount); } } catch(e){} });
|
||||
|
||||
// Delegated click handler for per-item toggle buttons (HTMX preferred)
|
||||
document.body.addEventListener('click', function(e){ const btn = e.target.closest && e.target.closest('.toggle-btn'); if(!btn) return; const form = btn.closest('form'); if(!form) return; e.preventDefault(); setLoading(btn, true); if(window.htmx && typeof htmx.submit === 'function'){ htmx.submit(form); return; } (async function(){ try{ const url = form.action || form.getAttribute('hx-post'); const fd = new FormData(form); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value; const headers = { 'HX-Request': 'true' }; if(csrf) headers['X-CSRFToken'] = csrf; const resp = await fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' }); const text = await resp.text(); try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newLi = doc.querySelector('li'); const oldLi = form.closest('li'); if(newLi && oldLi){ oldLi.replaceWith(newLi); } const trigger = resp.headers.get('HX-Trigger'); if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} } }catch(err){ console.error('toggle parse/swap failed', err); } }catch(err){ console.error('toggle request failed', err); } finally{ setLoading(btn, false); } })(); });
|
||||
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.current {
|
||||
color: lightgray;
|
||||
}
|
||||
.current::after {
|
||||
content: "[ADDED TO EXAM]"
|
||||
}
|
||||
/* removed decorative pseudo-element that showed [ADDED TO EXAM] */
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {% if current %}current{% endif %} py-1 px-2 bg-transparent border-secondary" data-pk="{{ user.pk }}" data-username="{{ user.username }}" data-name="{{ user.get_full_name|default:user.username|escape }}" data-email="{{ user.email|escape }}">
|
||||
<div class="me-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="fw-bold small mb-0">{{ user.username }}</div>
|
||||
<span class="badge {% if current %}bg-success{% else %}bg-secondary text-dark{% endif %} ms-2 small">{% if current %}Added{% else %}Not added{% endif %}</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{ user.get_full_name|default:user.username }} · {{ user.email }}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center ms-2">
|
||||
{% comment %} show any exam user-groups this user belongs to as badges {% endcomment %}
|
||||
<div class="me-2">
|
||||
{% for g in exam.user_user_groups.all %}
|
||||
{% if user in g.users.all %}
|
||||
<span class="badge bg-info text-dark me-1">{{ g.name }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{% if request.user.is_superuser %}
|
||||
<a href='{% url "account_profile" user.username %}' class="btn btn-sm btn-outline-light">Profile</a>
|
||||
{% endif %}
|
||||
<form method="post" class="d-inline m-0 p-0"
|
||||
action="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
|
||||
hx-post="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="edit_user_user" value="{{ user.pk }}">
|
||||
<input type="hidden" name="add" value="{% if current %}false{% else %}true{% endif %}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-light toggle-btn">Toggle</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -0,0 +1,14 @@
|
||||
<div id="user-users-wrap">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2 small text-muted">
|
||||
<div>Added: <span id="user-count-added">{{ current_user_users|length }}</span></div>
|
||||
<div>Available: <span id="user-count-available">{{ available_user_users|length }}</span></div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" id="user-users-list">
|
||||
{% for user in current_user_users %}
|
||||
{% include 'generic/partials/user_user_li.html' with user=user current=True exam=exam %}
|
||||
{% endfor %}
|
||||
{% for user in available_user_users %}
|
||||
{% include 'generic/partials/user_user_li.html' with user=user current=False exam=exam %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -421,6 +421,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
||||
generic_exam_view.exam_cid_bulk_edit,
|
||||
name="exam_cid_bulk_edit",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/user_bulk_edit",
|
||||
generic_exam_view.exam_user_bulk_edit,
|
||||
name="exam_user_bulk_edit",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/scores",
|
||||
generic_exam_view.exam_scores_all,
|
||||
|
||||
+94
-2
@@ -2412,8 +2412,15 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
else:
|
||||
app_exam_map[self.app_name].remove(pk)
|
||||
|
||||
data = {"status": "success", "added": add}
|
||||
return JsonResponse(data, status=200)
|
||||
# Return rendered list-item HTML for HTMX (or fetch fallback) and an HX-Trigger
|
||||
html = render_to_string(
|
||||
'generic/partials/user_user_li.html',
|
||||
{'user': user_user, 'exam': exam, 'current': add},
|
||||
request=request,
|
||||
)
|
||||
response = HttpResponse(html, content_type='text/html')
|
||||
response['HX-Trigger'] = json.dumps({'user_toggled': {'pk': user_user.pk, 'added': add}})
|
||||
return response
|
||||
|
||||
if "add_exam_questions" in request.POST:
|
||||
question_ids = json.loads(request.POST.get("add_exam_questions"))
|
||||
@@ -2601,6 +2608,91 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
response['HX-Trigger'] = json.dumps({'cid_bulk_toggled': {'changed': changed, 'add': add_flag, 'mixed': add_flag is None}})
|
||||
return response
|
||||
|
||||
@method_decorator(login_required)
|
||||
def exam_user_bulk_edit(self, request, pk):
|
||||
"""Bulk add/remove User objects to/from an exam. Expects POST with 'bulk_pks' (JSON list)
|
||||
and 'add' ('true'/'false'). Returns a rendered user list partial for HTMX swaps.
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'status': 'error, invalid method'}, status=400)
|
||||
|
||||
if request.user.groups.filter(name="cid_user_manager").exists():
|
||||
pass
|
||||
elif not self.check_user_edit_access(request.user, exam_id=pk):
|
||||
data = {"status": "invalid permisions"}
|
||||
return JsonResponse(data, status=403)
|
||||
|
||||
exam = get_object_or_404(self.Exam, pk=pk)
|
||||
|
||||
bulk_pks_raw = request.POST.get('bulk_pks') or request.POST.get('bulk_pks[]')
|
||||
items = []
|
||||
if bulk_pks_raw:
|
||||
try:
|
||||
items = json.loads(bulk_pks_raw)
|
||||
except Exception:
|
||||
try:
|
||||
items = [int(x) for x in bulk_pks_raw.split(',') if x.strip()]
|
||||
except Exception:
|
||||
items = []
|
||||
else:
|
||||
sel = request.POST.getlist('selection') or request.POST.getlist('selection[]')
|
||||
for s in sel:
|
||||
try:
|
||||
items.append(int(s))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
add_flag = None
|
||||
if 'add' in request.POST:
|
||||
add_flag = request.POST.get('add') == 'true'
|
||||
|
||||
changed = []
|
||||
for entry in items:
|
||||
if isinstance(entry, dict):
|
||||
uid = entry.get('pk')
|
||||
this_add = bool(entry.get('add'))
|
||||
else:
|
||||
uid = entry
|
||||
this_add = add_flag if add_flag is not None else True
|
||||
|
||||
try:
|
||||
user_obj = User.objects.get(pk=uid)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
app_exam_map = {}
|
||||
app_exam_map["rapids"] = user_obj.user_rapid_exams
|
||||
app_exam_map["shorts"] = user_obj.user_shorts_exams
|
||||
app_exam_map["anatomy"] = user_obj.user_anatomy_exams
|
||||
app_exam_map["longs"] = user_obj.user_longs_exams
|
||||
app_exam_map["physics"] = user_obj.user_physics_exams
|
||||
app_exam_map["sbas"] = user_obj.user_sba_exams
|
||||
app_exam_map["atlas"] = user_obj.user_casecollection_exams
|
||||
|
||||
try:
|
||||
if this_add:
|
||||
app_exam_map[self.app_name].add(pk)
|
||||
else:
|
||||
app_exam_map[self.app_name].remove(pk)
|
||||
changed.append(user_obj.pk)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Rebuild lists for rendering
|
||||
current_user_users = exam.valid_user_users.all()
|
||||
exam_groups = exam.user_user_groups.all()
|
||||
available_user_users = User.objects.filter(user_groups__in=exam_groups).difference(current_user_users)
|
||||
|
||||
html = render_to_string('generic/partials/user_user_list.html', {
|
||||
'exam': exam,
|
||||
'current_user_users': current_user_users,
|
||||
'available_user_users': available_user_users,
|
||||
}, request=request)
|
||||
|
||||
response = HttpResponse(html, content_type='text/html')
|
||||
response['HX-Trigger'] = json.dumps({'user_bulk_toggled': {'changed': changed, 'add': add_flag, 'mixed': add_flag is None}})
|
||||
return response
|
||||
|
||||
@method_decorator(login_required)
|
||||
def mark_overview(self, request, pk):
|
||||
exam: ExamBase = get_object_or_404(self.Exam, pk=pk)
|
||||
|
||||
Reference in New Issue
Block a user