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")
+1 -1
View File
@@ -1,3 +1,3 @@
We need to rework the scores pages for all of the app exams. we need to try to make sure that we keep all of the content that currently exists but display it in a more usable format. at the same time we need to optimise the request as for some of the app with large number of users this results in a large number of sql queries. it would be work splitting some of the page content into different sections that can be loaded via htmx partials (so that they are also reusable elsewhere).
We need to rework the scores pages for all of the app exams. we need to try to make sure that we keep all of the content that currently exists but display it in a more usable format. at the same time we need to optimise the request as for some of the app with large number of users this results in a huge number of sql queries. it may be sensible to split some of the page content into different sections that can be loaded via htmx partials (so that they are also reusable elsewhere).
At the same time we need to have a look at how the scores are calculated, some of the apps currently use exam caching. if possible we want to avoid this, if not we want it to be fully transparent to the end user.