Merge branch 'part12026live' into master
This commit is contained in:
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="small">Email: {{ user.email }}</div>
|
<div class="small">Email: {{ user.email }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted"> </div>
|
<div class="badge-container d-flex align-items-center ms-3"></div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+36
-3
@@ -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)
|
||||||
|
|||||||
@@ -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,9 +299,42 @@
|
|||||||
.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
|
||||||
@@ -266,6 +406,20 @@ 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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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)):
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user