Implement edit answer functionality with HTMX support; add new endpoint and template for editing stored answers in the marking UI.

This commit is contained in:
Ross
2025-11-11 13:01:51 +00:00
parent 14a54dfa8b
commit 796ac3fb84
4 changed files with 174 additions and 8 deletions
@@ -0,0 +1,35 @@
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">Edit stored answer</h6>
<form hx-post="{% url 'anatomy:mark2_edit_answer' %}" hx-target="closest .card" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="original" value="{{ original }}">
{% if exam_pk %}
<input type="hidden" name="exam_pk" value="{{ exam_pk }}">
{% endif %}
{% if sk is not None %}
<input type="hidden" name="sk" value="{{ sk }}">
{% endif %}
<div class="mb-2">
<label class="form-label">Original</label>
<pre class="small bg-light p-2">{{ original }}</pre>
</div>
<div class="mb-2">
<label class="form-label">New answer text</label>
<textarea class="form-control" name="new_answer" rows="3">{% if existing %}{{ existing.answer }}{% else %}{{ original }}{% endif %}</textarea>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
{% if exam_pk and sk is not None %}
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'anatomy:mark2_exam_marked' exam_pk sk %}" hx-swap="outerHTML" hx-target="closest .card">Cancel</button>
{% else %}
<button type="button" class="btn btn-outline-secondary btn-sm" _="on click remove .card">Cancel</button>
{% endif %}
</div>
</form>
</div>
</div>
@@ -1,13 +1,21 @@
{% comment %}List of distinct answers submitted within this exam for the question, with counts and mark badges{% endcomment %}
<div class="card mt-2">
<div class="card-body">
<style>
/* Hide edit buttons and mark-action forms by default; show only when card has editing-enabled */
.card .edit-btn { display: none !important; }
.card.editing-enabled .edit-btn { display: inline-block !important; }
.card .mark-actions { display: none !important; }
.card.editing-enabled .mark-actions { display: inline-block !important; }
</style>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="card-title mb-0">Answers submitted in this exam</h6>
<div>
<div class="d-flex gap-2 align-items-center">
<div class="btn-group btn-group-sm" role="group" aria-label="Order">
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'count' or not request.GET.order %} active{% endif %}" hx-get="{{ request.path }}?order=count" hx-swap="outerHTML" hx-target="closest .card">By count</button>
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'mark' %} active{% endif %}" hx-get="{{ request.path }}?order=mark" hx-swap="outerHTML" hx-target="closest .card">By score</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="this.closest('.card').classList.toggle('editing-enabled'); this.textContent = this.closest('.card').classList.contains('editing-enabled') ? 'Disable editing' : 'Enable editing'">Enable editing</button>
</div>
</div>
{% if not items %}
@@ -16,18 +24,35 @@
<ul class="list-group">
{% for it in items %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div><pre class="mb-0">{{ it.answer }}</pre></div>
<div class="text-end">
<div class="flex-grow-1 me-3"><pre class="mb-0">{{ it.answer }}</pre></div>
<div class="text-end d-flex align-items-center gap-2">
{# Stored mark badge #}
{% if it.mark == 'correct' %}
<span class="badge bg-success me-2" title="Stored answer marked as correct (2)">2</span>
<span class="badge bg-success me-1" title="Stored answer marked as correct (2)">2</span>
{% elif it.mark == 'half-correct' %}
<span class="badge bg-warning text-dark me-2" title="Stored answer marked as half-correct (1)">1</span>
<span class="badge bg-warning text-dark me-1" title="Stored answer marked as half-correct (1)">1</span>
{% elif it.mark == 'incorrect' %}
<span class="badge bg-danger me-2" title="Stored answer marked as incorrect (0)">0</span>
<span class="badge bg-danger me-1" title="Stored answer marked as incorrect (0)">0</span>
{% else %}
<span class="badge bg-secondary me-2" title="No stored mark for this answer">-</span>
<span class="badge bg-secondary me-1" title="No stored mark for this answer">-</span>
{% endif %}
{# Submission count badge #}
<span class="badge bg-light text-dark me-2" title="Submitted {{ it.count }} times">{{ it.count }}</span>
<form hx-post="{% url 'anatomy:mark2_toggle_answer' %}" hx-target="closest .card" hx-swap="outerHTML" class="d-inline mark-actions">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="answer" value="{{ it.answer }}">
<input type="hidden" name="exam_pk" value="{{ exam.pk }}">
<input type="hidden" name="sk" value="{{ sk }}">
<button class="btn btn-sm btn-outline-success" name="newmark" value="correct" title="Mark as correct (2)">2</button>
<button class="btn btn-sm btn-outline-warning text-dark" name="newmark" value="half-correct" title="Mark as half-correct (1)">1</button>
<button class="btn btn-sm btn-outline-danger" name="newmark" value="incorrect" title="Mark as incorrect (0)">0</button>
<button class="btn btn-sm btn-outline-secondary" name="newmark" value="clear" title="Clear stored mark"></button>
</form>
<button class="btn btn-sm btn-outline-secondary edit-btn" hx-get="{% url 'anatomy:mark2_edit_answer' %}?question_pk={{ question.pk }}&original={{ it.answer|urlencode }}&exam_pk={{ exam.pk }}&sk={{ sk }}" hx-target="closest .card" hx-swap="outerHTML" title="Edit stored answer">Edit</button>
</div>
</li>
{% endfor %}
@@ -35,3 +60,23 @@
{% endif %}
</div>
</div>
<script>
// Keep Edit buttons hidden by default; reveal them when the card receives
// the 'editing-enabled' class (toggled by the Enable editing button).
(function(){
document.querySelectorAll('.card').forEach(function(card){
function update(){
const enable = card.classList.contains('editing-enabled');
card.querySelectorAll('.edit-btn').forEach(function(b){
b.classList.toggle('d-none', !enable);
});
}
// run once in case HTMX inserted the fragment
update();
// observe class changes on the card to re-run update
const obs = new MutationObserver(update);
obs.observe(card, { attributes: true, attributeFilter: ['class'] });
});
})();
</script>
+1
View File
@@ -107,6 +107,7 @@ urlpatterns = [
path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"),
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
]
urlpatterns.extend(generic_view_urls(views.GenericViews))
+86 -1
View File
@@ -818,6 +818,24 @@ def mark2_toggle_answer(request):
"incorrect": Answer.MarkOptions.INCORRECT,
}
# Support clearing a stored answer (delete) via 'clear'
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if newmark == 'clear':
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is not None:
# permission: ensure user can edit this answer
if not a.can_edit(request.user):
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
a.delete()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
if newmark not in mark_map:
return JsonResponse({"status": "error", "message": "invalid mark"}, status=400)
@@ -833,6 +851,12 @@ def mark2_toggle_answer(request):
a.status = status_val
a.save()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
@@ -879,10 +903,71 @@ def mark2_exam_marked(request, exam_pk, sk):
else:
items.sort(key=lambda x: x.get('count', 0), reverse=True)
context = {'exam': exam, 'question': question, 'items': items}
context = {'exam': exam, 'question': question, 'items': items, 'sk': sk}
return render(request, 'anatomy/partials/mark2_exam_marked_fragment.html', context)
@login_required
def mark2_edit_answer(request):
"""HTMX-friendly endpoint to edit/create a stored Answer for a question.
GET: returns a small form fragment.
POST: updates/creates the Answer and, if exam_pk/sk provided, returns the
refreshed exam-marked fragment so the UI updates in-place.
"""
if request.method == 'GET' and request.htmx:
question_pk = request.GET.get('question_pk')
original = request.GET.get('original', '')
exam_pk = request.GET.get('exam_pk')
sk = request.GET.get('sk')
question = get_object_or_404(AnatomyQuestion, pk=question_pk)
# Try to find an existing Answer by compare
existing = question.answers.filter(answer_compare__iexact=original).first()
context = {
'question': question,
'original': original,
'existing': existing,
'exam_pk': exam_pk,
'sk': sk,
}
return render(request, 'anatomy/partials/mark2_edit_answer_fragment.html', context)
if request.method == 'POST' and request.htmx:
question_pk = request.POST.get('question_pk')
original = request.POST.get('original', '')
new_text = request.POST.get('new_answer', '').strip()
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if not question_pk or not new_text:
return HttpResponse('Missing parameters', status=400)
question = get_object_or_404(AnatomyQuestion, pk=question_pk)
a = question.answers.filter(answer_compare__iexact=original).first()
if a is None:
a = Answer()
a.question = question
a.answer = new_text
else:
a.answer = new_text
a.save()
# If we have exam context, refresh the exam-marked fragment for that question
if exam_pk and sk is not None:
# delegate to mark2_exam_marked to re-render the card
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return HttpResponse('OK')
return HttpResponse('OK')
# Fallback: forbidden for non-HTMX or other methods
raise PermissionDenied()
@login_required
def mark2_question_marked(request, pk):
"""Return a partial listing of all stored Answer choices for this question (grouped by status)."""