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."""
exam = get_object_or_404(Exam, pk=pk)
if not GenericExamViews.check_user_marker_access(request.user, pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
+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()
class Flag(models.Model):
"""A reusable flag model that can be attached to any object via a
GenericForeignKey. Useful for marking questions, items or other objects
across apps.
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
# Optional actor who created the flag (either a user or a CID user)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="flags",
)
cid_user = models.ForeignKey(
"CidUser",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="flags",
)
note = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-created_at",)
def __str__(self) -> str:
actor = None
if self.user:
actor = f"user:{self.user.username}"
elif self.cid_user:
actor = f"cid:{self.cid_user.cid}"
else:
actor = "anon"
return f"Flag [{actor}] -> {self.content_type}({self.object_id})"
CID_GROUP_EXAMS = (
("SBAs", "sba_cid_user_groups"),
("Physics", "physics_cid_user_groups"),
+125 -23
View File
@@ -15,8 +15,10 @@
<div class="card h-100">
<div class="card-body">
<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">
<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 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">
@@ -52,8 +54,10 @@
<div class="card h-100">
<div class="card-body">
<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">
<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 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">
@@ -122,16 +126,26 @@
function normalizeText(s){ return (s||'').toString().toLowerCase(); }
function filterList(inputElem, listSelector){
function filterList(inputElem, listSelector, indicatorId, clearBtnId){
const q = normalizeText(inputElem.value.trim());
document.querySelectorAll(listSelector).forEach(function(li){
const text = normalizeText(li.textContent);
const text = normalizeText(li.textContent || '');
if(!q || text.indexOf(q) !== -1){
li.style.display = '';
try{ li.style.removeProperty('display'); }catch(e){}
li.classList.remove('d-none');
} else {
li.style.display = 'none';
try{ li.style.setProperty('display','none','important'); }catch(e){ li.style.display = 'none'; }
li.classList.add('d-none');
}
});
// Toggle indicator & clear button
try{
const ind = document.getElementById(indicatorId);
const clearBtn = document.getElementById(clearBtnId);
if(ind) ind.classList.toggle('d-none', !q);
if(clearBtn) clearBtn.classList.toggle('d-none', !q);
}catch(e){}
}
function initTooltips(){
@@ -141,32 +155,112 @@
}
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{
const cid = el.dataset.cid;
const user = el.dataset.user;
if(cid && cid !== 'None'){
document.querySelectorAll(`.list-group-item`).forEach(function(li){
try{
const link = li.querySelector('.cid-link');
if(link && link.textContent.trim() === cid.toString()) li.classList.add('submitted');
}catch(e){}
});
}
if(user && user !== 'None'){
document.querySelectorAll('.list-group-item').forEach(function(li){ if(li.textContent.includes(user)) li.classList.add('submitted'); });
}
}catch(e){ console.error(e); }
const cid = el.dataset && el.dataset.cid ? el.dataset.cid.toString() : null;
const user = el.dataset && el.dataset.user ? el.dataset.user.toString() : null;
const first = el.dataset && el.dataset.first ? el.dataset.first.toString() : '';
const last = el.dataset && el.dataset.last ? el.dataset.last.toString() : '';
if(cid && cid !== 'None') submittedCids.set(cid, {first:first, last:last});
if(user && user !== 'None') submittedUsers.set(user, {first:first, last:last});
}catch(e){ console.error('highlightSubmitted collect error', e); }
});
// 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{
const link = li.querySelector && li.querySelector('.cid-link');
const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim();
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); }
});
}
// 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);
}
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
document.addEventListener('DOMContentLoaded', function(){
const cidFilter = document.getElementById('cid-filter');
if(cidFilter){ cidFilter.addEventListener('input', function(){ filterList(cidFilter, '.col-lg-6:first-of-type .list-group-item'); }); }
if(cidFilter && !cidFilter.dataset.bound){
cidFilter.addEventListener('input', function(){ filterList(cidFilter, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); });
cidFilter.dataset.bound = '1';
}
const userFilter = document.getElementById('user-filter');
if(userFilter){ userFilter.addEventListener('input', function(){ filterList(userFilter, '.col-lg-6:nth-of-type(2) .list-group-item, .col-lg-6:nth-of-type(2) .list-group-item.d-flex'); }); }
if(userFilter && !userFilter.dataset.bound){
userFilter.addEventListener('input', function(){ filterList(userFilter, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); });
userFilter.dataset.bound = '1';
}
// Clear buttons
const cidClear = document.getElementById('cid-filter-clear');
if(cidClear && !cidClear.dataset.bound){
cidClear.addEventListener('click', function(){ const f=document.getElementById('cid-filter'); if(f){ f.value=''; filterList(f, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); }});
cidClear.dataset.bound = '1';
}
const userClear = document.getElementById('user-filter-clear');
if(userClear && !userClear.dataset.bound){
userClear.addEventListener('click', function(){ const f=document.getElementById('user-filter'); if(f){ f.value=''; filterList(f, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); }});
userClear.dataset.bound = '1';
}
// initial runs (may be no partials yet)
initTooltips();
@@ -184,6 +278,14 @@
initTooltips();
// submitted-list may have loaded now; re-run highlighting
highlightSubmitted();
// Re-run current filters for newly-loaded partials
try{
const cf = document.getElementById('cid-filter');
if(cf) filterList(cf, '#cid-list .list-group-item');
const uf = document.getElementById('user-filter');
if(uf) filterList(uf, '#user-list .list-group-item');
}catch(e){ /* ignore */ }
}
}catch(e){ console.error(e); }
});
@@ -1,31 +1,32 @@
{% comment %} Partial: list of CID candidates for HTMX swap {% endcomment %}
{% if cid_users %}
<ul class="list-group list-group-flush">
{% for cid in cid_users %}
<li class="list-group-item">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
></i>
<a class="fw-bold me-2 cid-link" href="{% url 'generic:update_cid' cid.pk %}">{{ cid.cid }}</a>
<span class="text-muted">[{{ cid.passcode }}]</span>
<a class="ms-3" title="View CID exam details" hx-get="{% url 'generic:cid_user_exam_partial' exam.app_name exam.id cid.cid %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-box-arrow-up-right"></i></a>
<ul class="list-group list-group-flush">
{% for cid in cid_users %}
<li class="list-group-item">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
></i>
<a class="fw-bold me-2 cid-link" href="{% url 'generic:update_cid' cid.pk %}">{{ cid.cid }}</a>
<span class="text-muted">[{{ cid.passcode }}]</span>
<a class="ms-3" title="View CID exam details" hx-get="{% url 'generic:cid_user_exam_partial' exam.app_name exam.id cid.cid %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-box-arrow-up-right"></i></a>
</div>
<div class="small text-muted mt-1">{{ cid.name }}</div>
<div class="small">Email: {{ cid.email }}</div>
{% if cid.notes %}
<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 %}
</div>
<div class="small text-muted mt-1">{{ cid.name }}</div>
<div class="small">Email: {{ cid.email }}</div>
{% if cid.notes %}
<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 %}
<div class="badge-container d-flex align-items-center ms-3"></div>
</div>
</div>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">This exam has no CID candidates.</p>
{% endif %}
@@ -23,11 +23,11 @@
</thead>
<tbody id="submitted-candidates">
{% for user_data in user_exam_data %}
<tr data-cid="{{ user_data.cid_user }}" data-user="{{ user_data.user_user }}">
<td class="fw-bold">{{ user_data.cid_user }}</td>
<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 }}{% 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 class="text-nowrap small text-muted">{{ 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.start_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">
<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>
@@ -1,27 +1,27 @@
{% comment %} Partial: list of User candidates for HTMX swap {% endcomment %}
{% if user_users %}
<ul class="list-group list-group-flush">
{% for user in user_users %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
></i>
{% if request.user.is_superuser %}
<a class="me-2" href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<span class="fw-bold">{{ user.username }}</span>
<ul class="list-group list-group-flush">
{% for user in user_users %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
></i>
{% if request.user.is_superuser %}
<a class="me-2" href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<span class="fw-bold">{{ user.username }}</span>
</div>
<div class="small">Email: {{ user.email }}</div>
</div>
<div class="small">Email: {{ user.email }}</div>
</div>
<div class="small text-muted">&nbsp;</div>
</li>
{% endfor %}
</ul>
<div class="badge-container d-flex align-items-center ms-3"></div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">This exam has no User candidates.</p>
{% endif %}
+36 -3
View File
@@ -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)
+229 -75
View File
@@ -12,10 +12,15 @@
{% include "exam_clock.html" %}
<div class="no-select">
<h2>
{{ exam }}: Question
[<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>]
</h2>
<div class="d-flex justify-content-between align-items-start">
<h2 class="mb-0">
{{ exam }}: Question
[<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>]
</h2>
<div class="ms-3">
<!-- flag button moved into the question fragment so it updates with HTMX swaps -->
</div>
</div>
{% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert">
@@ -31,7 +36,7 @@
<div>
<p><span id="question-stem">{{ question.stem|safe }}</span></p>
</div>
<div id="question-fragment"
hx-trigger="load"
@@ -44,6 +49,108 @@
<h4>Questions</h4>
<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" %}
{% 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); } }
</style>
{% endblock %}
{% block head_js %}{% endblock %}
{% block js %}
<script>
<script>
/* HTMX swap helpers: preserve scroll and container height to avoid page jumping
Listens for htmx:beforeSwap and htmx:afterSwap on the question fragment container.
*/
(function(){
function getTarget(evt){
return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
}
(function(){
function getTarget(evt){
return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
}
document.addEventListener('htmx:beforeSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
document.addEventListener('htmx:beforeSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
// save scroll position
target.__savedScrollY = window.scrollY || window.pageYOffset || 0;
target.__savedScrollY = window.scrollY || window.pageYOffset || 0;
// preserve current height to avoid layout shift while fragment is swapping
target.style.minHeight = target.offsetHeight + 'px';
}catch(e){ console && console.error && console.error(e); }
});
target.style.minHeight = target.offsetHeight + 'px';
}catch(e){ console && console.error && console.error(e); }
});
document.addEventListener('htmx:afterSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
var y = target.__savedScrollY;
if(typeof y === 'number') window.scrollTo(0, y);
document.addEventListener('htmx:afterSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
var y = target.__savedScrollY;
if(typeof y === 'number') window.scrollTo(0, y);
// remove the minHeight after a short delay to allow content to layout
setTimeout(function(){ target.style.minHeight = ''; }, 60);
setTimeout(function(){ target.style.minHeight = ''; }, 60);
// notify fragment-loaded so fragment init code can listen if required
target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true }));
}catch(e){ console && console.error && console.error(e); }
});
})();
</script>
<script>
target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true }));
}catch(e){ console && console.error && console.error(e); }
});
})();
</script>
<script>
// Global listener for server-triggered save confirmations.
// The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs.
document.addEventListener('saved', function(evt){
try {
document.addEventListener('saved', function(evt){
try {
// Prefer toastr if available
if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') {
toastr.success('Saved');
return;
}
if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') {
toastr.success('Saved');
return;
}
// Lightweight fallback toast
const id = 'htmx-save-toast';
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.style.position = 'fixed';
el.style.top = '1rem';
el.style.right = '1rem';
el.style.zIndex = 2000;
el.style.padding = '0.6rem 0.9rem';
el.style.background = 'rgba(40,167,69,0.95)';
el.style.color = '#fff';
el.style.borderRadius = '0.4rem';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
el.style.fontWeight = '600';
el.style.opacity = '0';
el.style.transition = 'opacity 220ms ease-in-out';
document.body.appendChild(el);
}
el.textContent = 'Saved';
const id = 'htmx-save-toast';
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.style.position = 'fixed';
el.style.top = '1rem';
el.style.right = '1rem';
el.style.zIndex = 2000;
el.style.padding = '0.6rem 0.9rem';
el.style.background = 'rgba(40,167,69,0.95)';
el.style.color = '#fff';
el.style.borderRadius = '0.4rem';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
el.style.fontWeight = '600';
el.style.opacity = '0';
el.style.transition = 'opacity 220ms ease-in-out';
document.body.appendChild(el);
}
el.textContent = 'Saved';
// show
requestAnimationFrame(function(){ el.style.opacity = '1'; });
requestAnimationFrame(function(){ el.style.opacity = '1'; });
// hide after 1.6s
setTimeout(function(){ el.style.opacity = '0'; }, 1600);
} catch (e) { console && console.error && console.error(e); }
});
</script>
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); }
});
</script>
{% endblock %}
@@ -2,11 +2,11 @@
{% block content %}
<span id="user-id">
{% if not cid %}
User: {{request.user}}
{% else %}
CID: {{cid}}
{% endif %}
{% if not cid %}
User: {{request.user}}
{% else %}
CID: {{cid}}
{% endif %}
</span>
{% include "exam_clock.html" %}
@@ -14,7 +14,7 @@
{% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert">
Exam is in review mode. Score are available
Exam is in review mode. Score are available
{% if request.user.is_authenticated %}
<a href="{% url 'physics:exam_scores_user' pk=exam.id %}">here</a>
@@ -24,7 +24,7 @@
</div>
{% elif cid_user_exam.completed %}
<div class="alert alert-primary review-mode-alert" role="alert">
Exam completed. Answers are available
Exam completed. Answers are available
{% if request.user.is_authenticated %}
<a href="{% url 'physics:exam_scores_user' pk=exam.id %}">here</a>
@@ -37,35 +37,37 @@
You have unanswered questions.
</div>
{% endif %}
<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">
{% 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 %}
{% if not cid %}
<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 }}"
{% 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>
{% else %}
<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 }}"
{% 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>
{% endif %}
{% endfor %}
</div>
<div id="time-details">
Start time: {{cid_user_exam.start_time}}<br/>
Last change time: {{cid_user_exam.end_time}}<br/>
Completed: {{cid_user_exam.completed}}
Start time: {{cid_user_exam.start_time}}<br/>
Last change time: {{cid_user_exam.end_time}}<br/>
Completed: {{cid_user_exam.completed}}
</div>
{% if not cid_user_exam.completed %}
@@ -97,62 +99,80 @@
{% endblock %}
{% block css %}
<style>
<style>
/* Overview page specific styles */
.overview-text {
margin-bottom: 1rem;
font-weight: 500;
}
.overview-text {
margin-bottom: 1rem;
font-weight: 500;
}
.physics-finish-list {
display: grid;
.physics-finish-list {
display: grid;
/* Limit the maximum width of each tile so buttons don't stretch too wide on large screens.
Each tile will be between 80px and 140px. The grid will wrap responsively. */
grid-template-columns: repeat(auto-fit, minmax(80px, 140px));
justify-content: start;
gap: .5rem;
align-items: stretch;
margin-bottom: 1rem;
}
grid-template-columns: repeat(auto-fit, minmax(80px, 140px));
justify-content: start;
gap: .5rem;
align-items: stretch;
margin-bottom: 1rem;
}
.overview-question-btn {
.overview-question-btn {
/* Fill the grid cell but the cell itself is capped by the grid-template-columns above. */
width: 100%;
box-sizing: border-box;
text-align: left;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: .5rem .75rem;
display: flex;
align-items: center;
justify-content: flex-start;
}
width: 100%;
box-sizing: border-box;
text-align: left;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: .5rem .75rem;
display: flex;
align-items: center;
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 */
.overview-question-btn[title] {
border-color: #dc3545 !important;
color: #dc3545 !important;
background: rgba(220,53,69,0.03);
}
.overview-question-btn[title] {
border-color: #dc3545 !important;
color: #dc3545 !important;
background: rgba(220,53,69,0.03);
}
#time-details {
font-size: .95rem;
color: #9aa0a6;
margin-top: .5rem;
}
#time-details {
font-size: .95rem;
color: #9aa0a6;
margin-top: .5rem;
}
/* Make the finish button stand out on small screens */
.btn-primary.mt-3 {
min-width: 160px;
}
@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));
.btn-primary.mt-3 {
min-width: 160px;
}
.overview-question-btn { font-size: .95rem }
}
</style>
@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 }
}
</style>
{% 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,7 +1,8 @@
<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"
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-post="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">
{% csrf_token %}
{{ form.non_field_errors }}
@@ -55,21 +56,36 @@
</div>
<div class="mt-2">
{% 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>
{% endif %}
<div class="d-flex align-items-center justify-content-between">
<div class="btn-row">
{% 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" onclick="try{ window.currentQuestionPos = {{ previous }}; if(typeof updateQuestionMenu==='function') updateQuestionMenu(); }catch(e){}">Previous</button>
{% endif %}
{% 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>
{% else %}
{% 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>
{% endif %}
{% endif %}
{% 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" 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>
<br />
<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>
<!-- 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 %}
{% 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>
{% endif %}
{% endif %}
<br />
<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>
</div>
<div class="flag-container ms-3">
{% include 'physics/partials/exam_flag_button.html' %}
</div>
</div>
</div>
</form>
@@ -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 = $(`<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'); }
$('#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<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>
</div>
+11
View File
@@ -35,6 +35,17 @@ urlpatterns.extend(
views.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>/<str:cid>/<str:passcode>/finish",
+138 -10
View File
@@ -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)):
+1 -1
View File
@@ -22,7 +22,7 @@
{% for exam in exams %}
<li class="exam" data-exam-id="{{exam.pk}}">{{exam}}
{% 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>
</a>
{% endif %}