Add ability to do bulk status update on talkvote page
authorMagnus Hagander <magnus@hagander.net>
Mon, 11 Aug 2025 12:43:25 +0000 (14:43 +0200)
committerMagnus Hagander <magnus@hagander.net>
Mon, 11 Aug 2025 12:43:25 +0000 (14:43 +0200)
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
media/css/sessionvotes.css
media/js/sessionvotes.js
postgresqleu/confreg/views.py
template/confreg/sessionvotes.html

index 5a227971a9302bf4122bfed76ff510cecf012b1b..a6d63f5410fa72c2e5c7c68aed44f96ff1775e1d 100644 (file)
@@ -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
index b9d1b99ce706c68a28f38473b348c4756bd6f17b..1020461854ebeb0ea8bed1e3691f2318b3108f75 100644 (file)
@@ -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);
 }
index bb7ca75fade83d959c30a065fd9074a90c5a8fc4..0859549765bc4615bc1a28768ce454c1eddd63b0 100644 (file)
@@ -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);
   }
index 4b5a301c4073cbf3d904b018ea53b2b42a28f1a3..431cbd265567182ef4d213d8f9e3f887760ec0db 100644 (file)
@@ -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')
 
 
index 995b9a58973cf365721b373420a65493601e1324..7e3060c12b29727d5965158381fd608b8da70ff6 100644 (file)
@@ -114,7 +114,10 @@ body:has(input#col_{{fc.class}}:checked) tr.headerrow th.flt-{{fc.class}} {
 {%for s in sessionvotes%}
  <tbody>
  <tr class="headerrow sessionrow" data-sid="{{s.id}}" data-status="{{s.statusid}}" data-track="{{s.trackid|default:"0"}}"{% if conference.callforpaperstags %} data-tags="{{s.tags|join_dictkeys:"id,,"}}"{% endif %}>
-   <td class="flt-seq text-center">{{forloop.counter}}</td>
+   <td class="flt-seq text-center"><div>
+     <div class="seq">{{forloop.counter}}</div>
+{%if isadmin%}<div><a class="selup">&#xf0d8;</a><input type="checkbox"><a class="seldown">&#xf0d7;</a></div>{%endif%}
+   </div></td>
    <td class="flt-id">{{s.id}}</td>
    <td>
      <h3><label class="dropdown-checkbox"><input type="checkbox" data-sid="{{s.id}}"><span></span></label> {{s.title}}</h3>
@@ -206,6 +209,17 @@ body:has(input#col_{{fc.class}}:checked) tr.headerrow th.flt-{{fc.class}} {
  </tr>
 </tbody>
 {%endfor%}
+{%if isadmin %}
+ <tbody class="header">
+ <tr class="headerrow tableheader">
+  <th colspan="2" class="flt-seq">
+    <button id="btnClearCheckboxes" class="btn">Clear checkboxes</button>
+    <button id="btnBulkStatus" class="btn">Bulk set status</button>
+  </th>
+ </tr>
+ </tbody>
+{%endif%}
+
 </table>
 
 </fieldset>