From f955a6aa85b3fc6c909d016f3b8b76f4d5c6e660 Mon Sep 17 00:00:00 2001 From: Magnus Hagander Date: Mon, 11 Aug 2025 14:43:25 +0200 Subject: [PATCH] Add ability to do bulk status update on talkvote page This can be used to for example reject all sessions below a certain number etc. Can be combined with the existing filtering system to get reasonably granular control and efficiency. Fixes #128 --- docs/confreg/callforpapers.md | 3 +- media/css/sessionvotes.css | 16 ++++++ media/js/sessionvotes.js | 79 +++++++++++++++++++++++++++--- postgresqleu/confreg/views.py | 31 ++++++------ template/confreg/sessionvotes.html | 16 +++++- 5 files changed, 122 insertions(+), 23 deletions(-) diff --git a/docs/confreg/callforpapers.md b/docs/confreg/callforpapers.md index 5a227971..a6d63f54 100644 --- a/docs/confreg/callforpapers.md +++ b/docs/confreg/callforpapers.md @@ -72,7 +72,8 @@ emails are always just flagged and not sent until explicitly requested. That way it's possible to perform extra review. A state of a session can be changed either from the voting page (by -clicking the current state and picking a new one) or from the Edit +clicking the current state, or by selecting a number of sessions using the checkboxes on the left, +and picking a new status) or from the Edit Session page (by picking a new state in the drop down and then clicking save). In either case the new state is limited by valid state transitions. By repeating this process it is possible to "break the diff --git a/media/css/sessionvotes.css b/media/css/sessionvotes.css index b9d1b99c..10204618 100644 --- a/media/css/sessionvotes.css +++ b/media/css/sessionvotes.css @@ -88,6 +88,22 @@ a.sortheader[data-sorted="-1"]::after { content: " \f161"; } +td.flt-seq > div { + display: flex; + align-items: center; +} +td.flt-seq > div div:first-child { + margin-right: 2rem; +} +td.flt-seq input { + display: inline; +} +td.flt-seq a { + font-family: FontAwesome; + cursor: pointer; +} + + dialog::backdrop { backdrop-filter: blur(4px); } diff --git a/media/js/sessionvotes.js b/media/js/sessionvotes.js index bb7ca75f..08595497 100644 --- a/media/js/sessionvotes.js +++ b/media/js/sessionvotes.js @@ -96,6 +96,31 @@ document.addEventListener('DOMContentLoaded', () => { }); }); + document.querySelectorAll('a.selup').forEach((a) => { + a.title = 'Click to mark all entries from this row and up, based on the current view'; + a.addEventListener('click', (e) => { + const tbody = e.target.closest('tbody'); + for (let current = tbody ; current != null ; current = current.previousElementSibling) { + const cb = current.querySelector('tr td.flt-seq input[type="checkbox"]'); + if (cb && current.querySelector('tr.sessionrow').style.display != 'none') { + cb.checked = true; + } + }; + }); + }); + document.querySelectorAll('a.seldown').forEach((a) => { + a.title = 'Click to mark all entries from this row and down, based on the current view'; + a.addEventListener('click', (e) => { + const tbody = e.target.closest('tbody'); + for (let current = tbody ; current != null ; current = current.nextElementSibling) { + const cb = current.querySelector('tr td.flt-seq input[type="checkbox"]'); + if (cb && current.querySelector('tr.sessionrow').style.display != 'none') { + cb.checked = true; + } + }; + }); + }); + const dlgStatus = document.getElementById('dlgStatus'); dlgStatus.querySelectorAll('button').forEach((b) => { b.addEventListener("click", (e) => { @@ -128,6 +153,41 @@ document.addEventListener('DOMContentLoaded', () => { }); }); + if (document.getElementById('btnClearCheckboxes')) { + document.getElementById('btnClearCheckboxes').addEventListener('click', (e) => { + document.querySelectorAll('td.flt-seq input[type="checkbox"]').forEach((c) => { + c.checked = false; + }); + }); + } + + if (document.getElementById('btnBulkStatus')) { + document.getElementById('btnBulkStatus').addEventListener('click', (e) => { + const idlist = [...document.querySelectorAll('tr.sessionrow:has(td.flt-seq input[type="checkbox"]:checked)')].map((e) => e.dataset.sid); + const statuslist = new Set([...document.querySelectorAll('tr.sessionrow:has(td.flt-seq input[type="checkbox"]:checked)')].map((e) => e.dataset.status)); + const transitions = [...statuslist].map((s) => new Set(Object.keys(valid_status_transitions[s]))); + + const valid = transitions.reduce((acc, currval) => { + return acc.intersection(currval); + }); + + if (!valid.size) { + alert('There are no valid status transitions for all the selected sessions.'); + return; + } + + const dialog = document.getElementById('dlgStatus'); + dialog.dataset.sid = idlist; + dialog.getElementsByTagName('h3')[0].innerText = "Bulk change status for ids " + idlist;; + const buttonDiv = dialog.getElementsByTagName('div')[0]; + buttonDiv.querySelectorAll('button').forEach((btn) => { + btn.style.display = (valid.has(btn.dataset.statusid)) ? "inline-block": "none"; + }); + + dialog.showModal(); + }); + } + filter_sessions(); }); @@ -190,10 +250,10 @@ function filter_sessions() { document.getElementById('detailsrow_' + row.dataset.sid).style.display = visible ? "" : "none"; if (visible) { - row.querySelector('td').innerText = seq; + row.querySelector('td div.seq').innerText = seq; seq += 1; } else { - row.querySelector('td').innerText = ''; + row.querySelector('td div.seq').innerText = ''; } }); } @@ -207,8 +267,7 @@ function getFormData(obj) { } async function doUpdateStatus(id, statusval) { - const targetRow = document.querySelector('tr.sessionrow[data-sid="' + id + '"]'); - const targetFld = targetRow.querySelector('td.fld-status'); + if (!statusval) return; const response = await fetch('changestatus/', { 'method': 'POST', @@ -221,9 +280,15 @@ async function doUpdateStatus(id, statusval) { }); if (response.ok) { const j = await response.json(); - targetRow.dataset.status = statusval; - targetFld.getElementsByTagName('a')[0].text = j.newstatus; - targetFld.style.backgroundColor = j.statechanged ? 'yellow' : 'white'; + + id.split(',').forEach((i) => { + const targetRow = document.querySelector('tr.sessionrow[data-sid="' + i + '"]'); + const targetFld = targetRow.querySelector('td.fld-status'); + targetRow.dataset.status = statusval; + targetFld.getElementsByTagName('a')[0].text = j.newstatus; + targetFld.style.backgroundColor = j.statechanged[i] ? 'yellow' : 'white'; + }); + document.getElementById('pendingNotificationsButton').style.display = j.pending ? 'inline-block': 'none'; setAjaxStatus('Changed status to ' + j.newstatus, false); } diff --git a/postgresqleu/confreg/views.py b/postgresqleu/confreg/views.py index 4b5a301c..431cbd26 100644 --- a/postgresqleu/confreg/views.py +++ b/postgresqleu/confreg/views.py @@ -2991,26 +2991,29 @@ def talkvote_status(request, confname): raise Http404("No sessionid") newstatus = get_int_or_error(request.POST, 'newstatus') - session = get_object_or_404(ConferenceSession, conference=conference, id=get_int_or_error(request.POST, 'sessionid')) - if newstatus not in valid_status_transitions[session.status]: - return HttpResponse("Cannot change from {} to {}".format(get_status_string(session.status), get_status_string(newstatus)), status=400) + try: + idlist = [int(i) for i in request.POST.get('sessionid').split(',')] + except ValueError: + raise Http404("Parameter idlist contains non-integers") - session.status = newstatus - session.save() - statechange = session.speaker.exists() and (session.status != session.lastnotifiedstatus) + sessions = list(ConferenceSession.objects.only('id', 'status').filter(conference=conference, id__in=idlist)) + if len(idlist) != len(sessions): + return HttpResponse("Invalid number of sessions matched", status=400) - if statechange: - # If *this* session has a state changed, then we can shortcut the lookup for - # others and just indicate we know there is one. - pendingnotifications = True - else: - # Otherwise we have to see if there are any others - pendingnotifications = conference.pending_session_notifications + statechange = {} + for session in sessions: + if newstatus not in valid_status_transitions[session.status]: + return HttpResponse("Cannot change from {} to {}".format(get_status_string(session.status), get_status_string(newstatus)), status=400) + + session.status = newstatus + session.save(update_fields=['status']) + + statechange[session.id] = session.speaker.exists() and (session.status != session.lastnotifiedstatus) return HttpResponse(json.dumps({ 'newstatus': get_status_string(session.status), 'statechanged': statechange, - 'pending': bool(pendingnotifications), + 'pending': bool(conference.pending_session_notifications), }), content_type='application/json') diff --git a/template/confreg/sessionvotes.html b/template/confreg/sessionvotes.html index 995b9a58..7e3060c1 100644 --- a/template/confreg/sessionvotes.html +++ b/template/confreg/sessionvotes.html @@ -114,7 +114,10 @@ body:has(input#col_{{fc.class}}:checked) tr.headerrow th.flt-{{fc.class}} { {%for s in sessionvotes%} - {{forloop.counter}} +
+
{{forloop.counter}}
+{%if isadmin%}
{%endif%} +
{{s.id}}

{{s.title}}

@@ -206,6 +209,17 @@ body:has(input#col_{{fc.class}}:checked) tr.headerrow th.flt-{{fc.class}} { {%endfor%} +{%if isadmin %} + + + + + + + + +{%endif%} + -- 2.39.5