Merge branch 'part12026live' into master

This commit is contained in:
Ross
2026-01-05 20:55:29 +00:00
15 changed files with 1086 additions and 244 deletions
+3
View File
@@ -786,6 +786,9 @@ def mark2_overview(request, pk):
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2.""" """Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
exam = get_object_or_404(Exam, pk=pk) 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: if not exam.exam_mode:
raise Http404("Packet not in exam mode") raise Http404("Packet not in exam mode")
+32
View File
@@ -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',),
},
),
]
+42
View File
@@ -1704,6 +1704,48 @@ class CidUserExam(models.Model):
self.save() 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 = ( CID_GROUP_EXAMS = (
("SBAs", "sba_cid_user_groups"), ("SBAs", "sba_cid_user_groups"),
("Physics", "physics_cid_user_groups"), ("Physics", "physics_cid_user_groups"),
+121 -19
View File
@@ -15,8 +15,10 @@
<div class="card h-100"> <div class="card h-100">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">CID candidates <small class="text-muted">({{ cid_user_count }})</small></h5> <h5 class="card-title">CID candidates <small class="text-muted">({{ cid_user_count }})</small></h5>
<div class="mb-2"> <div class="mb-2 input-group input-group-sm">
<input id="cid-filter" class="form-control form-control-sm" placeholder="Filter CID candidates by CID, name, email or passcode"> <input id="cid-filter" class="form-control form-control-sm" placeholder="Filter CID candidates by CID, name, email or passcode">
<button type="button" id="cid-filter-clear" class="btn btn-outline-secondary d-none" title="Clear filter">×</button>
<span id="cid-filter-indicator" class="input-group-text d-none">Filtered</span>
</div> </div>
<div id="htmx-info"></div> <div id="htmx-info"></div>
<div id="cid-list" hx-get="{% url exam.app_name|add:':exam_cids_cid_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML"> <div id="cid-list" hx-get="{% url exam.app_name|add:':exam_cids_cid_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
@@ -52,8 +54,10 @@
<div class="card h-100"> <div class="card h-100">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">User candidates <small class="text-muted">({{ user_user_count }})</small></h5> <h5 class="card-title">User candidates <small class="text-muted">({{ user_user_count }})</small></h5>
<div class="mb-2"> <div class="mb-2 input-group input-group-sm">
<input id="user-filter" class="form-control form-control-sm" placeholder="Filter User candidates by username or email"> <input id="user-filter" class="form-control form-control-sm" placeholder="Filter User candidates by username or email">
<button type="button" id="user-filter-clear" class="btn btn-outline-secondary d-none" title="Clear filter">×</button>
<span id="user-filter-indicator" class="input-group-text d-none">Filtered</span>
</div> </div>
<div id="user-list" hx-get="{% url exam.app_name|add:':exam_cids_user_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML"> <div id="user-list" hx-get="{% url exam.app_name|add:':exam_cids_user_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
@@ -122,16 +126,26 @@
function normalizeText(s){ return (s||'').toString().toLowerCase(); } function normalizeText(s){ return (s||'').toString().toLowerCase(); }
function filterList(inputElem, listSelector){ function filterList(inputElem, listSelector, indicatorId, clearBtnId){
const q = normalizeText(inputElem.value.trim()); const q = normalizeText(inputElem.value.trim());
document.querySelectorAll(listSelector).forEach(function(li){ document.querySelectorAll(listSelector).forEach(function(li){
const text = normalizeText(li.textContent); const text = normalizeText(li.textContent || '');
if(!q || text.indexOf(q) !== -1){ if(!q || text.indexOf(q) !== -1){
li.style.display = ''; try{ li.style.removeProperty('display'); }catch(e){}
li.classList.remove('d-none');
} else { } 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(){ function initTooltips(){
@@ -141,32 +155,112 @@
} }
function highlightSubmitted(){ 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]');
// 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{ try{
const cid = el.dataset.cid; const cid = el.dataset && el.dataset.cid ? el.dataset.cid.toString() : null;
const user = el.dataset.user; const user = el.dataset && el.dataset.user ? el.dataset.user.toString() : null;
if(cid && cid !== 'None'){ const first = el.dataset && el.dataset.first ? el.dataset.first.toString() : '';
document.querySelectorAll(`.list-group-item`).forEach(function(li){ 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); }
});
// Helper to ensure a single badge exists inside an li and update its tooltip
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 = className;
b.textContent = text;
container.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{ try{
const link = li.querySelector('.cid-link'); const link = li.querySelector && li.querySelector('.cid-link');
if(link && link.textContent.trim() === cid.toString()) li.classList.add('submitted'); const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim();
}catch(e){} if(submittedCids.has(text)){
const info = submittedCids.get(text) || {first:'', last:''};
// 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); }
}); });
} }
if(user && user !== 'None'){
document.querySelectorAll('.list-group-item').forEach(function(li){ if(li.textContent.includes(user)) li.classList.add('submitted'); }); // 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.keys()){
if(txt.indexOf(user) !== -1){
const info = submittedUsers.get(user) || {first:'', last:''};
if(info.first){
const t = `Started\nFirst: ${info.first}`;
ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t);
} }
}catch(e){ console.error(e); } 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;
}
}
}catch(e){ console.error('highlightSubmitted User item error', e); }
}); });
} }
}
// Wire filters // Wire filters
document.addEventListener('DOMContentLoaded', function(){ document.addEventListener('DOMContentLoaded', function(){
const cidFilter = document.getElementById('cid-filter'); 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'); 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) // initial runs (may be no partials yet)
initTooltips(); initTooltips();
@@ -184,6 +278,14 @@
initTooltips(); initTooltips();
// submitted-list may have loaded now; re-run highlighting // submitted-list may have loaded now; re-run highlighting
highlightSubmitted(); 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); } }catch(e){ console.error(e); }
}); });
@@ -1,6 +1,6 @@
{% comment %} Partial: list of CID candidates for HTMX swap {% endcomment %} {% comment %} Partial: list of CID candidates for HTMX swap {% endcomment %}
{% if cid_users %} {% if cid_users %}
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for cid in cid_users %} {% for cid in cid_users %}
<li class="list-group-item"> <li class="list-group-item">
<div class="d-flex align-items-start justify-content-between"> <div class="d-flex align-items-start justify-content-between">
@@ -22,10 +22,11 @@
<div class="small text-muted mt-1 cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">Notes: {{ cid.notes|truncatechars:120 }}</div> <div class="small text-muted mt-1 cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">Notes: {{ cid.notes|truncatechars:120 }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="badge-container d-flex align-items-center ms-3"></div>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="text-muted">This exam has no CID candidates.</p> <p class="text-muted">This exam has no CID candidates.</p>
{% endif %} {% endif %}
@@ -23,11 +23,11 @@
</thead> </thead>
<tbody id="submitted-candidates"> <tbody id="submitted-candidates">
{% for user_data in user_exam_data %} {% for user_data in user_exam_data %}
<tr data-cid="{{ user_data.cid_user }}" data-user="{{ user_data.user_user }}"> <tr data-cid="{{ user_data.cid_user }}" data-user="{{ user_data.user_user }}" data-first="{{ user_data.start_time|default_if_none:'' }}" data-last="{{ user_data.end_time|default_if_none:'' }}" {% if not user_data.in_exam %}class="table-warning"{% endif %}>
<td class="fw-bold">{{ user_data.cid_user }}</td> <td class="fw-bold">{{ user_data.cid_user }}{% if not user_data.in_exam %} <span class="badge bg-danger ms-2" title="Candidate/User not added to exam">Not added</span>{% endif %}</td>
<td>{{ user_data.user_user }}</td> <td>{{ user_data.user_user }}</td>
<td class="text-nowrap small text-muted">{{ user_data.start_time }}</td> <td class="text-nowrap small {% if user_data.in_exam %}text-muted{% else %}text-dark{% endif %}">{{ user_data.start_time }}</td>
<td class="text-nowrap small text-muted">{{ user_data.end_time }}</td> <td class="text-nowrap small {% if user_data.in_exam %}text-muted{% else %}text-dark{% endif %}">{{ user_data.end_time }}</td>
<td class="text-end"> <td class="text-end">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" title="View details" hx-get="{% url exam.get_app_name|add:':exam_user_status' exam.id %}?cid={{ user_data.cid_user }}" hx-target="#history-modal .modal-body" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-eye"></i></button> <button class="btn btn-sm btn-outline-secondary" title="View details" hx-get="{% url exam.get_app_name|add:':exam_user_status' exam.id %}?cid={{ user_data.cid_user }}" hx-target="#history-modal .modal-body" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-eye"></i></button>
@@ -1,6 +1,6 @@
{% comment %} Partial: list of User candidates for HTMX swap {% endcomment %} {% comment %} Partial: list of User candidates for HTMX swap {% endcomment %}
{% if user_users %} {% if user_users %}
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for user in user_users %} {% for user in user_users %}
<li class="list-group-item d-flex justify-content-between align-items-start"> <li class="list-group-item d-flex justify-content-between align-items-start">
<div> <div>
@@ -18,10 +18,10 @@
</div> </div>
<div class="small">Email: {{ user.email }}</div> <div class="small">Email: {{ user.email }}</div>
</div> </div>
<div class="small text-muted">&nbsp;</div> <div class="badge-container d-flex align-items-center ms-3"></div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="text-muted">This exam has no User candidates.</p> <p class="text-muted">This exam has no User candidates.</p>
{% endif %} {% endif %}
+36 -3
View File
@@ -56,7 +56,7 @@ from reversion.views import RevisionMixin
from atlas.models import CaseCollection, CaseDetail from atlas.models import CaseCollection, CaseDetail
from generic.decorators import user_is_cid_user_manager from generic.decorators import user_is_cid_user_manager
from generic.filters import CidUserFilter, ExaminationFilter, SupervisorFilter from generic.filters import CidUserFilter, ExaminationFilter, SupervisorFilter
from generic.models import UserUserGroup from generic.models import UserUserGroup, CidUserExam
from generic.tables import ( from generic.tables import (
CidUserExamTable, CidUserExamTable,
@@ -2335,9 +2335,42 @@ class ExamViews(View, LoginRequiredMixin):
if request.user not in exam.author.all() and not request.user.is_superuser: if request.user not in exam.author.all() and not request.user.is_superuser:
raise PermissionDenied 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): # def exam_groups_edit(self, request, exam_id):
# exam = get_object_or_404(self.Exam, pk=exam_id) # exam = get_object_or_404(self.Exam, pk=exam_id)
+163 -9
View File
@@ -12,10 +12,15 @@
{% include "exam_clock.html" %} {% include "exam_clock.html" %}
<div class="no-select"> <div class="no-select">
<h2> <div class="d-flex justify-content-between align-items-start">
<h2 class="mb-0">
{{ exam }}: Question {{ exam }}: Question
[<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>] [<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>]
</h2> </h2>
<div class="ms-3">
<!-- flag button moved into the question fragment so it updates with HTMX swaps -->
</div>
</div>
{% if exam.publish_results %} {% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert"> <div class="alert alert-primary review-mode-alert" role="alert">
@@ -44,6 +49,108 @@
<h4>Questions</h4> <h4>Questions</h4>
<div id="menu-list"></div> <div id="menu-list"></div>
<script>
// Minimal per-question status injected by view: answered/flagged arrays
window.examQuestionAnswered = {{ answered_json|safe }};
window.examQuestionFlagged = {{ flagged_json|safe }};
// Update the visual state of the question menu based on global arrays
window.updateQuestionMenu = function(){
console.log("Updating question menu");
try{
const answered = window.examQuestionAnswered || [];
const flagged = window.examQuestionFlagged || [];
const pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
document.querySelectorAll('#menu-list .question-menu-item').forEach(btn => {
const idx = parseInt(btn.dataset.qn, 10);
btn.classList.toggle('answered', !!answered[idx]);
btn.classList.toggle('flagged', !!flagged[idx]);
btn.classList.toggle('current-question', idx === pos);
// manage flag icon presence
const hasIcon = btn.querySelector('.question-flag') !== null;
if (flagged[idx] && !hasIcon) {
const el = document.createElement('span'); el.className = 'question-flag'; el.setAttribute('aria-hidden','true'); el.textContent = '⚑'; btn.appendChild(el);
} else if (!flagged[idx] && hasIcon) {
const el = btn.querySelector('.question-flag'); if(el) el.remove();
}
});
}catch(e){ console && console.error && console.error(e); }
};
</script>
<script>
// Show a blur + spinner on the question fragment while HTMX loads it
(function(){
function showLoading(){
try{
const frag = document.getElementById('question-fragment');
if(!frag) return;
frag.classList.add('loading');
if(!frag.querySelector('.loading-spinner')){
const s = document.createElement('div');
s.className = 'loading-spinner';
s.setAttribute('aria-hidden','true');
frag.appendChild(s);
}
}catch(e){console && console.error && console.error(e);}
}
function hideLoading(){
try{
const frag = document.getElementById('question-fragment');
if(!frag) return;
frag.classList.remove('loading');
const s = frag.querySelector('.loading-spinner'); if(s) s.remove();
}catch(e){console && console.error && console.error(e);}
}
document.body.addEventListener('htmx:beforeRequest', function(evt){
try{
const target = (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
// if the request targets the question fragment, show loading
if(target && (target.id === 'question-fragment' || (target.closest && target.closest('#question-fragment')))){
showLoading();
}
}catch(e){/* ignore */}
});
document.body.addEventListener('htmx:afterSwap', function(evt){
try{
const target = (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
if(target && (target.id === 'question-fragment' || (target.closest && target.closest('#question-fragment')))){
hideLoading();
}
}catch(e){}
});
// also hide after settle to be safe
document.body.addEventListener('htmx:afterSettle', function(evt){ hideLoading(); });
})();
</script>
<script>
// Also update after HTMX settle to ensure DOM changes are applied
document.body.addEventListener('htmx:afterSettle', function(){
try{ if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu(); }catch(e){}
});
</script>
<script>
// Listen for flag button swaps (HTMX) and update the local menu state
document.addEventListener('htmx:afterSwap', function(evt){
try{
// read the current flag button in DOM (safer than relying on event target)
var container = document.getElementById('flag-button-container');
if(!container) return;
var btn = container.querySelector('button');
if(!btn) return;
var flaggedNow = btn.classList.contains('btn-warning');
var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
if(!isNaN(pos)){
window.examQuestionFlagged = window.examQuestionFlagged || new Array({{ exam_length }}).fill(false);
window.examQuestionFlagged[pos] = !!flaggedNow;
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
}
}catch(e){ console && console.error && console.error(e); }
});
</script>
{% include "physics/exam_take_help.html" %} {% include "physics/exam_take_help.html" %}
{% endblock %} {% endblock %}
@@ -192,15 +299,48 @@
.postinput { margin-top: 0.4rem; text-align: left; } .postinput { margin-top: 0.4rem; text-align: left; }
.physics-answer-list li { padding: 0.6rem; } .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); } }
</style> </style>
{% endblock %} {% endblock %}
{% block head_js %}{% endblock %}
{% block js %} {% block js %}
<script> <script>
/* HTMX swap helpers: preserve scroll and container height to avoid page jumping /* HTMX swap helpers: preserve scroll and container height to avoid page jumping
Listens for htmx:beforeSwap and htmx:afterSwap on the question fragment container. Listens for htmx:beforeSwap and htmx:afterSwap on the question fragment container.
*/ */
(function(){ (function(){
function getTarget(evt){ function getTarget(evt){
return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target; return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
} }
@@ -228,12 +368,12 @@
target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true })); target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true }));
}catch(e){ console && console.error && console.error(e); } }catch(e){ console && console.error && console.error(e); }
}); });
})(); })();
</script> </script>
<script> <script>
// Global listener for server-triggered save confirmations. // Global listener for server-triggered save confirmations.
// The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs. // The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs.
document.addEventListener('saved', function(evt){ document.addEventListener('saved', function(evt){
try { try {
// Prefer toastr if available // Prefer toastr if available
if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') { if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') {
@@ -266,7 +406,21 @@ document.addEventListener('saved', function(evt){
requestAnimationFrame(function(){ el.style.opacity = '1'; }); requestAnimationFrame(function(){ el.style.opacity = '1'; });
// hide after 1.6s // hide after 1.6s
setTimeout(function(){ el.style.opacity = '0'; }, 1600); 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); }
}); } catch (e) { console && console.error && console.error(e); }
</script> });
</script>
{% endblock %} {% endblock %}
@@ -42,22 +42,24 @@
<div class="overview-text">{{answer_count}} out of {{exam_length}} questions answered. Unanswered questions are shown in red. <span class="d-block">Click any tile to jump to that question.</span></div> <div class="overview-text">{{answer_count}} out of {{exam_length}} questions answered. Unanswered questions are shown in red. <span class="d-block">Click any tile to jump to that question.</span></div>
<div class="physics-finish-list" role="list"> <div class="physics-finish-list" role="list">
{% for question, answer in question_answer_tuples %} {% for question, answer, flagged in question_answer_tuples %}
{% comment %} Use an anchor styled as a button rather than nesting button inside anchor (invalid HTML). Keep link targets unchanged. {% endcomment %} {% comment %} Use an anchor styled as a button rather than nesting button inside anchor (invalid HTML). Keep link targets unchanged. {% endcomment %}
{% if not cid %} {% if not cid %}
<a role="listitem" href="{% url 'physics:exam_take_user' pk=exam.id sk=forloop.counter0 %}" <a role="listitem" href="{% url 'physics:exam_take_user' pk=exam.id sk=forloop.counter0 %}"
class="overview-question-btn btn btn-outline-secondary" class="overview-question-btn btn btn-outline-secondary {% if flagged %}flagged{% endif %}"
aria-label="Go to question {{ forloop.counter }}" aria-label="Go to question {{ forloop.counter }}"
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}> {% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %} <span class="qn-num">{{forloop.counter}}</span>{% if answer and answer.answer %}<span class="qn-ans">: {{answer.answer}}</span>{% endif %}
{% if flagged %}<span class="question-flag" aria-hidden="true"></span>{% endif %}
</a> </a>
{% else %} {% else %}
<a role="listitem" href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}" <a role="listitem" href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}"
class="overview-question-btn btn btn-outline-secondary" class="overview-question-btn btn btn-outline-secondary {% if flagged %}flagged{% endif %}"
aria-label="Go to question {{ forloop.counter }}" aria-label="Go to question {{ forloop.counter }}"
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}> {% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %} <span class="qn-num">{{forloop.counter}}</span>{% if answer and answer.answer %}<span class="qn-ans">: {{answer.answer}}</span>{% endif %}
{% if flagged %}<span class="question-flag" aria-hidden="true"></span>{% endif %}
</a> </a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@@ -97,7 +99,7 @@
{% endblock %} {% endblock %}
{% block css %} {% block css %}
<style> <style>
/* Overview page specific styles */ /* Overview page specific styles */
.overview-text { .overview-text {
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -129,6 +131,24 @@
justify-content: flex-start; justify-content: flex-start;
} }
/* Flagged questions show only the flag icon; do not change border color */
/* Ensure global `.flagged` rules don't change the overview tile text colour. */
.overview-question-btn.flagged {
color: inherit !important;
}
.overview-question-btn.flagged .qn-num,
.overview-question-btn.flagged .qn-ans {
color: inherit !important;
}
.overview-question-btn .question-flag {
margin-left: auto;
font-size: 0.95rem;
color: #ffc107;
line-height: 1;
}
/* Unanswered questions should draw attention */ /* Unanswered questions should draw attention */
.overview-question-btn[title] { .overview-question-btn[title] {
border-color: #dc3545 !important; border-color: #dc3545 !important;
@@ -154,5 +174,5 @@
} }
.overview-question-btn { font-size: .95rem } .overview-question-btn { font-size: .95rem }
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -0,0 +1,17 @@
{% comment %}A small flag/unflag button partial. Re-rendered by HTMX when toggled.{% endcomment %}
<div id="flag-button-container">
{% load static %}
{% if flagged %}
<form method="POST" hx-post="{% if cid %}{% url 'physics:exam_toggle_flag' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_toggle_flag_user' pk=exam.pk sk=pos %}{% endif %}" hx-target="#flag-button-container" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="set" value="false" />
<button type="submit" class="btn btn-warning btn-sm" title="Unflag question for review">Unflag <i class="bi bi-flag-fill"></i></button>
</form>
{% else %}
<form method="POST" hx-post="{% if cid %}{% url 'physics:exam_toggle_flag' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_toggle_flag_user' pk=exam.pk sk=pos %}{% endif %}" hx-target="#flag-button-container" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="set" value="true" />
<button type="submit" class="btn btn-outline-warning btn-sm" title="Flag question for review">Flag <i class="bi bi-flag"></i></button>
</form>
{% endif %}
</div>
@@ -1,4 +1,5 @@
<div class="exam-question-fragment"> <div class="exam-question-fragment">
<!-- Flag button lives inside the fragment so it updates together with other question controls -->
<form method="POST" class="post-form" <form method="POST" class="post-form"
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-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"> hx-target="#question-fragment" hx-swap="innerHTML">
@@ -55,12 +56,21 @@
</div> </div>
<div class="mt-2"> <div class="mt-2">
<div class="d-flex align-items-center justify-content-between">
<div class="btn-row">
{% if previous > -1 %} {% if previous > -1 %}
<button type="submit" name="previous" class="save btn btn-secondary" title="Click to save your answer(s) and go to the previous question">Previous</button> <button type="submit" name="previous" class="save btn btn-secondary" title="Click to save your answer(s) and go to the previous question" onclick="try{ window.currentQuestionPos = {{ previous }}; if(typeof updateQuestionMenu==='function') updateQuestionMenu(); }catch(e){}">Previous</button>
{% endif %} {% endif %}
{% if next %} {% if next %}
<button type="submit" name="next" class="save btn btn-secondary" title="Click to save your answer(s) and go to the next question">Next</button> <button type="submit" name="next" class="save btn btn-secondary" title="Click to save your answer(s) and go to the next question" onclick="try{ window.currentQuestionPos = {{ pos|add:1 }}; if(typeof updateQuestionMenu==='function') updateQuestionMenu(); }catch(e){}">Next</button>
<!-- Skip: load the next question fragment without submitting the form -->
<button type="button" id="skip-button" class="btn btn-outline-secondary ms-2" title="Skip to next question without saving"
hx-get="{% if cid %}{% url 'physics:exam_take_fragment' pk=exam.pk sk=pos|add:1 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_fragment_user' pk=exam.pk sk=pos|add:1 %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">Skip</button>
<!-- Fallback link for non-HTMX clients: simple navigation without saving -->
<a class="d-none" href="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos|add:1 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos|add:1 %}{% endif %}"></a>
{% else %} {% else %}
{% if not exam.publish_results %} {% if not exam.publish_results %}
<button type="submit" name="save" class="save btn btn-default" title="Click to save your current answer(s)">Save</button> <button type="submit" name="save" class="save btn btn-default" title="Click to save your current answer(s)">Save</button>
@@ -71,6 +81,12 @@
<button type="submit" id="overview-button" name="finish" class="save btn btn-default" title="Click to go to the overview page (your answers will be saved).">Overview</button> <button type="submit" id="overview-button" name="finish" class="save btn btn-default" title="Click to go to the overview page (your answers will be saved).">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button> <button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div> </div>
<div class="flag-container ms-3">
{% include 'physics/partials/exam_flag_button.html' %}
</div>
</div>
</div>
</form> </form>
<script> <script>
@@ -80,11 +96,21 @@
try { try {
var qnum = document.getElementById('question-number'); var qnum = document.getElementById('question-number');
if (qnum) { qnum.textContent = '{{ pos|add:"1" }}'; } if (qnum) { qnum.textContent = '{{ pos|add:"1" }}'; }
// expose current question index for global listeners
window.currentQuestionPos = {{ pos }};
var qstem = document.getElementById('question-stem'); var qstem = document.getElementById('question-stem');
if (qstem) { qstem.innerHTML = '{{ question.stem|escapejs }}'; } if (qstem) { qstem.innerHTML = '{{ question.stem|escapejs }}'; }
} catch (err) { } catch (err) {
console.warn('Could not update global question header from fragment', 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 %} {% if not exam.publish_results and not cid_user_exam.completed %}
let time_limit = '{{ exam.time_limit }}'; let time_limit = '{{ exam.time_limit }}';
if (time_limit != 'None') { if (time_limit != 'None') {
@@ -118,16 +144,289 @@
// build the question menu locally so it is available after fragment swaps // build the question menu locally so it is available after fragment swaps
$('#menu-list').empty(); $('#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++) { for (let i = 0; i < {{ exam_length }}; i++) {
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`); const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}"><span class="qnum">${i+1}</span></button>`);
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('<span class="question-flag" aria-hidden="true"></span>');
}
if (i == {{ pos }}) { qbutton.addClass('current-question'); } if (i == {{ pos }}) { qbutton.addClass('current-question'); }
$('#menu-list').append(qbutton); $('#menu-list').append(qbutton);
} }
$('button.question-menu-item').on('click', (e) => { // Let a central updater ensure classes are consistent (handles cases where menu exists before arrays)
document.getElementById('goto-button').value = e.currentTarget.dataset.qn; 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(); $('#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<current.length;i++){
if(!!current[i] !== !!savedAnswers[i]){ changed = true; break; }
}
} else {
// differing lengths — consider changed if any selection present
changed = current.some(Boolean);
}
}
if(changed){
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); }
});
})();
})();
</script>
<!-- Save / Skip Modal -->
<div class="modal fade" id="saveSkipModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="saveSkipModalMessage">Are you sure?</p>
</div>
<div class="modal-footer">
<button type="button" id="saveSkipModalSave" class="btn btn-primary">Save</button>
<button type="button" id="saveSkipModalSkip" class="btn btn-secondary">Skip</button>
<button type="button" id="saveSkipModalCancel" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<script>
// Modal helper: returns a Promise resolved with 'save'|'skip'|'cancel'
function openSaveSkipModal(message){
return new Promise(function(resolve, reject){
try{
let modalEl = document.getElementById('saveSkipModal');
if(!modalEl) return resolve('cancel');
modalEl.querySelector('#saveSkipModalMessage').textContent = message || '';
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
function cleanup(){
saveBtn.removeEventListener('click', onSave);
skipBtn.removeEventListener('click', onSkip);
cancelBtn.removeEventListener('click', onCancel);
modalEl.removeEventListener('hidden.bs.modal', onHidden);
}
function onSave(){ cleanup(); bsModal.hide(); resolve('save'); }
function onSkip(){ cleanup(); bsModal.hide(); resolve('skip'); }
function onCancel(){ cleanup(); bsModal.hide(); resolve('cancel'); }
function onHidden(){ cleanup(); resolve('cancel'); }
const saveBtn = modalEl.querySelector('#saveSkipModalSave');
const skipBtn = modalEl.querySelector('#saveSkipModalSkip');
const cancelBtn = modalEl.querySelector('#saveSkipModalCancel');
saveBtn.addEventListener('click', onSave);
skipBtn.addEventListener('click', onSkip);
cancelBtn.addEventListener('click', onCancel);
modalEl.addEventListener('hidden.bs.modal', onHidden);
bsModal.show();
}catch(e){ reject(e); }
});
}
</script> </script>
</div> </div>
+11
View File
@@ -35,6 +35,17 @@ urlpatterns.extend(
views.exam_take_fragment, views.exam_take_fragment,
name="exam_take_fragment", name="exam_take_fragment",
), ),
# Flag toggle (HTMX)
path(
"exam/<int:pk>/<int:sk>/toggle_flag",
views.exam_toggle_flag,
name="exam_toggle_flag_user",
),
path(
"exam/<int:pk>/<int:sk>/<str:cid>/<str:passcode>/toggle_flag",
views.exam_toggle_flag,
name="exam_toggle_flag",
),
path("exam/<int:pk>/start", views.exam_start, name="exam_start"), path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
path( path(
"exam/<int:pk>/<str:cid>/<str:passcode>/finish", "exam/<int:pk>/<str:cid>/<str:passcode>/finish",
+137 -9
View File
@@ -1,6 +1,6 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils import timezone 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.decorators import user_is_author_or_physics_checker
from physics.filters import QuestionFilter, UserAnswerFilter from physics.filters import QuestionFilter, UserAnswerFilter
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
@@ -179,19 +179,29 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
else: else:
answers = UserAnswer.objects.filter(user=request.user, exam=exam) answers = UserAnswer.objects.filter(user=request.user, exam=exam)
answer_question_map = {} # Map answers by question id for quick lookup
for ans in answers: answer_question_map = {ans.question_id: ans for ans in answers}
answer_question_map[ans.question] = ans
# 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 = [] question_answer_tuples = []
answer_count = 0 answer_count = 0
for q in questions: for q in questions:
# if q in answer_question_map and answer_question_map[q].answer: ans = answer_question_map.get(q.pk)
if q in answer_question_map: # might need to improve this if ans is not None:
question_answer_tuples.append((q, answer_question_map[q]))
answer_count += 1 answer_count += 1
else: flagged = q.pk in flagged_set
question_answer_tuples.append((q, None)) question_answer_tuples.append((q, ans, flagged))
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user) 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: if answer is not None:
saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e] 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( return render(
request, request,
"physics/exam_take.html", "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, "saved_answer": saved_answer,
"passcode": passcode, "passcode": passcode,
"cid_user_exam": cid_user_exam, "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() form = UserAnswerForm()
saved_answer = False 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 previous = -1
if sk > 0: if sk > 0:
previous = sk - 1 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: if sk == exam_length - 1:
next = False 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( return render(
request, request,
"physics/partials/exam_take_fragment.html", "physics/partials/exam_take_fragment.html",
@@ -436,12 +493,83 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
"exam_length": exam_length, "exam_length": exam_length,
"pos": pos, "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, "passcode": passcode,
"cid_user_exam": cid_user_exam, "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): # def loadJsonAnswer(answer):
# # As access is not restricted make sure the data appears valid # # As access is not restricted make sure the data appears valid
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)): # if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
+1 -1
View File
@@ -22,7 +22,7 @@
{% for exam in exams %} {% for exam in exams %}
<li class="exam" data-exam-id="{{exam.pk}}">{{exam}} <li class="exam" data-exam-id="{{exam.pk}}">{{exam}}
{% if exam.active %} {% if exam.active %}
<a href="{{exam.get_take_url}}?cid={{cid}}&passcode={{passcode}}" target="_blank" title="Click to take exam"> <a href="{{exam.get_take_url}}?cid={{cid}}&passcode={{passcode}}&exam_mode=true" target="_blank" title="Click to take exam">
<button class="small-url-button start-button">Start</button> <button class="small-url-button start-button">Start</button>
</a> </a>
{% endif %} {% endif %}