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."""
|
||||
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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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"> </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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)):
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user