From 71dc50b307ed8937fc7a49ceef3c19542d9eac0b Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 09:51:54 +0000 Subject: [PATCH 01/24] Enhance CID and User candidate filtering with clear buttons and indicators for improved usability --- generic/templates/generic/exam_cids.html | 57 ++++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/generic/templates/generic/exam_cids.html b/generic/templates/generic/exam_cids.html index c0dcac02..c465dee4 100644 --- a/generic/templates/generic/exam_cids.html +++ b/generic/templates/generic/exam_cids.html @@ -15,8 +15,10 @@
CID candidates ({{ cid_user_count }})
-
+
+ + Filtered
@@ -52,8 +54,10 @@
User candidates ({{ user_user_count }})
-
+
+ + Filtered
    @@ -122,16 +126,26 @@ function normalizeText(s){ return (s||'').toString().toLowerCase(); } - function filterList(inputElem, listSelector){ + function filterList(inputElem, listSelector, indicatorId, clearBtnId){ const q = normalizeText(inputElem.value.trim()); document.querySelectorAll(listSelector).forEach(function(li){ - const text = normalizeText(li.textContent); + const text = normalizeText(li.textContent || ''); if(!q || text.indexOf(q) !== -1){ - li.style.display = ''; + try{ li.style.removeProperty('display'); }catch(e){} + li.classList.remove('d-none'); } else { - li.style.display = 'none'; + try{ li.style.setProperty('display','none','important'); }catch(e){ li.style.display = 'none'; } + li.classList.add('d-none'); } }); + + // Toggle indicator & clear button + try{ + const ind = document.getElementById(indicatorId); + const clearBtn = document.getElementById(clearBtnId); + if(ind) ind.classList.toggle('d-none', !q); + if(clearBtn) clearBtn.classList.toggle('d-none', !q); + }catch(e){} } function initTooltips(){ @@ -163,10 +177,29 @@ // Wire filters document.addEventListener('DOMContentLoaded', function(){ const cidFilter = document.getElementById('cid-filter'); - if(cidFilter){ cidFilter.addEventListener('input', function(){ filterList(cidFilter, '.col-lg-6:first-of-type .list-group-item'); }); } + if(cidFilter && !cidFilter.dataset.bound){ + cidFilter.addEventListener('input', function(){ filterList(cidFilter, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); }); + cidFilter.dataset.bound = '1'; + } const userFilter = document.getElementById('user-filter'); - if(userFilter){ userFilter.addEventListener('input', function(){ filterList(userFilter, '.col-lg-6:nth-of-type(2) .list-group-item, .col-lg-6:nth-of-type(2) .list-group-item.d-flex'); }); } + if(userFilter && !userFilter.dataset.bound){ + userFilter.addEventListener('input', function(){ filterList(userFilter, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); }); + userFilter.dataset.bound = '1'; + } + + // Clear buttons + const cidClear = document.getElementById('cid-filter-clear'); + if(cidClear && !cidClear.dataset.bound){ + cidClear.addEventListener('click', function(){ const f=document.getElementById('cid-filter'); if(f){ f.value=''; filterList(f, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); }}); + cidClear.dataset.bound = '1'; + } + + const userClear = document.getElementById('user-filter-clear'); + if(userClear && !userClear.dataset.bound){ + userClear.addEventListener('click', function(){ const f=document.getElementById('user-filter'); if(f){ f.value=''; filterList(f, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); }}); + userClear.dataset.bound = '1'; + } // initial runs (may be no partials yet) initTooltips(); @@ -184,6 +217,14 @@ initTooltips(); // submitted-list may have loaded now; re-run highlighting highlightSubmitted(); + + // Re-run current filters for newly-loaded partials + try{ + const cf = document.getElementById('cid-filter'); + if(cf) filterList(cf, '#cid-list .list-group-item'); + const uf = document.getElementById('user-filter'); + if(uf) filterList(uf, '#user-list .list-group-item'); + }catch(e){ /* ignore */ } } }catch(e){ console.error(e); } }); From 086bd8f5890a552830ca59321bbc17c1633bc096 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 10:18:41 +0000 Subject: [PATCH 02/24] Add permission check for user marker access in mark2 overview --- anatomy/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/anatomy/views.py b/anatomy/views.py index 268478b8..388dd9b9 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -786,6 +786,9 @@ def mark2_overview(request, pk): """Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2.""" exam = get_object_or_404(Exam, pk=pk) + if not GenericExamViews.check_user_marker_access(request.user, pk): + raise PermissionDenied + if not exam.exam_mode: raise Http404("Packet not in exam mode") From debe093bc9322ee7fce0967a87ad115ab7cf8d62 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 10:59:40 +0000 Subject: [PATCH 03/24] Add exam_mode parameter to exam take URL for improved functionality --- templates/cid_scores.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/cid_scores.html b/templates/cid_scores.html index e44e951e..42dfc916 100644 --- a/templates/cid_scores.html +++ b/templates/cid_scores.html @@ -22,7 +22,7 @@ {% for exam in exams %}
  • {{exam}} {% if exam.active %} - + {% endif %} From 457fb06dc01feb01ed9a7e934d3601f8a57305f6 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 11:05:26 +0000 Subject: [PATCH 04/24] Enhance highlightSubmitted function to add badges for submitted CIDs and users, improving visual feedback in the candidate list. --- generic/templates/generic/exam_cids.html | 68 ++++++++++++++++++------ 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/generic/templates/generic/exam_cids.html b/generic/templates/generic/exam_cids.html index c465dee4..f33f29d9 100644 --- a/generic/templates/generic/exam_cids.html +++ b/generic/templates/generic/exam_cids.html @@ -155,23 +155,61 @@ } function highlightSubmitted(){ - document.querySelectorAll('#submitted-candidates li').forEach(function(el){ + // Find any submitted rows (table rows or list items) that include + // data attributes for cid/user. Then insert a small badge on matching list items. + // Collect submitted identifiers first to avoid duplicate processing + const submittedEls = document.querySelectorAll('#submitted-candidates [data-cid], #submitted-candidates [data-user]'); + const submittedCids = new Set(); + const submittedUsers = new Set(); + submittedEls.forEach(function(el){ try{ - const cid = el.dataset.cid; - const user = el.dataset.user; - if(cid && cid !== 'None'){ - document.querySelectorAll(`.list-group-item`).forEach(function(li){ - try{ - const link = li.querySelector('.cid-link'); - if(link && link.textContent.trim() === cid.toString()) li.classList.add('submitted'); - }catch(e){} - }); - } - if(user && user !== 'None'){ - document.querySelectorAll('.list-group-item').forEach(function(li){ if(li.textContent.includes(user)) li.classList.add('submitted'); }); - } - }catch(e){ console.error(e); } + const cid = el.dataset && el.dataset.cid ? el.dataset.cid.toString() : null; + const user = el.dataset && el.dataset.user ? el.dataset.user.toString() : null; + if(cid && cid !== 'None') submittedCids.add(cid); + if(user && user !== 'None') submittedUsers.add(user); + }catch(e){ console.error('highlightSubmitted collect error', e); } }); + + // Add badges for CID list items + if(submittedCids.size){ + document.querySelectorAll('#cid-list .list-group-item').forEach(function(li){ + try{ + const link = li.querySelector && li.querySelector('.cid-link'); + const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim(); + if(submittedCids.has(text) && !li.querySelector('.submitted-badge')){ + const badge = document.createElement('span'); + badge.className = 'submitted-badge badge bg-success ms-2'; + badge.textContent = 'Submitted'; + if(link && link.parentNode){ + link.parentNode.appendChild(badge); + } else { + li.appendChild(badge); + } + } + }catch(e){ console.error('highlightSubmitted CID item error', e); } + }); + } + + // Add badges for User list items + if(submittedUsers.size){ + document.querySelectorAll('#user-list .list-group-item').forEach(function(li){ + try{ + const txt = (li.textContent||'').trim(); + for(const user of submittedUsers){ + if(txt.indexOf(user) !== -1){ + if(!li.querySelector('.submitted-badge')){ + const badge = document.createElement('span'); + badge.className = 'submitted-badge badge bg-success ms-2'; + badge.textContent = 'Submitted'; + const nameEl = li.querySelector('.fw-bold') || li.querySelector('a') || li; + nameEl.appendChild(badge); + } + break; + } + } + }catch(e){ console.error('highlightSubmitted User item error', e); } + }); + } } // Wire filters From 96e88c05a4891b859296ac97f798613c494f36da Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 11:25:10 +0000 Subject: [PATCH 05/24] Enhance submission tracking by adding first and last name data attributes for candidates, improving badge tooltip information in the submitted candidates list. --- generic/templates/generic/exam_cids.html | 24 +++++++++++++++---- .../partials/exam_cids_submitted_list.html | 2 +- generic/views.py | 20 +++++++++++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/generic/templates/generic/exam_cids.html b/generic/templates/generic/exam_cids.html index f33f29d9..5703fd34 100644 --- a/generic/templates/generic/exam_cids.html +++ b/generic/templates/generic/exam_cids.html @@ -159,14 +159,18 @@ // data attributes for cid/user. Then insert a small badge on matching list items. // Collect submitted identifiers first to avoid duplicate processing const submittedEls = document.querySelectorAll('#submitted-candidates [data-cid], #submitted-candidates [data-user]'); - const submittedCids = new Set(); - const submittedUsers = new Set(); + // Build maps of submitted identifiers -> their timestamps so we + // can attach tooltip text to the badges we create below. + const submittedCids = new Map(); + const submittedUsers = new Map(); submittedEls.forEach(function(el){ try{ const cid = el.dataset && el.dataset.cid ? el.dataset.cid.toString() : null; const user = el.dataset && el.dataset.user ? el.dataset.user.toString() : null; - if(cid && cid !== 'None') submittedCids.add(cid); - if(user && user !== 'None') submittedUsers.add(user); + const first = el.dataset && el.dataset.first ? el.dataset.first.toString() : ''; + const last = el.dataset && el.dataset.last ? el.dataset.last.toString() : ''; + if(cid && cid !== 'None') submittedCids.set(cid, {first:first, last:last}); + if(user && user !== 'None') submittedUsers.set(user, {first:first, last:last}); }catch(e){ console.error('highlightSubmitted collect error', e); } }); @@ -177,9 +181,15 @@ const link = li.querySelector && li.querySelector('.cid-link'); const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim(); if(submittedCids.has(text) && !li.querySelector('.submitted-badge')){ + const info = submittedCids.get(text) || {first:'', last:''}; const badge = document.createElement('span'); badge.className = 'submitted-badge badge bg-success ms-2'; badge.textContent = 'Submitted'; + if(info.first || info.last){ + const title = `Submitted\nFirst: ${info.first}\nLast: ${info.last}`; + badge.setAttribute('title', title); + badge.setAttribute('data-bs-toggle', 'tooltip'); + } if(link && link.parentNode){ link.parentNode.appendChild(badge); } else { @@ -198,9 +208,15 @@ for(const user of submittedUsers){ if(txt.indexOf(user) !== -1){ if(!li.querySelector('.submitted-badge')){ + const info = submittedUsers.get(user) || {first:'', last:''}; const badge = document.createElement('span'); badge.className = 'submitted-badge badge bg-success ms-2'; badge.textContent = 'Submitted'; + if(info.first || info.last){ + const title = `Submitted\nFirst: ${info.first}\nLast: ${info.last}`; + badge.setAttribute('title', title); + badge.setAttribute('data-bs-toggle', 'tooltip'); + } const nameEl = li.querySelector('.fw-bold') || li.querySelector('a') || li; nameEl.appendChild(badge); } diff --git a/generic/templates/generic/partials/exam_cids_submitted_list.html b/generic/templates/generic/partials/exam_cids_submitted_list.html index 07d50b95..5f4914f0 100644 --- a/generic/templates/generic/partials/exam_cids_submitted_list.html +++ b/generic/templates/generic/partials/exam_cids_submitted_list.html @@ -23,7 +23,7 @@ {% for user_data in user_exam_data %} - + {{ user_data.cid_user }} {{ user_data.user_user }} {{ user_data.start_time }} diff --git a/generic/views.py b/generic/views.py index cdceb9ea..ca0a0d21 100644 --- a/generic/views.py +++ b/generic/views.py @@ -56,7 +56,7 @@ from reversion.views import RevisionMixin from atlas.models import CaseCollection, CaseDetail from generic.decorators import user_is_cid_user_manager from generic.filters import CidUserFilter, ExaminationFilter, SupervisorFilter -from generic.models import UserUserGroup +from generic.models import UserUserGroup, CidUserExam from generic.tables import ( CidUserExamTable, @@ -2335,9 +2335,23 @@ class ExamViews(View, LoginRequiredMixin): if request.user not in exam.author.all() and not request.user.is_superuser: raise PermissionDenied - user_exam_data = exam.cid_users.all().prefetch_related("cid_user", "user_user") + # Explicitly query the CidUserExam entries for this exam to avoid any + # ambiguity from GenericRelation managers and to ensure we only return + # submissions that belong to this exam instance. + from django.contrib.contenttypes.models import ContentType - return render(request, "generic/partials/exam_cids_submitted_list.html", {"user_exam_data": user_exam_data, "exam": exam}) + ct = ContentType.objects.get_for_model(self.Exam) + user_exam_data = ( + CidUserExam.objects.filter(content_type=ct, object_id=exam.pk) + .select_related("cid_user", "user_user") + .order_by("start_time") + ) + + return render( + request, + "generic/partials/exam_cids_submitted_list.html", + {"user_exam_data": user_exam_data, "exam": exam}, + ) # def exam_groups_edit(self, request, exam_id): # exam = get_object_or_404(self.Exam, pk=exam_id) From 378d784435628eb2b2c88ac0ea0829d97d06125b Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 11:30:53 +0000 Subject: [PATCH 06/24] Enhance submitted candidates display by adding dynamic exam status indicators and improving badge tooltip functionality for better user feedback. --- generic/templates/generic/exam_cids.html | 55 +++++++++---------- .../partials/exam_cids_submitted_list.html | 8 +-- generic/views.py | 19 +++++++ 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/generic/templates/generic/exam_cids.html b/generic/templates/generic/exam_cids.html index 5703fd34..bf48ce6a 100644 --- a/generic/templates/generic/exam_cids.html +++ b/generic/templates/generic/exam_cids.html @@ -174,52 +174,47 @@ }catch(e){ console.error('highlightSubmitted collect error', e); } }); - // Add badges for CID list items + // Helper to ensure a single badge exists inside an li and update its tooltip + function ensureSubmittedBadge(li, title){ + let b = li.querySelector('.submitted-badge'); + if(!b){ + b = document.createElement('span'); + b.className = 'submitted-badge badge bg-success ms-2'; + b.textContent = 'Submitted'; + li.appendChild(b); + } + if(title){ + b.setAttribute('title', title); + b.setAttribute('data-bs-toggle', 'tooltip'); + } + return b; + } + + // Add/update badges for CID list items if(submittedCids.size){ document.querySelectorAll('#cid-list .list-group-item').forEach(function(li){ try{ const link = li.querySelector && li.querySelector('.cid-link'); const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim(); - if(submittedCids.has(text) && !li.querySelector('.submitted-badge')){ + if(submittedCids.has(text)){ const info = submittedCids.get(text) || {first:'', last:''}; - const badge = document.createElement('span'); - badge.className = 'submitted-badge badge bg-success ms-2'; - badge.textContent = 'Submitted'; - if(info.first || info.last){ - const title = `Submitted\nFirst: ${info.first}\nLast: ${info.last}`; - badge.setAttribute('title', title); - badge.setAttribute('data-bs-toggle', 'tooltip'); - } - if(link && link.parentNode){ - link.parentNode.appendChild(badge); - } else { - li.appendChild(badge); - } + const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : ''; + ensureSubmittedBadge(li, title); } }catch(e){ console.error('highlightSubmitted CID item error', e); } }); } - // Add badges for User list items + // Add/update badges for User list items if(submittedUsers.size){ document.querySelectorAll('#user-list .list-group-item').forEach(function(li){ try{ const txt = (li.textContent||'').trim(); - for(const user of submittedUsers){ + for(const user of submittedUsers.keys()){ if(txt.indexOf(user) !== -1){ - if(!li.querySelector('.submitted-badge')){ - const info = submittedUsers.get(user) || {first:'', last:''}; - const badge = document.createElement('span'); - badge.className = 'submitted-badge badge bg-success ms-2'; - badge.textContent = 'Submitted'; - if(info.first || info.last){ - const title = `Submitted\nFirst: ${info.first}\nLast: ${info.last}`; - badge.setAttribute('title', title); - badge.setAttribute('data-bs-toggle', 'tooltip'); - } - const nameEl = li.querySelector('.fw-bold') || li.querySelector('a') || li; - nameEl.appendChild(badge); - } + const info = submittedUsers.get(user) || {first:'', last:''}; + const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : ''; + ensureSubmittedBadge(li, title); break; } } diff --git a/generic/templates/generic/partials/exam_cids_submitted_list.html b/generic/templates/generic/partials/exam_cids_submitted_list.html index 5f4914f0..f6aa259a 100644 --- a/generic/templates/generic/partials/exam_cids_submitted_list.html +++ b/generic/templates/generic/partials/exam_cids_submitted_list.html @@ -23,11 +23,11 @@ {% for user_data in user_exam_data %} - - {{ user_data.cid_user }} + + {{ user_data.cid_user }}{% if not user_data.in_exam %} Not added{% endif %} {{ user_data.user_user }} - {{ user_data.start_time }} - {{ user_data.end_time }} + {{ user_data.start_time }} + {{ user_data.end_time }}
    diff --git a/generic/views.py b/generic/views.py index ca0a0d21..f4c92a23 100644 --- a/generic/views.py +++ b/generic/views.py @@ -2347,6 +2347,25 @@ class ExamViews(View, LoginRequiredMixin): .order_by("start_time") ) + # Determine which submitted entries correspond to candidates/users + # that are actually added to the exam so we can highlight those that + # are not. Collect valid IDs once to avoid per-row queries. + valid_cid_pks = set(exam.valid_cid_users.values_list("pk", flat=True)) + valid_user_pks = set(exam.valid_user_users.values_list("pk", flat=True)) + + for ue in user_exam_data: + in_exam = False + try: + if ue.cid_user_id and ue.cid_user_id in valid_cid_pks: + in_exam = True + if ue.user_user_id and ue.user_user_id in valid_user_pks: + in_exam = True + except Exception: + in_exam = False + + # Attach a dynamic attribute the template can read + setattr(ue, "in_exam", in_exam) + return render( request, "generic/partials/exam_cids_submitted_list.html", From 459b7f9b939b78c6612f5460c7d08931417f89f8 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 11:52:58 +0000 Subject: [PATCH 07/24] Enhance candidate lists by adding badge containers for improved visual feedback on submission status. --- generic/templates/generic/exam_cids.html | 30 +++++++++++++------ .../generic/partials/exam_cids_cid_list.html | 1 + .../generic/partials/exam_cids_user_list.html | 2 +- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/generic/templates/generic/exam_cids.html b/generic/templates/generic/exam_cids.html index bf48ce6a..7d185bef 100644 --- a/generic/templates/generic/exam_cids.html +++ b/generic/templates/generic/exam_cids.html @@ -175,13 +175,15 @@ }); // Helper to ensure a single badge exists inside an li and update its tooltip - function ensureSubmittedBadge(li, title){ - let b = li.querySelector('.submitted-badge'); + function ensureBadge(li, selector, className, text, title){ + // Prefer the explicit badge container if present + const container = li.querySelector('.badge-container') || li; + let b = container.querySelector(selector); if(!b){ b = document.createElement('span'); - b.className = 'submitted-badge badge bg-success ms-2'; - b.textContent = 'Submitted'; - li.appendChild(b); + b.className = className; + b.textContent = text; + container.appendChild(b); } if(title){ b.setAttribute('title', title); @@ -198,8 +200,14 @@ const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim(); if(submittedCids.has(text)){ const info = submittedCids.get(text) || {first:'', last:''}; - const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : ''; - ensureSubmittedBadge(li, title); + // add Started badge if start time exists + if(info.first){ + const t = `Started\nFirst: ${info.first}`; + ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t); + } + // add Submitted badge if end time exists (or always keep submitted) + const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : 'Submitted'; + ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Submitted', title); } }catch(e){ console.error('highlightSubmitted CID item error', e); } }); @@ -213,8 +221,12 @@ for(const user of submittedUsers.keys()){ if(txt.indexOf(user) !== -1){ const info = submittedUsers.get(user) || {first:'', last:''}; - const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : ''; - ensureSubmittedBadge(li, title); + if(info.first){ + const t = `Started\nFirst: ${info.first}`; + ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t); + } + const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : 'Submitted'; + ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Submitted', title); break; } } diff --git a/generic/templates/generic/partials/exam_cids_cid_list.html b/generic/templates/generic/partials/exam_cids_cid_list.html index 0e23d7c4..d5940ace 100644 --- a/generic/templates/generic/partials/exam_cids_cid_list.html +++ b/generic/templates/generic/partials/exam_cids_cid_list.html @@ -22,6 +22,7 @@
    Notes: {{ cid.notes|truncatechars:120 }}
    {% endif %}
    +
{% endfor %} diff --git a/generic/templates/generic/partials/exam_cids_user_list.html b/generic/templates/generic/partials/exam_cids_user_list.html index c754a397..590d89ce 100644 --- a/generic/templates/generic/partials/exam_cids_user_list.html +++ b/generic/templates/generic/partials/exam_cids_user_list.html @@ -18,7 +18,7 @@
Email: {{ user.email }}
-
 
+
{% endfor %} From ba0c380f1833e2665b595a114de63eeb4887a226 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 11:55:03 +0000 Subject: [PATCH 08/24] Enhance candidate lists by restructuring HTML for improved readability and maintainability. --- .../generic/partials/exam_cids_cid_list.html | 50 +++++++++---------- .../generic/partials/exam_cids_user_list.html | 42 ++++++++-------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/generic/templates/generic/partials/exam_cids_cid_list.html b/generic/templates/generic/partials/exam_cids_cid_list.html index d5940ace..1b175710 100644 --- a/generic/templates/generic/partials/exam_cids_cid_list.html +++ b/generic/templates/generic/partials/exam_cids_cid_list.html @@ -1,32 +1,32 @@ {% comment %} Partial: list of CID candidates for HTMX swap {% endcomment %} {% if cid_users %} -
    - {% for cid in cid_users %} -
  • -
    -
    -
    - - {{ cid.cid }} - [{{ cid.passcode }}] - +
      + {% for cid in cid_users %} +
    • +
      +
      +
      + + {{ cid.cid }} + [{{ cid.passcode }}] + +
      +
      {{ cid.name }}
      +
      Email: {{ cid.email }}
      + {% if cid.notes %} +
      Notes: {{ cid.notes|truncatechars:120 }}
      + {% endif %}
      -
      {{ cid.name }}
      -
      Email: {{ cid.email }}
      - {% if cid.notes %} -
      Notes: {{ cid.notes|truncatechars:120 }}
      - {% endif %} +
      -
      -
    -
  • - {% endfor %} -
+ + {% endfor %} + {% else %}

This exam has no CID candidates.

{% endif %} diff --git a/generic/templates/generic/partials/exam_cids_user_list.html b/generic/templates/generic/partials/exam_cids_user_list.html index 590d89ce..714b6332 100644 --- a/generic/templates/generic/partials/exam_cids_user_list.html +++ b/generic/templates/generic/partials/exam_cids_user_list.html @@ -1,27 +1,27 @@ {% comment %} Partial: list of User candidates for HTMX swap {% endcomment %} {% if user_users %} -
    - {% for user in user_users %} -
  • -
    -
    - - {% if request.user.is_superuser %} - - {% endif %} - {{ user.username }} +
      + {% for user in user_users %} +
    • +
      +
      + + {% if request.user.is_superuser %} + + {% endif %} + {{ user.username }} +
      +
      Email: {{ user.email }}
      -
      Email: {{ user.email }}
      -
    -
    -
  • - {% endfor %} -
+
+ + {% endfor %} + {% else %}

This exam has no User candidates.

{% endif %} From f44f3bbcfefb05ce1b18919bc71c3279407ff885 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 11:59:23 +0000 Subject: [PATCH 09/24] Enhance exam navigation by adding a skip button for non-saving question transitions and implementing unsaved changes confirmation. --- .../physics/partials/exam_take_fragment.html | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/physics/templates/physics/partials/exam_take_fragment.html b/physics/templates/physics/partials/exam_take_fragment.html index f0d36e4e..7487cb46 100644 --- a/physics/templates/physics/partials/exam_take_fragment.html +++ b/physics/templates/physics/partials/exam_take_fragment.html @@ -60,7 +60,13 @@ {% endif %} {% if next %} - + + + + + {% else %} {% if not exam.publish_results %} @@ -128,6 +134,58 @@ document.getElementById('goto-button').value = e.currentTarget.dataset.qn; $('#goto-button').click(); }); + + // Skip confirmation: if the user's current selections differ from + // the saved answer, prompt before allowing the HTMX skip to proceed. + (function(){ + const skip = document.getElementById('skip-button'); + if(!skip) return; + + // Build a JS array of saved answers (booleans) or null + {% if saved_answer %} + const savedAnswers = [{% for s in saved_answer %}{{ s|yesno:"true,false" }}{% if not forloop.last %}, {% endif %}{% endfor %}]; + {% else %} + const savedAnswers = null; + {% endif %} + + function readCurrent(){ + const lis = document.querySelectorAll('ol.physics-answer-list li'); + return Array.from(lis).map(li => { + const inp = li.querySelector('input'); + return !!(inp && inp.checked); + }); + } + + skip.addEventListener('click', function(evt){ + try{ + const current = readCurrent(); + let changed = false; + if(savedAnswers === null){ + // no previously-saved answer; any current selection is a change + changed = current.some(Boolean); + } else { + if(current.length === savedAnswers.length){ + for(let i=0;i
From e4cfb1f782a9b85946f93eaf72b71aa58f23269e Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 12:32:14 +0000 Subject: [PATCH 10/24] Enhance exam navigation by adding a confirmation modal for unsaved changes when skipping questions. --- .../physics/partials/exam_take_fragment.html | 193 ++++++++++++++++-- 1 file changed, 177 insertions(+), 16 deletions(-) diff --git a/physics/templates/physics/partials/exam_take_fragment.html b/physics/templates/physics/partials/exam_take_fragment.html index 7487cb46..bf696598 100644 --- a/physics/templates/physics/partials/exam_take_fragment.html +++ b/physics/templates/physics/partials/exam_take_fragment.html @@ -1,7 +1,7 @@
+ hx-post="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos %}{% endif %}" + hx-target="#question-fragment" hx-swap="innerHTML"> {% csrf_token %} {{ form.non_field_errors }} @@ -60,13 +60,13 @@ {% endif %} {% if next %} - + - - + {% else %} {% if not exam.publish_results %} @@ -114,7 +114,7 @@ if (el == li_el.find('input').get(0).checked) { li_el.addClass('answer-correct'); } else { - li_el.addClass('answer-incorrect'); + li_el.addClass('answer-incorrect'); } }); @@ -130,10 +130,99 @@ $('#menu-list').append(qbutton); } - $('button.question-menu-item').on('click', (e) => { - document.getElementById('goto-button').value = e.currentTarget.dataset.qn; - $('#goto-button').click(); - }); + // Template URL for loading a fragment — use sk=0 as placeholder to replace later + const fragUrlTemplate = `{% if cid %}{% url 'physics:exam_take_fragment' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_fragment_user' pk=exam.pk sk=0 %}{% endif %}`; + + // Use event delegation on the menu list to reliably catch clicks + (function(){ + const menu = document.getElementById('menu-list'); + if(!menu) return; + if(menu.dataset.delegateBound) return; // idempotent + menu.dataset.delegateBound = '1'; + menu.addEventListener('click', function(e){ + const btn = e.target.closest && e.target.closest('.question-menu-item'); + if(!btn) return; + const destIndex = btn.dataset.qn; + try{ + const allFalse = Array.from(document.querySelectorAll('ol.physics-answer-list li')).every(li => { + const inp = li.querySelector('input'); + return !(inp && inp.checked); + }); + + if(allFalse){ + const save = confirm('All answers are false. OK to save these answers, Cancel to skip without saving.'); + if(save){ + const form = document.querySelector('.post-form'); + if(form){ + const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp); + const onSaved = function(){ document.removeEventListener('saved', onSaved); document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); }; + document.addEventListener('saved', onSaved); + tmp.click(); + setTimeout(function(){ document.removeEventListener('saved', onSaved); document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); }, 1500); + return; + } + } + + // Skip without saving: load fragment directly via HTMX and update URL + try{ + const url = fragUrlTemplate.replace('/0/', '/' + destIndex + '/'); + if(window.htmx && typeof htmx.ajax === 'function'){ + htmx.ajax('GET', url, {target: '#question-fragment', swap: 'innerHTML'}); + } else { + window.location.href = url; + } + // update browser URL to match the question destination + try{ + const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/'); + history.replaceState(null, '', pageUrl); + }catch(e){} + }catch(e){ + document.getElementById('goto-button').value = destIndex; + $('#goto-button').click(); + } + return; + } + }catch(ex){ console && console.error && console.error(ex); } + + // Default: perform the form-based navigation (which will save) + document.getElementById('goto-button').value = destIndex; + $('#goto-button').click(); + }); + })(); + + // Intercept Overview button to offer Save or Skip when all answers false + (function(){ + const overview = document.getElementById('overview-button'); + if(!overview) return; + const overviewUrl = `{% if cid %}{% url 'physics:exam_take_overview' pk=exam.pk cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_overview_user' pk=exam.pk %}{% endif %}`; + overview.addEventListener('click', function(evt){ + try{ + const allFalse = Array.from(document.querySelectorAll('ol.physics-answer-list li')).every(li => { + const inp = li.querySelector('input'); + return !(inp && inp.checked); + }); + if(!allFalse) return; // allow normal submit (which will save) + + // prevent the default submit while we prompt + evt.preventDefault(); evt.stopImmediatePropagation(); + openSaveSkipModal('All answers are false. Save these answers or skip without saving?').then(function(choice){ + if(choice === 'save'){ + const form = document.querySelector('.post-form'); + if(form){ + const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp); + const onSaved = function(){ document.removeEventListener('saved', onSaved); window.location = overviewUrl; }; + document.addEventListener('saved', onSaved); + tmp.click(); + setTimeout(function(){ document.removeEventListener('saved', onSaved); window.location = overviewUrl; }, 1500); + return; + } + } + // skip or cancel -> navigate without saving + window.location = overviewUrl; + }).catch(function(){ window.location = overviewUrl; }); + }catch(ex){ console && console.error && console.error(ex); } + }); + })(); // Skip confirmation: if the user's current selections differ from // the saved answer, prompt before allowing the HTMX skip to proceed. @@ -175,12 +264,28 @@ } if(changed){ - const ok = confirm('You have unsaved changes. Skip to the next question without saving?'); - if(!ok){ - // prevent HTMX from handling the click - evt.preventDefault(); evt.stopImmediatePropagation(); - return false; - } + evt.preventDefault(); evt.stopImmediatePropagation(); + openSaveSkipModal('You have unsaved changes. Skip to the next question without saving?').then(function(choice){ + if(choice === 'skip'){ + // trigger the HTMX GET on the button by invoking its click handler programmatically + const hxGet = skip.getAttribute('hx-get'); + if(hxGet){ + if(window.htmx && typeof htmx.ajax === 'function'){ + htmx.ajax('GET', hxGet, {target: '#question-fragment', swap: 'innerHTML'}); + } else { + window.location.href = hxGet; + } + } + } else if(choice === 'save'){ + const form = document.querySelector('.post-form'); + if(form){ + const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp); + tmp.click(); + } + } + // cancel => do nothing + }).catch(function(){/* ignore */}); + return false; } // allow the HTMX attribute on the button to proceed }catch(e){ console && console.error && console.error(e); } @@ -188,4 +293,60 @@ })(); })(); + + + +
From bf344f20e1619796f4ae13069481918d9d8f623f Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 5 Jan 2026 13:47:45 +0000 Subject: [PATCH 11/24] Add flagging functionality for exam questions with a reusable Flag model and toggle feature --- generic/migrations/0031_flag.py | 32 ++++++ generic/models.py | 42 ++++++++ .../physics/partials/exam_flag_button.html | 17 ++++ .../physics/partials/exam_take_fragment.html | 93 +++++++++--------- physics/urls.py | 11 +++ physics/views.py | 98 ++++++++++++++++++- 6 files changed, 246 insertions(+), 47 deletions(-) create mode 100644 generic/migrations/0031_flag.py create mode 100644 physics/templates/physics/partials/exam_flag_button.html diff --git a/generic/migrations/0031_flag.py b/generic/migrations/0031_flag.py new file mode 100644 index 00000000..e2887c2d --- /dev/null +++ b/generic/migrations/0031_flag.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.7 on 2026-01-05 12:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('generic', '0030_ciduser_notes'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Flag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('note', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('cid_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to='generic.ciduser')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/generic/models.py b/generic/models.py index 6b973fb2..2110b507 100644 --- a/generic/models.py +++ b/generic/models.py @@ -1704,6 +1704,48 @@ class CidUserExam(models.Model): self.save() +class Flag(models.Model): + """A reusable flag model that can be attached to any object via a + GenericForeignKey. Useful for marking questions, items or other objects + across apps. + """ + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + # Optional actor who created the flag (either a user or a CID user) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="flags", + ) + cid_user = models.ForeignKey( + "CidUser", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="flags", + ) + + note = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + actor = None + if self.user: + actor = f"user:{self.user.username}" + elif self.cid_user: + actor = f"cid:{self.cid_user.cid}" + else: + actor = "anon" + return f"Flag [{actor}] -> {self.content_type}({self.object_id})" + + CID_GROUP_EXAMS = ( ("SBAs", "sba_cid_user_groups"), ("Physics", "physics_cid_user_groups"), diff --git a/physics/templates/physics/partials/exam_flag_button.html b/physics/templates/physics/partials/exam_flag_button.html new file mode 100644 index 00000000..e5d0cfec --- /dev/null +++ b/physics/templates/physics/partials/exam_flag_button.html @@ -0,0 +1,17 @@ +{% comment %}A small flag/unflag button partial. Re-rendered by HTMX when toggled.{% endcomment %} +
+ {% load static %} + {% if flagged %} + + {% csrf_token %} + + + + {% else %} +
+ {% csrf_token %} + + +
+ {% endif %} +
diff --git a/physics/templates/physics/partials/exam_take_fragment.html b/physics/templates/physics/partials/exam_take_fragment.html index bf696598..4a4286f8 100644 --- a/physics/templates/physics/partials/exam_take_fragment.html +++ b/physics/templates/physics/partials/exam_take_fragment.html @@ -55,6 +55,9 @@
+
+ {% include 'physics/partials/exam_flag_button.html' %} +
{% if previous > -1 %} {% endif %} @@ -294,59 +297,59 @@ })(); -