Enhance exam overview and fragment by adding question status indicators for answered and flagged questions
This commit is contained in:
@@ -49,6 +49,12 @@
|
|||||||
<h4>Questions</h4>
|
<h4>Questions</h4>
|
||||||
<div id="menu-list"></div>
|
<div id="menu-list"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Minimal per-question status injected by view: answered/flagged arrays
|
||||||
|
window.examQuestionAnswered = {{ answered_json|safe }};
|
||||||
|
window.examQuestionFlagged = {{ flagged_json|safe }};
|
||||||
|
</script>
|
||||||
|
|
||||||
{% include "physics/exam_take_help.html" %}
|
{% include "physics/exam_take_help.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -197,6 +203,27 @@
|
|||||||
.postinput { margin-top: 0.4rem; text-align: left; }
|
.postinput { margin-top: 0.4rem; text-align: left; }
|
||||||
.physics-answer-list li { padding: 0.6rem; }
|
.physics-answer-list li { padding: 0.6rem; }
|
||||||
}
|
}
|
||||||
|
/* Question menu item states (small pill buttons) */
|
||||||
|
button.question-menu-item {
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
button.question-menu-item.answered {
|
||||||
|
border-color: var(--success) !important; /* answered -> success border */
|
||||||
|
}
|
||||||
|
/* flagged items show an icon only; no border change here */
|
||||||
|
button.question-menu-item .question-flag {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #ffc107;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,11 @@
|
|||||||
<div class="overview-text">{{answer_count}} out of {{exam_length}} questions answered. Unanswered questions are shown in red. <span class="d-block">Click any tile to jump to that question.</span></div>
|
<div class="overview-text">{{answer_count}} out of {{exam_length}} questions answered. Unanswered questions are shown in red. <span class="d-block">Click any tile to jump to that question.</span></div>
|
||||||
|
|
||||||
<div class="physics-finish-list" role="list">
|
<div class="physics-finish-list" role="list">
|
||||||
{% for question, answer in question_answer_tuples %}
|
{% for question, answer, flagged in question_answer_tuples %}
|
||||||
{% comment %} Use an anchor styled as a button rather than nesting button inside anchor (invalid HTML). Keep link targets unchanged. {% endcomment %}
|
{% comment %} Use an anchor styled as a button rather than nesting button inside anchor (invalid HTML). Keep link targets unchanged. {% endcomment %}
|
||||||
{% if not cid %}
|
{% if not cid %}
|
||||||
<a role="listitem" href="{% url 'physics:exam_take_user' pk=exam.id sk=forloop.counter0 %}"
|
<a role="listitem" href="{% url 'physics:exam_take_user' pk=exam.id sk=forloop.counter0 %}"
|
||||||
class="overview-question-btn btn btn-outline-secondary"
|
class="overview-question-btn btn btn-outline-secondary {% if flagged %}flagged{% endif %}"
|
||||||
aria-label="Go to question {{ forloop.counter }}"
|
aria-label="Go to question {{ forloop.counter }}"
|
||||||
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
|
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
|
||||||
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %}
|
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %}
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<a role="listitem" href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}"
|
<a role="listitem" href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}"
|
||||||
class="overview-question-btn btn btn-outline-secondary"
|
class="overview-question-btn btn btn-outline-secondary {% if flagged %}flagged{% endif %}"
|
||||||
aria-label="Go to question {{ forloop.counter }}"
|
aria-label="Go to question {{ forloop.counter }}"
|
||||||
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
|
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
|
||||||
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %}
|
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %}
|
||||||
@@ -129,6 +129,13 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flagged question indicator: use border colour to signal a flagged item */
|
||||||
|
.overview-question-btn.flagged {
|
||||||
|
border-color: #ffc107 !important;
|
||||||
|
box-shadow: 0 1px 2px rgba(255,193,7,0.06);
|
||||||
|
background: rgba(255,193,7,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
/* Unanswered questions should draw attention */
|
/* Unanswered questions should draw attention */
|
||||||
.overview-question-btn[title] {
|
.overview-question-btn[title] {
|
||||||
border-color: #dc3545 !important;
|
border-color: #dc3545 !important;
|
||||||
|
|||||||
@@ -124,8 +124,17 @@
|
|||||||
|
|
||||||
// build the question menu locally so it is available after fragment swaps
|
// build the question menu locally so it is available after fragment swaps
|
||||||
$('#menu-list').empty();
|
$('#menu-list').empty();
|
||||||
|
const answeredArr = (window.examQuestionAnswered && Array.isArray(window.examQuestionAnswered)) ? window.examQuestionAnswered : null;
|
||||||
|
const flaggedArr = (window.examQuestionFlagged && Array.isArray(window.examQuestionFlagged)) ? window.examQuestionFlagged : null;
|
||||||
for (let i = 0; i < {{ exam_length }}; i++) {
|
for (let i = 0; i < {{ exam_length }}; i++) {
|
||||||
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`);
|
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}"><span class="qnum">${i+1}</span></button>`);
|
||||||
|
// answered: indicate with border colour but keep text colour unchanged (white)
|
||||||
|
if (answeredArr && answeredArr[i]) { qbutton.addClass('answered'); }
|
||||||
|
// flagged: add flag icon and flagged class which alters border
|
||||||
|
if (flaggedArr && flaggedArr[i]) {
|
||||||
|
qbutton.addClass('flagged');
|
||||||
|
qbutton.append('<span class="question-flag" aria-hidden="true">⚑</span>');
|
||||||
|
}
|
||||||
if (i == {{ pos }}) { qbutton.addClass('current-question'); }
|
if (i == {{ pos }}) { qbutton.addClass('current-question'); }
|
||||||
$('#menu-list').append(qbutton);
|
$('#menu-list').append(qbutton);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-4
@@ -186,12 +186,23 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
|
|||||||
question_answer_tuples = []
|
question_answer_tuples = []
|
||||||
answer_count = 0
|
answer_count = 0
|
||||||
for q in questions:
|
for q in questions:
|
||||||
# if q in answer_question_map and answer_question_map[q].answer:
|
if q in answer_question_map:
|
||||||
if q in answer_question_map: # might need to improve this
|
ans = answer_question_map[q]
|
||||||
question_answer_tuples.append((q, answer_question_map[q]))
|
|
||||||
answer_count += 1
|
answer_count += 1
|
||||||
else:
|
else:
|
||||||
question_answer_tuples.append((q, None))
|
ans = None
|
||||||
|
# compute flagged state for this question+actor
|
||||||
|
try:
|
||||||
|
ct = ContentType.objects.get_for_model(q)
|
||||||
|
flags_qs = Flag.objects.filter(content_type=ct, object_id=q.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
|
||||||
|
question_answer_tuples.append((q, ans, flagged))
|
||||||
|
|
||||||
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
|
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
|
||||||
|
|
||||||
@@ -380,6 +391,17 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
|
|||||||
"passcode": passcode,
|
"passcode": passcode,
|
||||||
"cid_user_exam": cid_user_exam,
|
"cid_user_exam": cid_user_exam,
|
||||||
"flagged": flagged,
|
"flagged": flagged,
|
||||||
|
# minimal per-question status for client-side menu: answered/flagged lists
|
||||||
|
"answered_json": json.dumps([
|
||||||
|
True if (question.cid_user_answers.filter(cid=cid, exam=exam).exists() if cid is not None else question.cid_user_answers.filter(user=request.user, exam=exam).exists()) else False
|
||||||
|
for question in questions
|
||||||
|
]),
|
||||||
|
"flagged_json": json.dumps([
|
||||||
|
(Flag.objects.filter(content_type=ContentType.objects.get_for_model(question), object_id=question.pk).filter(cid_user__cid=cid).exists()
|
||||||
|
if cid is not None
|
||||||
|
else Flag.objects.filter(content_type=ContentType.objects.get_for_model(question), object_id=question.pk).filter(user=request.user).exists())
|
||||||
|
for question in questions
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user