Implement primary answer management with HTMX support, including form for selecting or creating answers and display templates
This commit is contained in:
+16
-1
@@ -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">
|
||||
|
||||
@@ -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
@@ -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})
|
||||
Reference in New Issue
Block a user