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:
@@ -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 %}
|
{% 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 mt-2">
|
||||||
<div class="card-body">
|
<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">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h6 class="card-title mb-0">Answers submitted in this exam</h6>
|
<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">
|
<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 == '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>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% if not items %}
|
{% if not items %}
|
||||||
@@ -16,18 +24,35 @@
|
|||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
{% for it in items %}
|
{% for it in items %}
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<div><pre class="mb-0">{{ it.answer }}</pre></div>
|
<div class="flex-grow-1 me-3"><pre class="mb-0">{{ it.answer }}</pre></div>
|
||||||
<div class="text-end">
|
<div class="text-end d-flex align-items-center gap-2">
|
||||||
|
{# Stored mark badge #}
|
||||||
{% if it.mark == 'correct' %}
|
{% 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' %}
|
{% 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' %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Submission count badge #}
|
||||||
<span class="badge bg-light text-dark me-2" title="Submitted {{ it.count }} times">{{ it.count }}</span>
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -35,3 +60,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ urlpatterns = [
|
|||||||
path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"),
|
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("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("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))
|
urlpatterns.extend(generic_view_urls(views.GenericViews))
|
||||||
|
|||||||
+86
-1
@@ -818,6 +818,24 @@ def mark2_toggle_answer(request):
|
|||||||
"incorrect": Answer.MarkOptions.INCORRECT,
|
"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:
|
if newmark not in mark_map:
|
||||||
return JsonResponse({"status": "error", "message": "invalid mark"}, status=400)
|
return JsonResponse({"status": "error", "message": "invalid mark"}, status=400)
|
||||||
|
|
||||||
@@ -833,6 +851,12 @@ def mark2_toggle_answer(request):
|
|||||||
a.status = status_val
|
a.status = status_val
|
||||||
a.save()
|
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"})
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@@ -879,10 +903,71 @@ def mark2_exam_marked(request, exam_pk, sk):
|
|||||||
else:
|
else:
|
||||||
items.sort(key=lambda x: x.get('count', 0), reverse=True)
|
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)
|
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
|
@login_required
|
||||||
def mark2_question_marked(request, pk):
|
def mark2_question_marked(request, pk):
|
||||||
"""Return a partial listing of all stored Answer choices for this question (grouped by status)."""
|
"""Return a partial listing of all stored Answer choices for this question (grouped by status)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user