Implement primary answer management with HTMX support, including form for selecting or creating answers and display templates

This commit is contained in:
Ross
2026-02-16 09:46:57 +00:00
parent ec03def164
commit 17d3b4206a
7 changed files with 165 additions and 3 deletions
+16 -1
View File
@@ -290,4 +290,19 @@ class ExamGroupsForm(ExamGroupsFormMixin):
class SuggestIncorrectAnswerForm(ModelForm):
class Meta:
model = AnatomyQuestion
fields = ["answer_suggest_incorrect"]
fields = ["answer_suggest_incorrect"]
class PrimaryAnswerForm(Form):
existing_answer = ChoiceField(required=False, choices=[], label="Use existing answer")
new_answer = CharField(required=False, widget=Textarea(attrs={"rows":3}), label="Or create new primary answer")
def __init__(self, *args, **kwargs):
question = kwargs.pop("question", None)
super().__init__(*args, **kwargs)
choices = [("", "-- choose existing --")]
if question is not None:
# Only include existing non-proposed CORRECT answers, ordered by answer text
for a in question.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).order_by('answer'):
choices.append((str(a.pk), a.answer))
self.fields["existing_answer"].choices = choices
@@ -0,0 +1,25 @@
<div id="primary-answer-container" class="position-relative primary-answer-wrap py-1">
<strong>{{ question.get_primary_answer }}</strong>
<button
class="edit-primary-btn btn btn-sm btn-link text-muted position-absolute"
style="right:0; top:0;"
hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}"
hx-target="#primary-answer-container"
hx-swap="outerHTML"
aria-label="Edit primary answer"
>
Edit
</button>
<style>
/* Scoped styles for primary answer edit control */
.primary-answer-wrap .edit-primary-btn {
opacity: 0.55;
transition: opacity 120ms ease, color 120ms ease;
}
.primary-answer-wrap:hover .edit-primary-btn {
opacity: 1;
color: inherit;
}
</style>
</div>
@@ -0,0 +1,4 @@
<div id="primary-answer-container">
<span class="small text-muted">No primary answer set</span>
<button class="btn btn-sm btn-link" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">Add primary answer</button>
</div>
@@ -0,0 +1,58 @@
{% load crispy_forms_tags %}
<div id="primary-answer-container">
<form hx-post="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">
{% csrf_token %}
<div class="mb-2">
<label for="id_existing_answer" class="form-label">Select existing answer</label>
<select name="existing_answer" id="id_existing_answer" class="form-select">
{% for val, text in form.existing_answer.field.choices %}
<option value="{{ val }}" {% if val == form.initial.existing_answer %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="mb-2">
<label for="id_new_answer" class="form-label">Or add a new primary answer</label>
<textarea name="new_answer" id="id_new_answer" class="form-control" rows="3"></textarea>
<script>
(function(){
const sel = document.getElementById('id_existing_answer');
const ta = document.getElementById('id_new_answer');
if(!sel || !ta) return;
function update() {
try {
if (ta.value && ta.value.trim().length > 0) {
sel.disabled = true;
} else {
sel.disabled = false;
}
} catch(e) {
// ignore
}
}
// initial state
update();
// listeners
sel.addEventListener('change', function(){
if(this.value && this.value !== '') {
ta.disabled = true;
} else {
ta.disabled = false;
}
});
ta.addEventListener('input', update);
})();
</script>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" type="submit">Save primary answer</button>
<button class="btn btn-secondary btn-sm" type="button" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}?cancel=1" hx-target="#primary-answer-container" hx-swap="outerHTML">Cancel</button>
</div>
</form>
</div>
@@ -36,7 +36,11 @@
</div>
<h6 class="mt-2">Primary answer</h6>
<div class="mb-2"><strong>{{ question.get_primary_answer }}</strong></div>
{% if question.primary_answer %}
{% include 'anatomy/partials/primary_answer_display.html' %}
{% else %}
<div id="primary-answer-container" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-trigger="load" hx-swap="outerHTML"></div>
{% endif %}
<h6>Answers</h6>
<details class="mb-3">
+1
View File
@@ -114,6 +114,7 @@ urlpatterns = [
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"),
path("question/<int:pk>/primary_answer/", views.primary_answer_htmx, name="primary_answer_htmx"),
]
urlpatterns.extend(generic_view_urls(views.GenericViews))
+56 -1
View File
@@ -46,6 +46,7 @@ from .forms import (
AnatomyQuestionForm,
ExamForm,
SuggestIncorrectAnswerForm,
PrimaryAnswerForm,
)
from .models import (
AnatomyQuestion,
@@ -1655,4 +1656,58 @@ def question_add_exam(request, question_id: int):
return HttpResponse(mark_safe(html))
raise PermissionDenied()
raise PermissionDenied()
@login_required
def primary_answer_htmx(request, pk: int):
"""HTMX endpoint to render or update a question's primary answer.
GET: render the form for selecting/creating primary answer.
POST: create/select answer and set `question.primary_answer`, then
return the display fragment.
"""
if not request.htmx:
raise PermissionDenied()
question = get_object_or_404(AnatomyQuestion, pk=pk)
if not question.can_edit(request.user):
return HttpResponse("Invalid permissions", status=403)
if request.method == "POST":
form = PrimaryAnswerForm(request.POST, question=question)
if form.is_valid():
new_text = form.cleaned_data.get("new_answer", "").strip()
existing = form.cleaned_data.get("existing_answer", "")
if new_text:
# create a new Answer and mark it CORRECT
ans = Answer(question=question, answer=new_text, proposed=False, status=Answer.MarkOptions.CORRECT)
ans.save()
question.primary_answer = ans
question.save(update_fields=["primary_answer"])
elif existing:
try:
aid = int(existing)
ans = Answer.objects.get(pk=aid, question=question)
question.primary_answer = ans
question.save(update_fields=["primary_answer"])
except (ValueError, Answer.DoesNotExist):
return HttpResponse("Invalid answer choice", status=400)
# return display fragment
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question})
else:
return HttpResponse("Invalid form", status=400)
# Handle cancel request to restore original state
if request.method == "GET" and request.GET.get("cancel"):
if question.primary_answer:
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question})
else:
return render(request, "anatomy/partials/primary_answer_empty.html", {"question": question})
# GET - render form
form = PrimaryAnswerForm(question=question)
return render(request, "anatomy/partials/primary_answer_form.html", {"form": form, "question": question})