Add bulk edit functionality for CID users: implement endpoint and update templates for bulk add/remove actions
This commit is contained in:
@@ -361,6 +361,11 @@ urlpatterns = [
|
||||
views.GenericExamViews.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(
|
||||
"collection/<int:pk>/dicom_json",
|
||||
views.collection_dicom_json,
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
{% if current_cid_users or available_cid_users %}
|
||||
<button class="btn btn-sm btn-outline-light toggle-all-btn">Toggle all</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-outline-success add-all-btn">Add all</button>
|
||||
<button class="btn btn-sm btn-outline-danger remove-all-btn">Remove 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" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Remove all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,112 +35,225 @@
|
||||
|
||||
<ul class="list-group list-group-flush" id="cid-users-list">
|
||||
{% 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}}">
|
||||
<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}} · {{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>
|
||||
{% include 'generic/partials/cid_user_li.html' with cid=cid current=True exam=exam %}
|
||||
{% endfor %}
|
||||
{% 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}}">
|
||||
<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}} · {{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>
|
||||
{% include 'generic/partials/cid_user_li.html' with cid=cid current=False exam=exam %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(() => {
|
||||
// Wire buttons: Add/Remove/Toggle
|
||||
$(".remove-all-btn").click((el) => {
|
||||
$(".list-group-item.current .toggle-btn").each(function(){ $(this).click(); });
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
// Bulk Add / Remove using a single HTMX POST
|
||||
const bulkForm = document.createElement('form');
|
||||
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) => {
|
||||
$(".list-group-item:not(.current) .toggle-btn").each(function(){ $(this).click(); });
|
||||
});
|
||||
$(".toggle-all-btn").click((el) => {
|
||||
$(".toggle-btn").each(function(){ $(this).click(); });
|
||||
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;
|
||||
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){
|
||||
const btn = ev.currentTarget;
|
||||
const parentLi = $(btn).closest('li');
|
||||
$.ajax({
|
||||
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{csrf_token}}",
|
||||
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');
|
||||
// Toggle all: send mixed per-item {pk, add} payload so server can apply add/remove per item
|
||||
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl;
|
||||
// build mixed list: for each li include {pk, add}
|
||||
const items = Array.from(document.querySelectorAll('#cid-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);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Sorting
|
||||
let currentSort = { key: 'cid', dir: 'asc' };
|
||||
function updateCarets() {
|
||||
$('.sort-btn').each(function(){
|
||||
const key = $(this).data('key');
|
||||
const caret = $(this).find('.sort-caret');
|
||||
caret.text('');
|
||||
if (currentSort.key === key) caret.text(currentSort.dir === 'asc' ? ' ▲' : ' ▼');
|
||||
|
||||
// HTMX: listen for server HX-Trigger to show toasts for toggles
|
||||
document.body.addEventListener('cid_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`);
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
function sortList(key) {
|
||||
const $ul = $('#cid-users-list');
|
||||
const items = $ul.children('li').get();
|
||||
const dir = (currentSort.key === key && currentSort.dir === 'asc') ? 'desc' : 'asc';
|
||||
items.sort(function(a,b){
|
||||
const va = ($(a).data(key) || '').toString().toLowerCase();
|
||||
const vb = ($(b).data(key) || '').toString().toLowerCase();
|
||||
if (va < vb) return dir === 'asc' ? -1 : 1;
|
||||
if (va > vb) return dir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
|
||||
// HTMX: clear loading indicators when requests finish
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt){
|
||||
try{
|
||||
const elt = (evt.detail && evt.detail.elt) || null;
|
||||
let btn = null;
|
||||
if(elt){
|
||||
if(elt.tagName === 'BUTTON') btn = elt;
|
||||
else if(elt.tagName === 'FORM') btn = elt.querySelector('button[type=submit]');
|
||||
else btn = elt.closest && elt.closest('button') ? elt.closest('button') : null;
|
||||
}
|
||||
if(btn) setLoading(btn, false);
|
||||
}catch(e){ }
|
||||
});
|
||||
// re-append
|
||||
for (let i=0;i<items.length;i++) $ul.append(items[i]);
|
||||
currentSort = { key: key, dir: dir };
|
||||
|
||||
// Delegate toggle button clicks so newly-inserted items work
|
||||
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();
|
||||
}
|
||||
$('.sort-btn').on('click', function(){ sortList($(this).data('key')); });
|
||||
// initialize caret
|
||||
updateCarets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<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}} · {{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>
|
||||
@@ -416,6 +416,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
||||
generic_exam_view.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(
|
||||
"exam/<int:pk>/scores",
|
||||
generic_exam_view.exam_scores_all,
|
||||
|
||||
@@ -21,6 +21,7 @@ from django.core.exceptions import PermissionDenied, FieldError
|
||||
|
||||
from django.http import Http404, HttpRequest, JsonResponse
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
|
||||
@@ -2378,6 +2379,18 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
else:
|
||||
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}
|
||||
return JsonResponse(data, status=200)
|
||||
|
||||
@@ -2502,6 +2515,95 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
data = {"status": "error"}
|
||||
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)
|
||||
def mark_overview(self, request, pk):
|
||||
exam: ExamBase = get_object_or_404(self.Exam, pk=pk)
|
||||
|
||||
Reference in New Issue
Block a user