+ {{ 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/templates/generic/partials/exam_cids_user_list.html b/generic/templates/generic/partials/exam_cids_user_list.html
index c754a397..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 %}
diff --git a/generic/views.py b/generic/views.py
index cdceb9ea..f4c92a23 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,42 @@ 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")
+ )
+
+ # 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",
+ {"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)
diff --git a/physics/templates/physics/exam_take.html b/physics/templates/physics/exam_take.html
index f617a2d0..ec90c304 100755
--- a/physics/templates/physics/exam_take.html
+++ b/physics/templates/physics/exam_take.html
@@ -12,10 +12,15 @@
{% include "exam_clock.html" %}
-
- {{ exam }}: Question
- [{{ pos|add:1 }} /{{ exam_length }} ]
-
+
+
+ {{ exam }}: Question
+ [{{ pos|add:1 }} /{{ exam_length }} ]
+
+
+
+
+
{% if exam.publish_results %}
@@ -31,7 +36,7 @@
-
+
Questions
+
+
+
+
+
{% include "physics/exam_take_help.html" %}
{% endblock %}
@@ -80,20 +187,20 @@
.exam-take-container { max-width: 980px; margin: 0 auto; padding: 1rem; }
.no-select { user-select: none; }
- .flex-container { display: flex; align-items: center; gap: 0.75rem; }
+ .flex-container { display: flex; align-items: center; gap: 0.75rem; }
/* fieldWrapper is used around each label + input; ensure it stretches the full width */
- .fieldWrapper { width: 100%; display: flex; align-items: center; gap: 0.75rem; }
+ .fieldWrapper { width: 100%; display: flex; align-items: center; gap: 0.75rem; }
/* We keep two main columns inside each fieldWrapper:
- the label (question-text) which should take the bulk of space and sit to the left on wide screens
- the input container (.flex-1) which holds the input and a post-input label; the post-input should float right
*/
- .flex-8 { flex: 8; }
- .flex-1 { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
+ .flex-8 { flex: 8; }
+ .flex-1 { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
/* Left-align label text on wide screens so the question text appears on the left, input between, and the
post-input label floats to the right. Padding keeps visual separation. */
- .question-text { margin: 0; text-align: left; padding-right: 0.5rem; }
+ .question-text { margin: 0; text-align: left; padding-right: 0.5rem; }
/* Answer items styled as cards */
.physics-answer-list { list-style: none; padding: 0; margin: 0; }
@@ -113,8 +220,8 @@
.question-text { margin: 0; }
/* Post-input badge (shows True/False text next to inputs) */
- .postinput { display: inline-block; min-width: 3rem; text-align: right; color: var(--muted); }
- .physics-answer-list input { margin: 0 0.25rem 0 0; }
+ .postinput { display: inline-block; min-width: 3rem; text-align: right; color: var(--muted); }
+ .physics-answer-list input { margin: 0 0.25rem 0 0; }
/* show a compact post-label on the right of the input container */
.physics-answer-list input:checked + .postinput::after {
content: 'True'; color: var(--success); font-weight: 600; margin-left: 0.4rem;
@@ -172,15 +279,15 @@
.mt-2 .save.btn.btn-default { background: var(--feedback-bg); border: 1px solid var(--card-border); color: var(--text); }
/* Overview button - make it stand out using the primary color */
- #overview-button { background: var(--primary); border-color: var(--primary); color: #fff; }
- #overview-button:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
+ #overview-button { background: var(--primary); border-color: var(--primary); color: #fff; }
+ #overview-button:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
/* Previous / Next button colors */
- .mt-2 button[name="previous"] { background: var(--info); border-color: var(--info); color: #fff; }
- .mt-2 button[name="previous"]:hover { filter: brightness(0.95); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
+ .mt-2 button[name="previous"] { background: var(--info); border-color: var(--info); color: #fff; }
+ .mt-2 button[name="previous"]:hover { filter: brightness(0.95); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
- .mt-2 button[name="next"] { background: var(--info); border-color: var(--info); color: #fff; }
- .mt-2 button[name="next"]:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
+ .mt-2 button[name="next"] { background: var(--info); border-color: var(--info); color: #fff; }
+ .mt-2 button[name="next"]:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
/* Small helper: make labels wrap nicely on small screens */
@media (max-width: 576px) {
@@ -192,81 +299,128 @@
.postinput { margin-top: 0.4rem; text-align: left; }
.physics-answer-list li { padding: 0.6rem; }
}
+ /* Question menu item states (small pill buttons) */
+ button.question-menu-item {
+ color: var(--text);
+ border: 1px solid var(--card-border);
+ background: var(--card-bg);
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.35rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ }
+ button.question-menu-item.answered {
+ border-color: var(--success) !important; /* answered -> success border */
+ }
+ /* flagged items show an icon only; no border change here */
+ button.question-menu-item .question-flag {
+ margin-left: 0.25rem;
+ font-size: 0.85em;
+ color: #ffc107;
+ line-height: 1;
+ }
+ /* Loading blur + spinner for question fragment */
+ #question-fragment.loading { filter: blur(3px); opacity: 0.9; pointer-events: none; position: relative; }
+ #question-fragment .loading-spinner {
+ position: absolute;
+ left: 50%; top: 50%; transform: translate(-50%, -50%);
+ width: 36px; height: 36px; border-radius: 50%;
+ border: 4px solid rgba(255,255,255,0.2); border-top-color: var(--primary);
+ animation: spin 800ms linear infinite; z-index: 1200;
+ }
+ @keyframes spin { from { transform: translate(-50%,-50%) rotate(0deg); } to { transform: translate(-50%,-50%) rotate(360deg); } }
{% endblock %}
+{% block head_js %}{% endblock %}
+
{% block js %}
-
-
+
+ setTimeout(function(){ el.style.opacity = '0'; }, 1600);
+ // Update the question list state locally so the menu reflects answered questions
+ try {
+ var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : (document.getElementById('question-number') ? parseInt(document.getElementById('question-number').textContent, 10) - 1 : NaN);
+ if (!isNaN(pos)) {
+ window.examQuestionAnswered = window.examQuestionAnswered || new Array({{ exam_length }}).fill(false);
+ window.examQuestionAnswered[pos] = true;
+ if(typeof window.updateQuestionMenu === 'function'){
+ window.updateQuestionMenu();
+ } else {
+ var btn = document.querySelector('#menu-list [data-qn="' + pos + '"]');
+ if (btn) btn.classList.add('answered');
+ }
+ }
+ } catch (e) { console && console.error && console.error(e); }
+ } catch (e) { console && console.error && console.error(e); }
+ });
+
{% endblock %}
diff --git a/physics/templates/physics/exam_take_overview.html b/physics/templates/physics/exam_take_overview.html
index 6e6cb7b4..102ce95c 100644
--- a/physics/templates/physics/exam_take_overview.html
+++ b/physics/templates/physics/exam_take_overview.html
@@ -2,11 +2,11 @@
{% block content %}
- {% if not cid %}
- User: {{request.user}}
- {% else %}
- CID: {{cid}}
- {% endif %}
+ {% if not cid %}
+ User: {{request.user}}
+ {% else %}
+ CID: {{cid}}
+ {% endif %}
{% include "exam_clock.html" %}
@@ -14,7 +14,7 @@
{% if exam.publish_results %}
- Exam is in review mode. Score are available
+ Exam is in review mode. Score are available
{% if request.user.is_authenticated %}
here
@@ -24,7 +24,7 @@
{% elif cid_user_exam.completed %}
- Exam completed. Answers are available
+ Exam completed. Answers are available
{% if request.user.is_authenticated %}
here
@@ -37,35 +37,37 @@
You have unanswered questions.
{% endif %}
-
+
{{answer_count}} out of {{exam_length}} questions answered. Unanswered questions are shown in red. Click any tile to jump to that question.
- Start time: {{cid_user_exam.start_time}}
- Last change time: {{cid_user_exam.end_time}}
- Completed: {{cid_user_exam.completed}}
+ Start time: {{cid_user_exam.start_time}}
+ Last change time: {{cid_user_exam.end_time}}
+ Completed: {{cid_user_exam.completed}}
{% if not cid_user_exam.completed %}
@@ -97,62 +99,80 @@
{% endblock %}
{% block css %}
-
+
+ @media (max-width: 575.98px) {
+ .physics-finish-list {
+ /* On very small screens allow slightly narrower tiles but still wrap */
+ grid-template-columns: repeat(auto-fit, minmax(70px, 110px));
+ }
+ .overview-question-btn { font-size: .95rem }
+ }
+
{% endblock %}
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 %}
+
+ {% else %}
+
+ {% endif %}
+
diff --git a/physics/templates/physics/partials/exam_take_fragment.html b/physics/templates/physics/partials/exam_take_fragment.html
index f0d36e4e..dc8626b3 100644
--- a/physics/templates/physics/partials/exam_take_fragment.html
+++ b/physics/templates/physics/partials/exam_take_fragment.html
@@ -1,7 +1,8 @@
+
- {% if previous > -1 %}
-
Previous
- {% endif %}
+
+
+ {% if previous > -1 %}
+
Previous
+ {% endif %}
- {% if next %}
-
Next
- {% else %}
- {% if not exam.publish_results %}
-
Save
- {% endif %}
- {% endif %}
+ {% if next %}
+
Next
+
+
Skip
-
-
Overview
-
goto
+
+
+ {% else %}
+ {% if not exam.publish_results %}
+
Save
+ {% endif %}
+ {% endif %}
+
+
+
Overview
+
goto
+
+
+
+ {% include 'physics/partials/exam_flag_button.html' %}
+
+
@@ -80,11 +96,21 @@
try {
var qnum = document.getElementById('question-number');
if (qnum) { qnum.textContent = '{{ pos|add:"1" }}'; }
+ // expose current question index for global listeners
+ window.currentQuestionPos = {{ pos }};
var qstem = document.getElementById('question-stem');
if (qstem) { qstem.innerHTML = '{{ question.stem|escapejs }}'; }
} catch (err) {
console.warn('Could not update global question header from fragment', err);
}
+ // expose current question index for global listeners
+ window.currentQuestionPos = {{ pos }};
+ // Update the browser URL to reflect the current question (so Prev/Next/Skip update URL)
+ try {
+ const pageUrlTemplate = `{% 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 %}`;
+ const pageUrl = pageUrlTemplate.replace('/0/', '/' + window.currentQuestionPos + '/');
+ history.replaceState(null, '', pageUrl);
+ } catch (e) { /* ignore */ }
{% if not exam.publish_results and not cid_user_exam.completed %}
let time_limit = '{{ exam.time_limit }}';
if (time_limit != 'None') {
@@ -108,7 +134,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');
}
});
@@ -118,16 +144,289 @@
// build the question menu locally so it is available after fragment swaps
$('#menu-list').empty();
+ const serverAnswered = {{ answered_json|default:'null'|safe }};
+ const serverFlagged = {{ flagged_json|default:'null'|safe }};
+ // Always refresh client-side arrays from server-provided state when fragment loads
+ if (Array.isArray(serverAnswered)) {
+ window.examQuestionAnswered = serverAnswered;
+ }
+ if (Array.isArray(serverFlagged)) {
+ window.examQuestionFlagged = serverFlagged;
+ }
+ const answeredArr = (window.examQuestionAnswered && Array.isArray(window.examQuestionAnswered)) ? window.examQuestionAnswered : null;
+ const flaggedArr = (window.examQuestionFlagged && Array.isArray(window.examQuestionFlagged)) ? window.examQuestionFlagged : null;
for (let i = 0; i < {{ exam_length }}; i++) {
- const qbutton = $(``);
+ const qbutton = $(``);
+ if (answeredArr && answeredArr[i]) { qbutton.addClass('answered'); }
+ if (flaggedArr && flaggedArr[i]) {
+ qbutton.addClass('flagged');
+ // ensure flag icon exists only once
+ if (qbutton.find('.question-flag').length === 0) qbutton.append('
⚑ ');
+ }
if (i == {{ pos }}) { qbutton.addClass('current-question'); }
$('#menu-list').append(qbutton);
}
- $('button.question-menu-item').on('click', (e) => {
- document.getElementById('goto-button').value = e.currentTarget.dataset.qn;
- $('#goto-button').click();
- });
+ // Let a central updater ensure classes are consistent (handles cases where menu exists before arrays)
+ if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
+
+ // 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){
+ // Use modal prompt instead of native confirm
+ try{
+ // prevent default while we prompt
+ e.preventDefault(); e.stopImmediatePropagation();
+ }catch(ex){}
+ 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);
+
+ // One-time htmx listener to detect the save response and then navigate
+ let handled = false;
+ function htmxHandler(evt){
+ try{
+ const xhr = evt.detail.xhr;
+ const hxTrigger = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Trigger');
+ if(hxTrigger && hxTrigger.indexOf('saved') !== -1){
+ handled = true;
+ if(window.htmx && typeof htmx.off === 'function') htmx.off('htmx:afterRequest', htmxHandler);
+ 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 + '/');
+ window.location = pageUrl;
+ }catch(e){
+ if(document.getElementById('goto-button')){
+ document.getElementById('goto-button').value = destIndex;
+ $('#goto-button').click();
+ }
+ }
+ }
+ }catch(e){/* ignore */}
+ }
+
+ if(window.htmx && typeof htmx.on === 'function'){
+ htmx.on('htmx:afterRequest', htmxHandler);
+ }
+
+ // As a fallback listen for the custom 'saved' event
+ const onSavedFallback = function(){ if(handled) return; handled = true; document.removeEventListener('saved', onSavedFallback); 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 + '/'); window.location = pageUrl; }catch(e){ if(document.getElementById('goto-button')){ document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); } } };
+ document.addEventListener('saved', onSavedFallback);
+
+ tmp.click();
+
+ // final timeout fallback
+ setTimeout(function(){ if(!handled){ try{ document.removeEventListener('saved', onSavedFallback); if(window.htmx && typeof htmx.off === 'function') htmx.off('htmx:afterRequest', htmxHandler); 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 + '/'); window.location = pageUrl; }catch(e){ try{ if(document.getElementById('goto-button')){ document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); } }catch(e){} } } }, 1500);
+ return;
+ }
+ }
+
+ // skip or cancel -> 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;
+ }
+ 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();
+ }
+ }).catch(function(){
+ // on error, fallback to navigation
+ 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.
+ (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
do nothing
+ }).catch(function(){/* ignore */});
+ return false;
+ }
+ // allow the HTMX attribute on the button to proceed
+ }catch(e){ console && console.error && console.error(e); }
+ });
+ })();
})();
+
+
+
+
diff --git a/physics/urls.py b/physics/urls.py
index d3355101..d848c131 100644
--- a/physics/urls.py
+++ b/physics/urls.py
@@ -35,6 +35,17 @@ urlpatterns.extend(
views.exam_take_fragment,
name="exam_take_fragment",
),
+ # Flag toggle (HTMX)
+ path(
+ "exam/
//toggle_flag",
+ views.exam_toggle_flag,
+ name="exam_toggle_flag_user",
+ ),
+ path(
+ "exam/////toggle_flag",
+ views.exam_toggle_flag,
+ name="exam_toggle_flag",
+ ),
path("exam//start", views.exam_start, name="exam_start"),
path(
"exam////finish",
diff --git a/physics/views.py b/physics/views.py
index dbcb21e7..77eca3a5 100644
--- a/physics/views.py
+++ b/physics/views.py
@@ -1,6 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
-from generic.models import CidUser, ExamUserStatus, CidUserExam
+from generic.models import CidUser, ExamUserStatus, CidUserExam, Flag
from physics.decorators import user_is_author_or_physics_checker
from physics.filters import QuestionFilter, UserAnswerFilter
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
@@ -179,19 +179,29 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
else:
answers = UserAnswer.objects.filter(user=request.user, exam=exam)
- answer_question_map = {}
- for ans in answers:
- answer_question_map[ans.question] = ans
+ # Map answers by question id for quick lookup
+ answer_question_map = {ans.question_id: ans for ans in answers}
+
+ # Prepare bulk flagged set to avoid per-question queries
+ q_ids = [q.pk for q in questions]
+ try:
+ ct = ContentType.objects.get_for_model(Question)
+ if cid is not None:
+ flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, cid_user__cid=cid)
+ else:
+ flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, user=request.user)
+ flagged_set = set(flags_qs.values_list('object_id', flat=True))
+ except Exception:
+ flagged_set = set()
question_answer_tuples = []
answer_count = 0
for q in questions:
- # if q in answer_question_map and answer_question_map[q].answer:
- if q in answer_question_map: # might need to improve this
- question_answer_tuples.append((q, answer_question_map[q]))
+ ans = answer_question_map.get(q.pk)
+ if ans is not None:
answer_count += 1
- else:
- question_answer_tuples.append((q, None))
+ flagged = q.pk in flagged_set
+ question_answer_tuples.append((q, ans, flagged))
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
@@ -352,6 +362,27 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
if answer is not None:
saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]
+ # compute flagged state for this question+actor
+ try:
+ ct = ContentType.objects.get_for_model(question)
+ flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
+ if cid is not None:
+ flags_qs = flags_qs.filter(cid_user__cid=cid)
+ else:
+ flags_qs = flags_qs.filter(user=request.user)
+ flagged = flags_qs.exists()
+ except Exception:
+ flagged = False
+
+ # Precompute answered and flagged sets in bulk to avoid repeated queries
+ q_ids = [q.pk for q in questions]
+ if cid is not None:
+ answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
+ flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
+ else:
+ answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
+ flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))
+
return render(
request,
"physics/exam_take.html",
@@ -367,6 +398,11 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
"saved_answer": saved_answer,
"passcode": passcode,
"cid_user_exam": cid_user_exam,
+ "flagged": flagged,
+ # minimal per-question status for client-side menu: answered/flagged lists
+ # compute answered/flagged sets in bulk to avoid N+1 queries
+ "answered_json": json.dumps([q.pk in answered_set for q in questions]),
+ "flagged_json": json.dumps([q.pk in flagged_set for q in questions]),
},
)
@@ -416,6 +452,18 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
form = UserAnswerForm()
saved_answer = False
+ # compute flagged state for this question+actor so fragment renders correctly
+ try:
+ ct = ContentType.objects.get_for_model(question)
+ flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
+ if cid is not None:
+ flags_qs = flags_qs.filter(cid_user__cid=cid)
+ else:
+ flags_qs = flags_qs.filter(user=request.user)
+ flagged = flags_qs.exists()
+ except Exception:
+ flagged = False
+
previous = -1
if sk > 0:
previous = sk - 1
@@ -423,6 +471,15 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
if sk == exam_length - 1:
next = False
+ # Bulk compute answered and flagged sets for the fragment
+ q_ids = [q.pk for q in questions]
+ if cid is not None:
+ frag_answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
+ frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
+ else:
+ frag_answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
+ frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))
+
return render(
request,
"physics/partials/exam_take_fragment.html",
@@ -435,13 +492,84 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
"previous": previous,
"exam_length": exam_length,
"pos": pos,
- "saved_answer": saved_answer,
+ "saved_answer": saved_answer,
+ "answer": answer,
+ "flagged": flagged,
+ "answered_json": json.dumps([q.pk in frag_answered_set for q in questions]),
+ "flagged_json": json.dumps([q.pk in frag_flagged_set for q in questions]),
"passcode": passcode,
"cid_user_exam": cid_user_exam,
},
)
+@login_required
+def exam_toggle_flag(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
+ """Toggle the flagged state for the current user's answer to a question.
+
+ Returns the small flag-button partial so HTMX clients can swap it in-place.
+ """
+ exam = get_object_or_404(Exam, pk=pk)
+
+ if not exam.active:
+ return exam_inactive(request, context={"exam": exam})
+
+ exam.check_user_can_take(cid, passcode, request.user)
+
+ # canonical questions list
+ questions = list(exam.get_questions())
+ try:
+ index = int(sk)
+ except Exception:
+ raise Http404("Invalid question index")
+
+ if index < 0 or index >= len(questions):
+ raise Http404("Question not found in exam")
+
+ question = questions[index]
+
+ # Determine actor: either a CidUser (for cid flows) or the logged-in user
+ cid_user_obj = None
+ if cid is not None:
+ try:
+ cid_user_obj = CidUser.objects.filter(cid=cid).first()
+ except Exception:
+ cid_user_obj = None
+
+ # Find existing flag for this question+actor
+ ct = ContentType.objects.get_for_model(question)
+ flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
+ if cid_user_obj is not None:
+ flags_qs = flags_qs.filter(cid_user=cid_user_obj)
+ else:
+ flags_qs = flags_qs.filter(user=request.user)
+
+ flagged = flags_qs.exists()
+
+ if request.method == "POST":
+ # set param explicitly controls state; otherwise toggle
+ set_val = request.POST.get("set")
+ if set_val is None:
+ # toggle
+ if flagged:
+ flags_qs.delete()
+ flagged = False
+ else:
+ Flag.objects.create(content_type=ct, object_id=question.pk, user=(None if cid_user_obj else request.user), cid_user=cid_user_obj)
+ flagged = True
+ else:
+ desired = str(set_val).lower() in ("1", "true", "yes")
+ if desired and not flagged:
+ Flag.objects.create(content_type=ct, object_id=question.pk, user=(None if cid_user_obj else request.user), cid_user=cid_user_obj)
+ flagged = True
+ elif not desired and flagged:
+ flags_qs.delete()
+ flagged = False
+
+ # Render the partial button for the current state
+ return render(request, "physics/partials/exam_flag_button.html", {"flagged": flagged, "exam": exam, "pos": index, "cid": cid, "passcode": passcode})
+
+
# def loadJsonAnswer(answer):
# # As access is not restricted make sure the data appears valid
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
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 %}
-
+
Start
{% endif %}