Refactor exam take functionality to support HTMX for dynamic question loading and navigation
This commit is contained in:
@@ -33,75 +33,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<form method="POST" class="post-form">
|
<div id="question-fragment"
|
||||||
{% csrf_token %}
|
hx-trigger="load"
|
||||||
{{ form.non_field_errors }}
|
hx-swap="innerHTML"
|
||||||
|
hx-get="{% if cid %}{% url 'physics:exam_take_fragment' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_fragment_user' pk=exam.pk sk=pos %}{% endif %}">
|
||||||
<div class="form-contents">
|
<!-- HTMX will load the question form + controls here -->
|
||||||
<ol class="physics-answer-list">
|
|
||||||
<li data-ans="a">
|
|
||||||
<div class="fieldWrapper flex-container">
|
|
||||||
{{ form.a.errors }}
|
|
||||||
<label for="{{ form.a.id_for_label }}" class="flex-8 question-text">{{ question.a|safe }}</label>
|
|
||||||
<span class="flex-1">{{ form.a }}<span class="postinput"></span></span>
|
|
||||||
</div>
|
</div>
|
||||||
{% if exam.publish_results and question.a_feedback %}
|
|
||||||
<span class="feedback">Feedback: {{ question.a_feedback }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li data-ans="b">
|
|
||||||
<div class="fieldWrapper flex-container">
|
|
||||||
{{ form.b.errors }}
|
|
||||||
<label for="{{ form.b.id_for_label }}" class="flex-8 question-text">{{ question.b|safe }}</label>
|
|
||||||
<span class="flex-1">{{ form.b }}<span class="postinput"></span></span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li data-ans="c">
|
|
||||||
<div class="fieldWrapper flex-container">
|
|
||||||
{{ form.c.errors }}
|
|
||||||
<label for="{{ form.c.id_for_label }}" class="flex-8 question-text">{{ question.c|safe }}</label>
|
|
||||||
<span class="flex-1">{{ form.c }}<span class="postinput"></span></span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li data-ans="d">
|
|
||||||
<div class="fieldWrapper flex-container">
|
|
||||||
{{ form.d.errors }}
|
|
||||||
<label for="{{ form.d.id_for_label }}" class="flex-8 question-text">{{ question.d|safe }}</label>
|
|
||||||
<span class="flex-1">{{ form.d }}<span class="postinput"></span></span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li data-ans="e">
|
|
||||||
<div class="fieldWrapper flex-container">
|
|
||||||
{{ form.e.errors }}
|
|
||||||
<label for="{{ form.e.id_for_label }}" class="flex-8 question-text">{{ question.e|safe }}</label>
|
|
||||||
<span class="flex-1">{{ form.e }}<span class="postinput"></span></span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2">
|
|
||||||
{% if previous > -1 %}
|
|
||||||
<button type="submit" name="previous" class="save btn btn-default" title="Click to save your answer(s) and go to the previous question">Previous</button>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if next %}
|
|
||||||
<button type="submit" name="next" class="save btn btn-default" 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 %}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>Questions</h4>
|
<h4>Questions</h4>
|
||||||
@@ -111,53 +48,7 @@
|
|||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js %}
|
{# HTMX fragment will load question-specific JS; nothing required here #}
|
||||||
<script>
|
|
||||||
$(document).ready(() => {
|
|
||||||
/* beautify ignore:start */
|
|
||||||
{% if not exam.publish_results and not cid_user_exam.completed %}
|
|
||||||
let time_limit = '{{ exam.time_limit }}';
|
|
||||||
if (time_limit != 'None') {
|
|
||||||
let end_time = new Date({{ cid_user_exam.start_time|date:"U" }} * 1000 + parseInt('{{ exam.time_limit }}') * 1000);
|
|
||||||
initializeClock('clockdiv', end_time);
|
|
||||||
}
|
|
||||||
|
|
||||||
{% elif exam.publish_results %}
|
|
||||||
$('ol.physics-answer-list input').each((n, el) => { el.disabled = true; });
|
|
||||||
{{ question.get_answers_js }}.forEach((el, n) => {
|
|
||||||
const li_el = $(`ol.physics-answer-list li:eq(${n})`);
|
|
||||||
|
|
||||||
if (el) {
|
|
||||||
li_el.addClass('answer-true');
|
|
||||||
} else {
|
|
||||||
li_el.addClass('answer-false');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (el == li_el.find('input').get(0).checked) {
|
|
||||||
li_el.addClass('answer-correct');
|
|
||||||
} else {
|
|
||||||
li_el.addClass('answer-incorrect');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
{% elif cid_user_exam.completed %}
|
|
||||||
$('ol.physics-answer-list input').each((n, el) => { el.disabled = true; });
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
for (let i = 0; i < {{ exam_length }}; i++) {
|
|
||||||
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`);
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
/* beautify ignore:end */
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<div class="exam-question-fragment">
|
||||||
|
<form method="POST" class="post-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
|
||||||
|
<div class="form-contents">
|
||||||
|
<ol class="physics-answer-list">
|
||||||
|
<li data-ans="a">
|
||||||
|
<div class="fieldWrapper flex-container">
|
||||||
|
{{ form.a.errors }}
|
||||||
|
<label for="{{ form.a.id_for_label }}" class="flex-8 question-text">{{ question.a|safe }}</label>
|
||||||
|
<span class="flex-1">{{ form.a }}<span class="postinput"></span></span>
|
||||||
|
</div>
|
||||||
|
{% if exam.publish_results and question.a_feedback %}
|
||||||
|
<span class="feedback">Feedback: {{ question.a_feedback }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-ans="b">
|
||||||
|
<div class="fieldWrapper flex-container">
|
||||||
|
{{ form.b.errors }}
|
||||||
|
<label for="{{ form.b.id_for_label }}" class="flex-8 question-text">{{ question.b|safe }}</label>
|
||||||
|
<span class="flex-1">{{ form.b }}<span class="postinput"></span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-ans="c">
|
||||||
|
<div class="fieldWrapper flex-container">
|
||||||
|
{{ form.c.errors }}
|
||||||
|
<label for="{{ form.c.id_for_label }}" class="flex-8 question-text">{{ question.c|safe }}</label>
|
||||||
|
<span class="flex-1">{{ form.c }}<span class="postinput"></span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-ans="d">
|
||||||
|
<div class="fieldWrapper flex-container">
|
||||||
|
{{ form.d.errors }}
|
||||||
|
<label for="{{ form.d.id_for_label }}" class="flex-8 question-text">{{ question.d|safe }}</label>
|
||||||
|
<span class="flex-1">{{ form.d }}<span class="postinput"></span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li data-ans="e">
|
||||||
|
<div class="fieldWrapper flex-container">
|
||||||
|
{{ form.e.errors }}
|
||||||
|
<label for="{{ form.e.id_for_label }}" class="flex-8 question-text">{{ question.e|safe }}</label>
|
||||||
|
<span class="flex-1">{{ form.e }}<span class="postinput"></span></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
{% if previous > -1 %}
|
||||||
|
<button type="submit" name="previous" class="save btn btn-default" title="Click to save your answer(s) and go to the previous question">Previous</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if next %}
|
||||||
|
<button type="submit" name="next" class="save btn btn-default" 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 %}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
/* run after fragment is loaded */
|
||||||
|
{% if not exam.publish_results and not cid_user_exam.completed %}
|
||||||
|
let time_limit = '{{ exam.time_limit }}';
|
||||||
|
if (time_limit != 'None') {
|
||||||
|
let end_time = new Date({{ cid_user_exam.start_time|date:"U" }} * 1000 + parseInt('{{ exam.time_limit }}') * 1000);
|
||||||
|
if (typeof initializeClock === 'function') {
|
||||||
|
initializeClock('clockdiv', end_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{% elif exam.publish_results %}
|
||||||
|
$('ol.physics-answer-list input').each((n, el) => { el.disabled = true; });
|
||||||
|
{{ question.get_answers_js }}.forEach((el, n) => {
|
||||||
|
const li_el = $(`ol.physics-answer-list li:eq(${n})`);
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
li_el.addClass('answer-true');
|
||||||
|
} else {
|
||||||
|
li_el.addClass('answer-false');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el == li_el.find('input').get(0).checked) {
|
||||||
|
li_el.addClass('answer-correct');
|
||||||
|
} else {
|
||||||
|
li_el.addClass('answer-incorrect');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{% elif cid_user_exam.completed %}
|
||||||
|
$('ol.physics-answer-list input').each((n, el) => { el.disabled = true; });
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// build the question menu locally so it is available after fragment swaps
|
||||||
|
$('#menu-list').empty();
|
||||||
|
for (let i = 0; i < {{ exam_length }}; i++) {
|
||||||
|
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
@@ -22,6 +22,17 @@ urlpatterns.extend(
|
|||||||
views.exam_take,
|
views.exam_take,
|
||||||
name="exam_take_user",
|
name="exam_take_user",
|
||||||
),
|
),
|
||||||
|
# HTMX fragment endpoint for loading the question+controls via AJAX
|
||||||
|
path(
|
||||||
|
"exam/<int:pk>/<int:sk>/fragment",
|
||||||
|
views.exam_take_fragment,
|
||||||
|
name="exam_take_fragment_user",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"exam/<int:pk>/<int:sk>/<str:cid>/<str:passcode>/fragment",
|
||||||
|
views.exam_take_fragment,
|
||||||
|
name="exam_take_fragment",
|
||||||
|
),
|
||||||
path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
|
path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
|
||||||
path(
|
path(
|
||||||
"exam/<int:pk>/<str:cid>/<str:passcode>/finish",
|
"exam/<int:pk>/<str:cid>/<str:passcode>/finish",
|
||||||
|
|||||||
@@ -302,6 +302,77 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
|
||||||
|
"""Return an HTMX partial containing the question form and navigation controls.
|
||||||
|
|
||||||
|
This view mirrors the GET rendering logic of `exam_take` but returns a fragment
|
||||||
|
suitable for being loaded via HTMX into the page. It intentionally does not
|
||||||
|
process POSTs (the main `exam_take` endpoint handles form submissions and
|
||||||
|
redirects) to keep fragment logic focused on rendering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Canonical questions list for consistent indexing/navigation
|
||||||
|
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.a, answer.b, answer.c, answer.d, answer.e]
|
||||||
|
else:
|
||||||
|
form = UserAnswerForm()
|
||||||
|
saved_answer = False
|
||||||
|
|
||||||
|
previous = -1
|
||||||
|
if sk > 0:
|
||||||
|
previous = sk - 1
|
||||||
|
next = sk + 1
|
||||||
|
if sk == exam_length - 1:
|
||||||
|
next = False
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"physics/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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# def loadJsonAnswer(answer):
|
# def loadJsonAnswer(answer):
|
||||||
# # As access is not restricted make sure the data appears valid
|
# # As access is not restricted make sure the data appears valid
|
||||||
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
|
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
|
||||||
|
|||||||
Reference in New Issue
Block a user