feat: implement HTMX support for exam question navigation and flagging functionality

This commit is contained in:
Ross
2026-07-06 11:34:58 +01:00
parent 94b08c5c21
commit b13261c302
7 changed files with 564 additions and 107 deletions
+152 -100
View File
@@ -1,9 +1,9 @@
{% extends 'base.html' %}
{% block content %}
<div class="py-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="py-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<span id="user-id" class="text-muted small">
{% if request.user.is_authenticated %}
User: {{ request.user }}
@@ -12,101 +12,92 @@
{% endif %}
</span>
<h2 class="h5 mt-1">{{ exam }}: Question <small class="text-muted">[<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>]</small></h2>
<div class="">
{% include "exam_clock.html" %}
</div>
</div>
{% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert">
Exam is in review mode. Add question feedback <a href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='sbas' pk=question.pk %}')">here</a>.
</div>
{% elif cid_user_exam.completed %}
<div class="alert alert-primary review-mode-alert" role="alert">Exam completed.</div>
{% endif %}
<div class="row flex-wrap">
{# Layout change: make both columns full-width so the right panel is not a sidebar #}
<div class="col-12 order-1 mb-3">
<div class="card">
<div class="card-body">
<div id="question-stem" class="mb-3">{{ question.stem|safe }}</div>
<ul class="sba-answer-list list-group">
<li class="list-group-item" data-ans="a">{{ question.a_answer|safe }}</li>
<li class="list-group-item" data-ans="b">{{ question.b_answer|safe }}</li>
<li class="list-group-item" data-ans="c">{{ question.c_answer|safe }}</li>
<li class="list-group-item" data-ans="d">{{ question.d_answer|safe }}</li>
<li class="list-group-item" data-ans="e">{{ question.e_answer|safe }}</li>
</ul>
<form method="POST" class="post-form mt-3">{% csrf_token %}
<div class="form-contents visually-hidden">{{ form }}</div>
<div class="d-flex flex-column flex-sm-row gap-2 mt-2">
<div class="d-flex gap-2 flex-wrap">
{% if previous > -1 %}
<button type="submit" name="previous" class="btn btn-outline-secondary" title="Save and go to previous">Previous</button>
{% endif %}
{% if next %}
<button type="submit" name="next" class="btn btn-primary">Next</button>
{% else %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="btn btn-success">Save</button>
{% endif %}
{% endif %}
</div>
<div class="ms-sm-auto">
{% if exam.publish_results and question.feedback %}
<div class="mt-2 mt-sm-0">
<h6 class="mb-1">Feedback</h6>
<div>{{ question.feedback|safe }}</div>
</div>
{% endif %}
<button type="submit" name="finish" class="btn btn-outline-primary ms-0 ms-sm-2 mt-2 mt-sm-0">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="visually-hidden">goto</button>
</div>
</div>
</form>
</div>
<div class="">
{% include "exam_clock.html" %}
</div>
</div>
<div class="col-12 order-2 mb-3">
{% include "sbas/exam_take_help.html" %}
{% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert">
Exam is in review mode. Add question feedback <a href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='sbas' pk=question.pk %}')">here</a>.
</div>
{% elif cid_user_exam.completed %}
<div class="alert alert-primary review-mode-alert" role="alert">Exam completed.</div>
{% endif %}
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title">Questions</h6>
<p class="small text-muted mb-2">Tap a number to jump to that question.</p>
<div class="row flex-wrap">
{# Layout change: make both columns full-width so the right panel is not a sidebar #}
<div class="col-12 order-1 mb-3">
<div class="card">
<div class="card-body">
<div>
<p><span id="question-stem"></span></p>
</div>
<div id="question-fragment"
hx-trigger="load"
hx-get="{% if cid %}{% url 'sbas:exam_take_fragment' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'sbas:exam_take_fragment_user' pk=exam.pk sk=pos %}{% endif %}" hx-swap="innerHTML">
<!-- HTMX will load the per-question form fragment here -->
</div>
</div>
</div>
</div>
<div class="col-12 order-2 mb-3">
{% include "sbas/exam_take_help.html" %}
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title">Questions</h6>
<p class="small text-muted mb-2">Tap a number to jump to that question.</p>
<!-- Collapsible for small screens -->
<div class="d-md-none mb-2">
<button class="btn btn-sm btn-outline-secondary w-100" type="button" data-bs-toggle="collapse" data-bs-target="#menu-list" aria-expanded="false" aria-controls="menu-list">
Show questions
</button>
</div>
<div class="d-md-none mb-2">
<button class="btn btn-sm btn-outline-secondary w-100" type="button" data-bs-toggle="collapse" data-bs-target="#menu-list" aria-expanded="false" aria-controls="menu-list">
Show questions
</button>
</div>
<div id="menu-list" class="collapse d-md-block">
<div class="d-flex flex-wrap gap-1" id="menu-buttons" role="toolbar" aria-label="Question navigation">
<div id="menu-list" class="collapse d-md-block">
<div class="d-flex flex-wrap gap-1" id="menu-buttons" role="toolbar" aria-label="Question navigation">
<!-- Buttons injected by JS -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script>
$(document).ready(() => {
/* beautify ignore:start */
// initialize global question state from server
window.examQuestionAnswered = {{ answered_json|default:'null'|safe }};
window.examQuestionFlagged = {{ flagged_json|default:'null'|safe }};
window.updateQuestionMenu = function(){
try{
const answered = window.examQuestionAnswered || [];
const flagged = window.examQuestionFlagged || [];
const pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
document.querySelectorAll('#menu-buttons .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);
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); }
};
{% if not exam.publish_results and not cid_user_exam.completed %}
let time_limit = '{{exam.time_limit}}'
@@ -116,13 +107,13 @@
initializeClock("clockdiv", end_time);
}
$("ul.sba-answer-list li").each((n, el) => {
$(el).click((e) => {
$("#id_answer").val(el.dataset.ans);
$("ul.sba-answer-list li").removeClass("selected");
$(el).addClass("selected")
})
})
// Delegate clicks so dynamically-loaded fragments are handled
$(document).on('click', 'ul.sba-answer-list li', function(e){
const el = this;
try{ $("#id_answer").val(el.dataset.ans); }catch(err){}
$("ul.sba-answer-list li").removeClass("selected");
$(el).addClass("selected")
});
{% elif exam.publish_results %}
$("ul.sba-answer-list li[data-ans='{{question.best_answer}}']").addClass("correct");
{% endif %}
@@ -132,21 +123,46 @@
for (let i = 0; i < {{ exam_length }}; i++) {
// Create Bootstrap-styled buttons and append into the flex-wrap container
qbutton = $(`<button type="button" class="btn btn-sm btn-outline-secondary question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`)
if (i == {{pos}}) {qbutton.addClass("current-question")}
$("#menu-buttons").append(qbutton);
const qbutton = document.createElement('button');
qbutton.type = 'button';
qbutton.className = 'btn btn-sm btn-outline-secondary question-menu-item';
qbutton.name = `goto-${i}`;
qbutton.dataset.qn = i;
qbutton.textContent = (i+1).toString();
if (i == {{pos}}) qbutton.classList.add('current-question');
document.getElementById('menu-buttons').appendChild(qbutton);
}
$("button.question-menu-item").on("click", (e) => {
console.log(e.currentTarget.dataset.qn);
document.getElementById("goto-button").value = e.currentTarget.dataset.qn;
$("#goto-button").click();
document.getElementById('menu-buttons').addEventListener('click', function(e){
const btn = e.target.closest('.question-menu-item');
if (!btn) return;
document.getElementById('goto-button').value = btn.dataset.qn;
document.getElementById('goto-button').click();
});
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
})
</script>
<script>
// Update flagged state when flag button is swapped via HTMX
document.addEventListener('htmx:afterSwap', function(evt){
try{
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>
{% endblock %}
{% block css %}
<style type="text/css">
@@ -154,18 +170,54 @@
display: none;
}
/* SBA answer list styled similarly to physics answers */
.sba-answer-list { list-style: none; padding: 0; margin: 0; }
.sba-answer-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--card-border);
border-radius: 0.5rem;
background: var(--card-bg);
box-shadow: var(--shadow);
color: var(--text);
cursor: pointer;
}
.sba-answer-list li + li { margin-top: 0.75rem; }
.sba-answer-list li .question-text { margin: 0; }
/* Selected state */
.sba-answer-list li.selected { background-color: rgba(13,110,253,0.06); border-color: rgba(13,110,253,0.18); }
/* Small helper for flagged/answered markers in the menu */
.question-flag { margin-left: 0.5rem; color: var(--muted); }
.selected {
border: 1px solid purple;
}
.sba-answer-list li:hover::before {
/* radio-like selection indicator */
.sba-answer-list li { position: relative; }
.sba-answer-list li::before {
content: '';
display: inline-block;
width: 1rem; height: 1rem;
border-radius: 50%;
border: 2px solid var(--muted);
margin-right: 0.5rem;
flex: 0 0 auto;
}
.sba-answer-list li.selected::before {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(110,168,255,0.06);
}
.sba-answer-list li:hover::after {
content: "Click to select";
float: right
}
.sba-answer-list li:hover.selected::before {
content: "Chosen answer";
float: right
position: absolute; right: 0.75rem; top: 0.6rem; color: var(--muted); font-size: 0.8rem;
}
.sba-answer-list li.selected:hover::after { content: "Chosen"; }
/* Make question nav buttons consistent and allow wrapping on small screens */
.question-menu-item {
@@ -0,0 +1,17 @@
{% comment %}SBAS flag/unflag button partial used by HTMX fragment.{% endcomment %}
<div id="flag-button-container">
{% load static %}
{% if flagged %}
<form method="POST" hx-post="{% if cid %}{% url 'sbas:exam_toggle_flag' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'sbas: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 'sbas:exam_toggle_flag' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'sbas: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>
@@ -0,0 +1,146 @@
<div class="sba-question-fragment">
<form method="POST" class="post-form"
hx-post="{% if cid %}{% url 'sbas:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'sbas:exam_take_user' pk=exam.pk sk=pos %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">
{% csrf_token %}
<div class="form-contents-visible">
<div class="question-stem-fragment d-md-none mb-2">{{ question.stem|safe }}</div>
<ul class="sba-answer-list list-group">
<li class="list-group-item" data-ans="a" role="button" tabindex="0" aria-pressed="false">
<div class="d-flex justify-content-between align-items-center">
<div>{{ question.a_answer|safe }}</div>
</div>
</li>
<li class="list-group-item" data-ans="b" role="button" tabindex="0" aria-pressed="false">
<div class="d-flex justify-content-between align-items-center">
<div>{{ question.b_answer|safe }}</div>
<div></div>
</div>
</li>
<li class="list-group-item" data-ans="c" role="button" tabindex="0" aria-pressed="false">
<div class="d-flex justify-content-between align-items-center">
<div>{{ question.c_answer|safe }}</div>
<div></div>
</div>
</li>
<li class="list-group-item" data-ans="d" role="button" tabindex="0" aria-pressed="false">
<div class="d-flex justify-content-between align-items-center">
<div>{{ question.d_answer|safe }}</div>
<div></div>
</div>
</li>
<li class="list-group-item" data-ans="e" role="button" tabindex="0" aria-pressed="false">
<div class="d-flex justify-content-between align-items-center">
<div>{{ question.e_answer|safe }}</div>
<div></div>
</div>
</li>
</ul>
{# Hidden form field to track selected choice for SBA question #}
<input type="hidden" name="answer" id="id_answer" value="{{ saved_answer|default_if_none:'' }}" />
</div>
<div class="mt-2">
<div class="d-flex align-items-center justify-content-between">
<div class="btn-row">
{% if previous > -1 %}
<button type="submit" name="previous" class="btn btn-outline-secondary" title="Save and go to previous">Previous</button>
{% endif %}
{% if next %}
<button type="submit" name="next" class="btn btn-primary">Next</button>
{% else %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="btn btn-success">Save</button>
{% endif %}
{% endif %}
<br />
<button type="submit" name="finish" class="btn btn-outline-primary ms-0 ms-sm-2 mt-2 mt-sm-0">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="visually-hidden">goto</button>
</div>
<div class="flag-container ms-3">
{% include 'sbas/partials/exam_flag_button.html' %}
</div>
</div>
</div>
</form>
<script>
(function () {
try {
// expose current question index for global listeners
window.currentQuestionPos = {{ pos }};
// Update the fragment mobile stem copy
var qstem = document.getElementById('question-stem');
if (qstem) { qstem.innerHTML = '{{ question.stem|escapejs }}'; }
// build the question menu locally so it is available after fragment swaps
$('#menu-buttons').empty();
const serverAnswered = {{ answered_json|default:'null'|safe }};
const serverFlagged = {{ flagged_json|default:'null'|safe }};
if (Array.isArray(serverAnswered)) window.examQuestionAnswered = serverAnswered;
if (Array.isArray(serverFlagged)) window.examQuestionFlagged = serverFlagged;
for (let i = 0; i < {{ exam_length }}; i++) {
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}"><span class="qnum">${i+1}</span></button>`);
if (i == {{ pos }}) { qbutton.addClass('current-question'); }
if (window.examQuestionAnswered && window.examQuestionAnswered[i]) qbutton.addClass('answered');
if (window.examQuestionFlagged && window.examQuestionFlagged[i]) qbutton.addClass('flagged');
$('#menu-buttons').append(qbutton);
}
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
} catch (e) { console && console.error && console.error(e); }
// Make SBA choices clickable and auto-save via HTMX
try {
// mark saved answer as selected
const saved = '{{ saved_answer|default_if_none:"" }}';
if (saved) {
const sel = document.querySelector(`ul.sba-answer-list li[data-ans="${saved}"]`);
if (sel) sel.classList.add('selected');
}
// find csrf token
const csrfEl = document.querySelector('input[name="csrfmiddlewaretoken"]');
const csrftoken = csrfEl ? csrfEl.value : null;
// attach click handler (idempotent)
document.querySelectorAll('ul.sba-answer-list li').forEach(li => {
if (li.dataset.delegateBound) return;
li.dataset.delegateBound = '1';
const activate = (el) => {
const choice = el.dataset.ans;
// update hidden input and visuals
const hidden = document.getElementById('id_answer');
if (hidden) hidden.value = choice;
document.querySelectorAll('ul.sba-answer-list li').forEach(x=>{ x.classList.remove('selected'); x.setAttribute('aria-pressed','false'); });
el.classList.add('selected'); el.setAttribute('aria-pressed','true');
// auto-save via HTMX if available and exam isn't in review/completed mode
if (window.htmx && typeof htmx.ajax === 'function'){
const form = el.closest('form.post-form');
const postUrl = form ? (form.getAttribute('hx-post') || form.getAttribute('action')) : null;
if (postUrl){
const values = { 'answer': choice, 'save': '' };
const headers = {};
if (csrftoken) headers['X-CSRFToken'] = csrftoken;
htmx.ajax('POST', postUrl, { target: '#question-fragment', swap: 'innerHTML', values: values, headers: headers });
}
} else {
// fallback: submit the form normally
const form = el.closest('form.post-form');
if (form){
const tmp = document.createElement('input'); tmp.type='hidden'; tmp.name='save'; tmp.value=''; form.appendChild(tmp);
form.submit();
}
}
};
li.addEventListener('click', function(e){ try{ activate(this);}catch(ex){ console && console.error && console.error(ex); } });
li.addEventListener('keydown', function(e){ if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); try{ activate(this);}catch(ex){ console && console.error && console.error(ex);} } });
});
} catch(e){ console && console.error && console.error(e); }
})();
</script>
</div>
+42 -5
View File
@@ -117,6 +117,8 @@ def test_exams(db, client):
},
)
# Request the full page which now loads the fragment via HTMX;
# fetch the fragment endpoint directly to get the form and CSRF token.
cid_take_res = client.get(question_url)
if cid_user in exam_tester.invalid_users:
@@ -125,12 +127,47 @@ def test_exams(db, client):
else:
assert cid_take_res.status_code == HTTPStatus.OK
cid_take_soup = BeautifulSoup(cid_take_res.content, "html.parser")
# The full page should include the HTMX container which will load
# the fragment client-side. Assert the container exists and has an
# hx-get attribute pointing to a fragment endpoint.
main_soup = BeautifulSoup(cid_take_res.content, "html.parser")
container = main_soup.find("div", attrs={"id": "question-fragment"})
assert container is not None, "Missing #question-fragment container"
hxget = container.get("hx-get") or container.get("data-hx-get")
assert hxget and "take_fragment" in hxget, f"Container hx-get not pointing to fragment: {hxget}"
# Extract csrftoken
csrftoken = cid_take_soup.find(
"input", attrs={"name": "csrfmiddlewaretoken"}
)["value"]
# Build fragment URL and fetch it to obtain CSRF token and controls
if exam_tester.testing_cid_user:
frag_url = reverse(
f"{APP_NAME}:exam_take_fragment",
kwargs={
"pk": exam.pk,
"sk": n,
"cid": exam_tester.current_user.cid,
"passcode": exam_tester.current_user.passcode,
},
)
else:
frag_url = reverse(
f"{APP_NAME}:exam_take_fragment_user",
kwargs={
"pk": exam.pk,
"sk": n,
},
)
frag_res = client.get(frag_url)
assert frag_res.status_code == HTTPStatus.OK
cid_take_soup = BeautifulSoup(frag_res.content, "html.parser")
# Extract csrftoken from fragment
csrftoken = cid_take_soup.find("input", attrs={"name": "csrfmiddlewaretoken"})["value"]
# Ensure fragment contains the question stem and answer list
assert "test question stem" in frag_res.text
# check answer choices exist with data-ans attributes
assert cid_take_soup.find("li", attrs={"data-ans": "a"}), "Missing answer choice a in fragment"
assert "question a text" in frag_res.text
# Send answers (all TRUE)
post_json = {
"csrfmiddlewaretoken": csrftoken,
+21
View File
@@ -29,11 +29,32 @@ urlpatterns = [
views.exam_take,
name="exam_take",
),
path(
"exam/<int:pk>/<int:sk>/<str:cid>/<str:passcode>/take_fragment",
views.exam_take_fragment,
name="exam_take_fragment",
),
path(
"exam/<int:pk>/<int:sk>/take",
views.exam_take,
name="exam_take_user",
),
path(
"exam/<int:pk>/<int:sk>/take_fragment",
views.exam_take_fragment,
name="exam_take_fragment_user",
),
# 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",
+185 -1
View File
@@ -38,6 +38,8 @@ from django.db import transaction
import re
import logging
from generic.models import Flag
logger = logging.getLogger(__name__)
import uuid
@@ -521,16 +523,48 @@ def exam_take(request, pk: int, sk: int, cid: int = None, passcode: str = None):
take_url = "sbas:exam_take_user"
finish_url = "sbas:exam_take_overview_user"
# HTMX detection: header 'HX-Request' or META 'HTTP_HX_REQUEST'
is_htmx = request.headers.get("HX-Request") == "true" or request.META.get("HTTP_HX_REQUEST")
if "next" in request.POST:
if not next:
return HttpResponseBadRequest("Invalid request")
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos + 1, cid=cid, passcode=passcode)
return redirect(take_url, sk=pos + 1, **kwargs)
elif "previous" in request.POST:
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos - 1, cid=cid, passcode=passcode)
return redirect(take_url, sk=pos - 1, **kwargs)
elif "finish" in request.POST:
if is_htmx:
try:
url = reverse(finish_url, kwargs=kwargs)
except Exception:
resp = HttpResponse()
resp["HX-Redirect"] = ""
return resp
resp = HttpResponse()
resp["HX-Redirect"] = url
return resp
return redirect(finish_url, **kwargs)
elif "save" in request.POST:
if is_htmx:
resp = exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
try:
resp['HX-Trigger'] = json.dumps({'saved': True})
except Exception:
resp['HX-Trigger'] = 'saved'
return resp
return redirect(take_url, sk=pos, **kwargs)
elif "goto" in request.POST:
return redirect(take_url, sk=int(request.POST.get("goto")), **kwargs)
dest = int(request.POST.get("goto"))
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=dest, cid=cid, passcode=passcode)
return redirect(take_url, sk=dest, **kwargs)
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
else:
form = UserAnswerForm(instance=answer)
@@ -557,6 +591,156 @@ def exam_take(request, pk: int, sk: int, cid: int = None, passcode: str = None):
)
def exam_take_fragment(request, pk: int, sk: int, cid: int = None, passcode: str = None):
"""Return an HTMX partial for SBAs question form and controls."""
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)
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
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]
exam_length = len(questions)
pos = index
if cid is not None:
answer = question.cid_user_answers.filter(cid=cid, exam=exam).first()
else:
answer = question.cid_user_answers.filter(user=request.user, exam=exam).first()
if answer is not None:
form = UserAnswerForm(instance=answer)
saved_answer = answer.answer
else:
form = UserAnswerForm()
saved_answer = None
# compute flagged state
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
next = sk + 1
if sk == exam_length - 1:
next = False
# bulk answered/flagged sets for menu
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, "sbas/partials/exam_take_fragment.html", {
"form": form,
"cid": cid,
"exam": exam,
"question": question,
"next": next,
"previous": previous,
"exam_length": exam_length,
"pos": pos,
"saved_answer": saved_answer,
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"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]),
})
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, "sbas/partials/exam_flag_button.html", {"flagged": flagged, "exam": exam, "pos": index, "cid": cid, "passcode": passcode})
GenericExamViews = ExamViews(Exam, Question, None, UserAnswer, "sbas", "sbas")