Add bulk edit functionality for CID users: implement endpoint and update templates for bulk add/remove actions

This commit is contained in:
Ross
2025-12-15 11:49:06 +00:00
parent 7e4e66a74f
commit 00dfaf9888
6 changed files with 347 additions and 92 deletions
+5
View File
@@ -361,6 +361,11 @@ urlpatterns = [
views.GenericExamViews.exam_json_edit, views.GenericExamViews.exam_json_edit,
name="exam_json_edit", name="exam_json_edit",
), ),
path(
"collection/<int:pk>/cid_bulk_edit",
views.GenericExamViews.exam_cid_bulk_edit,
name="exam_cid_bulk_edit",
),
path( path(
"collection/<int:pk>/dicom_json", "collection/<int:pk>/dicom_json",
views.collection_dicom_json, views.collection_dicom_json,
+205 -92
View File
@@ -19,8 +19,8 @@
{% if current_cid_users or available_cid_users %} {% if current_cid_users or available_cid_users %}
<button class="btn btn-sm btn-outline-light toggle-all-btn">Toggle all</button> <button class="btn btn-sm btn-outline-light toggle-all-btn">Toggle all</button>
{% endif %} {% endif %}
<button class="btn btn-sm btn-outline-success add-all-btn">Add all</button> <button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Add all</button>
<button class="btn btn-sm btn-outline-danger remove-all-btn">Remove all</button> <button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Remove all</button>
</div> </div>
</div> </div>
@@ -35,112 +35,225 @@
<ul class="list-group list-group-flush" id="cid-users-list"> <ul class="list-group list-group-flush" id="cid-users-list">
{% for cid in current_cid_users %} {% for cid in current_cid_users %}
<li class="list-group-item d-flex justify-content-between align-items-center current py-1 px-2 bg-transparent border-secondary" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}" data-name="{{cid.name|escape}}" data-email="{{cid.email|escape}}"> {% include 'generic/partials/cid_user_li.html' with cid=cid current=True exam=exam %}
<div class="me-2">
<div class="d-flex align-items-center">
<div class="fw-bold small mb-0">{{cid.cid}} <span class="text-muted">[{{cid.passcode}}]</span></div>
<span class="badge bg-success ms-2 small">Added</span>
</div>
<div class="small text-muted">{{cid.name}} &middot; {{cid.email}}</div>
</div>
<div class="btn-group ms-2">
<a href="{% url 'generic:update_cid' cid.pk %}" class="btn btn-sm btn-outline-light">Edit</a>
<button class="btn btn-sm btn-outline-light toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</div>
</li>
{% endfor %} {% endfor %}
{% for cid in available_cid_users %} {% for cid in available_cid_users %}
<li class="list-group-item d-flex justify-content-between align-items-center py-1 px-2 bg-transparent border-secondary" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}" data-name="{{cid.name|escape}}" data-email="{{cid.email|escape}}"> {% include 'generic/partials/cid_user_li.html' with cid=cid current=False exam=exam %}
<div class="me-2">
<div class="d-flex align-items-center">
<div class="fw-bold small mb-0">{{cid.cid}} <span class="text-muted">[{{cid.passcode}}]</span></div>
<span class="badge bg-secondary text-dark ms-2 small">Not added</span>
</div>
<div class="small text-muted">{{cid.name}} &middot; {{cid.email}}</div>
</div>
<div class="btn-group ms-2">
<a href="{% url 'generic:update_cid' cid.pk %}" class="btn btn-sm btn-outline-light">Edit</a>
<button class="btn btn-sm btn-outline-light toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</div>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</div> </div>
<script> <script>
$(document).ready(() => { document.addEventListener('DOMContentLoaded', function(){
// Wire buttons: Add/Remove/Toggle // Bulk Add / Remove using a single HTMX POST
$(".remove-all-btn").click((el) => { const bulkForm = document.createElement('form');
$(".list-group-item.current .toggle-btn").each(function(){ $(this).click(); }); bulkForm.id = 'bulk-cid-form';
bulkForm.className = 'd-none';
bulkForm.setAttribute('hx-target', '#cid-users-list');
bulkForm.setAttribute('hx-swap', 'outerHTML');
const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input'; bulkForm.appendChild(bulkInput);
const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input'; bulkForm.appendChild(bulkAddInput);
// include csrf token
const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
document.body.appendChild(bulkForm);
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){ /* ignore */ }
}
function submitBulk(url, pks, add, triggeringButton){
// prefer HTMX if available
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;
}
// fallback: use fetch and swap the list manually
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 }) => {
// swap the list
try{
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const newList = doc.querySelector('#cid-users-list');
if (newList){
const old = document.querySelector('#cid-users-list');
old.replaceWith(newList);
}
}catch(e){ console.error('bulk swap failed', e); }
// dispatch HX-Trigger equivalent if present
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); });
}
document.querySelectorAll('.add-all-btn').forEach(function(btn){
btn.addEventListener('click', function(){
const url = btn.dataset.bulkUrl;
const items = document.querySelectorAll('#cid-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);
});
}); });
$(".add-all-btn").click((el) => { document.querySelectorAll('.remove-all-btn').forEach(function(btn){
$(".list-group-item:not(.current) .toggle-btn").each(function(){ $(this).click(); }); btn.addEventListener('click', function(){
}); if(!confirm('Remove all selected users from the exam?')) return;
$(".toggle-all-btn").click((el) => { const url = btn.dataset.bulkUrl;
$(".toggle-btn").each(function(){ $(this).click(); }); const items = document.querySelectorAll('#cid-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).on('click', '.toggle-btn', function(ev){ // Toggle all: send mixed per-item {pk, add} payload so server can apply add/remove per item
const btn = ev.currentTarget; document.querySelectorAll('.toggle-all-btn').forEach(function(btn){
const parentLi = $(btn).closest('li'); btn.addEventListener('click', function(){
$.ajax({ const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl;
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}", // build mixed list: for each li include {pk, add}
data: { const items = Array.from(document.querySelectorAll('#cid-users-list li')).map(function(li){
csrfmiddlewaretoken: "{{csrf_token}}", return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') };
edit_cid_user: btn.dataset.pk,
add: !parentLi.hasClass('current'),
},
type: "POST",
dataType: "json",
})
.done(function (data) {
if (data.status == "success") {
if (data.added) {
parentLi.addClass('current');
toastr.info(`User ${btn.dataset.cid} added to exam`)
} else {
parentLi.removeClass('current');
toastr.info(`User ${btn.dataset.cid} removed from exam`)
}
} else {
toastr.error(data.status)
}
})
.fail(function (data) {
toastr.error((data && data.responseJSON && data.responseJSON.status) ? data.responseJSON.status : 'Request failed');
}); });
if (!items.length) return;
submitBulk(url, items, null, btn);
});
}); });
});
// Sorting // HTMX: listen for server HX-Trigger to show toasts for toggles
let currentSort = { key: 'cid', dir: 'asc' }; document.body.addEventListener('cid_toggled', function(evt){
function updateCarets() { try {
$('.sort-btn').each(function(){ const detail = evt.detail || {};
const key = $(this).data('key'); const added = detail.added;
const caret = $(this).find('.sort-caret'); if (window.toastr) {
caret.text(''); if (added) toastr.info(`User added to exam`);
if (currentSort.key === key) caret.text(currentSort.dir === 'asc' ? ' ▲' : ' ▼'); else toastr.info(`User removed from exam`);
}
} catch(e) { /* ignore */ }
}); });
}
function sortList(key) { // HTMX: clear loading indicators when requests finish
const $ul = $('#cid-users-list'); document.body.addEventListener('htmx:afterRequest', function(evt){
const items = $ul.children('li').get(); try{
const dir = (currentSort.key === key && currentSort.dir === 'asc') ? 'desc' : 'asc'; const elt = (evt.detail && evt.detail.elt) || null;
items.sort(function(a,b){ let btn = null;
const va = ($(a).data(key) || '').toString().toLowerCase(); if(elt){
const vb = ($(b).data(key) || '').toString().toLowerCase(); if(elt.tagName === 'BUTTON') btn = elt;
if (va < vb) return dir === 'asc' ? -1 : 1; else if(elt.tagName === 'FORM') btn = elt.querySelector('button[type=submit]');
if (va > vb) return dir === 'asc' ? 1 : -1; else btn = elt.closest && elt.closest('button') ? elt.closest('button') : null;
return 0; }
if(btn) setLoading(btn, false);
}catch(e){ }
}); });
// re-append
for (let i=0;i<items.length;i++) $ul.append(items[i]); // Delegate toggle button clicks so newly-inserted items work
currentSort = { key: key, dir: dir }; document.body.addEventListener('click', function(e){
const btn = e.target.closest && e.target.closest('.toggle-btn');
if(!btn) return;
// find enclosing form
const form = btn.closest('form');
if(!form) return;
e.preventDefault();
setLoading(btn, true);
if(window.htmx && typeof htmx.submit === 'function'){
htmx.submit(form);
return;
}
// fallback: send via fetch and swap the nearest li with returned HTML
(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 to parse HTML and replace the closest li
try{
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
// the server should return the li outerHTML
const newLi = doc.querySelector('li');
const oldLi = form.closest('li');
if(newLi && oldLi){ oldLi.replaceWith(newLi); }
// dispatch any HX-Trigger header events
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); }
})();
});
// Sorting: pure JS implementation
let currentSort = { key: 'cid', dir: 'asc' };
function updateCarets(){
document.querySelectorAll('.sort-btn').forEach(function(b){
const key = b.dataset.key;
const caret = b.querySelector('.sort-caret');
if (caret) caret.textContent = (currentSort.key === key) ? (currentSort.dir === 'asc' ? ' ▲' : ' ▼') : '';
});
}
function sortList(key){
const ul = document.getElementById('cid-users-list');
const items = Array.from(ul.children);
const dir = (currentSort.key === key && currentSort.dir === 'asc') ? 'desc' : 'asc';
items.sort(function(a,b){
const va = (a.dataset[key] || '').toString().toLowerCase();
const vb = (b.dataset[key] || '').toString().toLowerCase();
if (va < vb) return dir === 'asc' ? -1 : 1;
if (va > vb) return dir === 'asc' ? 1 : -1;
return 0;
});
items.forEach(function(it){ ul.appendChild(it); });
currentSort = { key: key, dir: dir };
updateCarets();
}
document.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortList(b.dataset.key); }); });
updateCarets(); updateCarets();
} });
$('.sort-btn').on('click', function(){ sortList($(this).data('key')); });
// initialize caret
updateCarets();
</script> </script>
<style> <style>
@@ -0,0 +1,22 @@
<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="{{cid.pk}}" data-cid="{{cid.cid}}" data-name="{{cid.name|escape}}" data-email="{{cid.email|escape}}">
<div class="me-2">
<div class="d-flex align-items-center">
<div class="fw-bold small mb-0">{{cid.cid}} <span class="text-muted">[{{cid.passcode}}]</span></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">{{cid.name}} &middot; {{cid.email}}</div>
</div>
<div class="btn-group ms-2">
<a href="{% url 'generic:update_cid' cid.pk %}" class="btn btn-sm btn-outline-light">Edit</a>
<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_cid_user" value="{{cid.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>
</li>
@@ -0,0 +1,8 @@
<ul class="list-group list-group-flush" id="cid-users-list">
{% for cid in current_cid_users %}
{% include 'generic/partials/cid_user_li.html' with cid=cid current=True exam=exam %}
{% endfor %}
{% for cid in available_cid_users %}
{% include 'generic/partials/cid_user_li.html' with cid=cid current=False exam=exam %}
{% endfor %}
</ul>
+5
View File
@@ -416,6 +416,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
generic_exam_view.exam_json_edit, generic_exam_view.exam_json_edit,
name="exam_json_edit", name="exam_json_edit",
), ),
path(
"exam/<int:pk>/cid_bulk_edit",
generic_exam_view.exam_cid_bulk_edit,
name="exam_cid_bulk_edit",
),
path( path(
"exam/<int:pk>/scores", "exam/<int:pk>/scores",
generic_exam_view.exam_scores_all, generic_exam_view.exam_scores_all,
+102
View File
@@ -21,6 +21,7 @@ from django.core.exceptions import PermissionDenied, FieldError
from django.http import Http404, HttpRequest, JsonResponse from django.http import Http404, HttpRequest, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.template.loader import render_to_string
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
@@ -2378,6 +2379,18 @@ class ExamViews(View, LoginRequiredMixin):
else: else:
app_exam_map[self.app_name].remove(pk) app_exam_map[self.app_name].remove(pk)
# If this is an HTMX request, return the rendered li partial
if request.headers.get('HX-Request') == 'true':
# Render the updated list item and return as HTML so HTMX can swap it
html = render_to_string(
'generic/partials/cid_user_li.html',
{'cid': cid_user, 'exam': exam, 'current': add},
request=request,
)
response = HttpResponse(html, content_type='text/html')
response['HX-Trigger'] = json.dumps({'cid_toggled': {'pk': cid_user.pk, 'added': add}})
return response
data = {"status": "success", "added": add} data = {"status": "success", "added": add}
return JsonResponse(data, status=200) return JsonResponse(data, status=200)
@@ -2502,6 +2515,95 @@ class ExamViews(View, LoginRequiredMixin):
data = {"status": "error"} data = {"status": "error"}
return JsonResponse(data, status=400) return JsonResponse(data, status=400)
@method_decorator(login_required)
def exam_cid_bulk_edit(self, request, pk):
"""Bulk add/remove CidUsers to/from an exam. Expects POST with 'bulk_pks' (JSON list)
and 'add' ('true'/'false'). Returns a rendered 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:
# attempt to parse comma separated
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' may be provided for uniform bulk operations; otherwise items may be a list of {pk, add}
add_flag = None
if 'add' in request.POST:
add_flag = request.POST.get('add') == 'true'
changed = []
for entry in items:
# entry may be an int PK or a dict {pk, add}
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:
cid_user = CidUser.objects.get(pk=uid)
except Exception:
continue
app_exam_map = {}
app_exam_map["rapids"] = cid_user.rapid_exams
app_exam_map["shorts"] = cid_user.shorts_exams
app_exam_map["anatomy"] = cid_user.anatomy_exams
app_exam_map["longs"] = cid_user.longs_exams
app_exam_map["physics"] = cid_user.physics_exams
app_exam_map["sbas"] = cid_user.sba_exams
app_exam_map["atlas"] = cid_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(cid_user.pk)
except Exception:
continue
# Rebuild lists for rendering
current_cid_users = exam.valid_cid_users.all()
exam_groups = exam.cid_user_groups.all()
available_cid_users = CidUser.objects.filter(group__in=exam_groups).difference(current_cid_users)
html = render_to_string('generic/partials/cid_user_list.html', {
'exam': exam,
'current_cid_users': current_cid_users,
'available_cid_users': available_cid_users,
}, request=request)
response = HttpResponse(html, content_type='text/html')
# add_flag may be None for mixed add/remove operations
response['HX-Trigger'] = json.dumps({'cid_bulk_toggled': {'changed': changed, 'add': add_flag, 'mixed': add_flag is None}})
return response
@method_decorator(login_required) @method_decorator(login_required)
def mark_overview(self, request, pk): def mark_overview(self, request, pk):
exam: ExamBase = get_object_or_404(self.Exam, pk=pk) exam: ExamBase = get_object_or_404(self.Exam, pk=pk)