Implement primary answer management with HTMX support, including form for selecting or creating answers and display templates
This commit is contained in:
@@ -291,3 +291,18 @@ class SuggestIncorrectAnswerForm(ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = AnatomyQuestion
|
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>
|
</div>
|
||||||
|
|
||||||
<h6 class="mt-2">Primary answer</h6>
|
<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>
|
<h6>Answers</h6>
|
||||||
<details class="mb-3">
|
<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("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"),
|
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))
|
urlpatterns.extend(generic_view_urls(views.GenericViews))
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from .forms import (
|
|||||||
AnatomyQuestionForm,
|
AnatomyQuestionForm,
|
||||||
ExamForm,
|
ExamForm,
|
||||||
SuggestIncorrectAnswerForm,
|
SuggestIncorrectAnswerForm,
|
||||||
|
PrimaryAnswerForm,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
AnatomyQuestion,
|
AnatomyQuestion,
|
||||||
@@ -1656,3 +1657,57 @@ def question_add_exam(request, question_id: int):
|
|||||||
|
|
||||||
|
|
||||||
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