feat: implement HTMX support for exam question navigation and flagging functionality
This commit is contained in:
+152
-100
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user