Refactor collection case navigation and question handling

- Updated links in collection_case_priors.html to point to the new collection_case_questions view.
- Modified collection_case_view_take.html to display case history using the new method.
- Changed collection_detail.html to include links to the new collection_case_questions view.
- Added new URL pattern for collection_case_questions in urls.py.
- Refactored collection_case_details view to handle question-related logic and rendering.
- Introduced new CaseQuestionForm for handling question submissions.
- Created new migration to add override_history and redact_history fields to CaseDetail model.
- Added new templates for collection_case_details.html and collection_case_questions.html to manage case questions.
- Enhanced JavaScript functionality for dynamic question management in collection_case_questions.html.
- Updated useranswer model fields in shorts app to improve feedback and scoring.
This commit is contained in:
Ross
2025-09-15 11:17:57 +01:00
parent 779559997e
commit b9f6b7c837
11 changed files with 231 additions and 23 deletions
+76 -7
View File
@@ -15,6 +15,7 @@ from django.forms import (
CheckboxSelectMultiple,
SplitDateTimeWidget,
)
from django.utils.html import escape
from django.forms import inlineformset_factory
from django.shortcuts import get_object_or_404
from django_jsonforms.forms import JSONSchemaField
@@ -67,7 +68,8 @@ from autocomplete import (
register as autocomplete_register,
)
import logging
from loguru import logger
from generic.forms import (
ExamAuthorFormMixin,
@@ -471,7 +473,7 @@ class CaseForm(ModelForm):
self.collection = None
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
collections = kwargs["initial"].pop("exams")
logging.debug(collections)
logger.debug(collections)
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
@@ -606,13 +608,13 @@ class CaseForm(ModelForm):
instance.save()
self.save_m2m()
logging.debug(f"{self.collection=}")
logger.debug(f"{self.collection=}")
if self.collection is not None:
logging.debug(f"{self.collection=}")
logger.debug(f"{self.collection=}")
case_no = self.collection.cases.count() + 1
logging.debug(f"{case_no=}")
logging.debug(f"{instance=}")
logger.debug(f"{case_no=}")
logger.debug(f"{instance=}")
# Create through model
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
@@ -1010,7 +1012,7 @@ class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
template_name = "atlas/svelte_jsoneditor_widget_override.html"
class CaseDetailForm(ModelForm):
class CaseQuestionForm(ModelForm):
class Meta:
model = CaseDetail
fields = ["question_schema", "question_answers"] # , "user"]
@@ -1020,6 +1022,73 @@ class CaseDetailForm(ModelForm):
"question_answers": SvelteJSONEditorWidgetOverride(),
}
class CaseDetailForm(ModelForm):
class Meta:
model = CaseDetail
fields = ["redact_history", "override_history"]
def __init__(self, *args, case_history: str = None, **kwargs):
"""
case_history: optional explicit history text to show (falls back to instance.case.history)
"""
super().__init__(*args, **kwargs)
# Determine original history text (safe-escaped for HTML)
if case_history is None:
try:
case_history = self.instance.case.history if self.instance and getattr(self.instance, "case", None) else ""
except Exception:
case_history = ""
case_history_html = escape(case_history or "No history available.")
# Ensure override_history has a stable id we can reference from the inline script
override_id = self.fields["override_history"].widget.attrs.get("id", "id_override_history")
self.fields["override_history"].widget.attrs["id"] = override_id
# Optionally make it a textarea style appearance if not already
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
# Build crispy helper/layout embedding the original history and buttons tied to override_history
self.helper = FormHelper()
self.helper.form_tag = False
# Inline HTML block with buttons and a script that copies/clears the override field.
# The script references the explicit override_id above.
history_block = f"""
<details class="mb-3">
<summary class="h6" style="cursor:pointer;"><i class="bi bi-info-circle"></i> Show original case history</summary>
<div class="mt-2 p-2 bg-dark text-light border rounded">
<label class="form-label fw-bold">Original history (read-only)</label>
<div id="original-history" style="white-space: pre-wrap;">{case_history_html}</div>
<div class="form-text text-secondary">Use the buttons below to copy this into the override field or clear the override.</div>
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-light" id="use-original-history">Copy original into override</button>
<button type="button" class="btn btn-sm btn-outline-danger ms-2" id="clear-override-history">Clear override</button>
</div>
</div>
</details>
<script>
document.addEventListener('DOMContentLoaded', function() {{
var copyBtn = document.getElementById('use-original-history');
var clearBtn = document.getElementById('clear-override-history');
var original = '{case_history_html.replace("'", "\\'").replace("\\n", "\\\\n")}';
var overrideField = document.getElementById('{override_id}');
function setOverride(val) {{
if(!overrideField) return; overrideField.value = val; overrideField.dispatchEvent(new Event('input',{{bubbles:true}})); }}
if(copyBtn) copyBtn.addEventListener('click', function(e){{ setOverride(original); }});
if(clearBtn) clearBtn.addEventListener('click', function(e){{ setOverride(''); }});
}});
</script>
"""
# Compose layout: history block then the override field and redact checkbox
self.helper.layout = Layout(
HTML(history_block),
Field("override_history"),
Field("redact_history")
)
class QuestionSchemaForm(ModelForm):
class Meta:
@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-09-15 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0074_alter_seriesimage_image_md5_hash'),
]
operations = [
migrations.AddField(
model_name='casedetail',
name='override_history',
field=models.TextField(blank=True, help_text='This will override the case history for the purpose of the exam/collection.', null=True),
),
migrations.AddField(
model_name='casedetail',
name='redact_history',
field=models.BooleanField(default=False, help_text='Set to true if the history should be redacted whilst taking the case.'),
),
]
+19
View File
@@ -1190,6 +1190,16 @@ class CaseDetail(models.Model):
sort_order = models.IntegerField(default=1000)
redact_history = models.BooleanField(
default=False,
help_text="Set to true if the history should be redacted whilst taking the case."
)
override_history = models.TextField(
null=True, blank=True,
help_text="This will override the case history for the purpose of the exam/collection."
)
class Meta:
ordering = ("sort_order",)
@@ -1213,6 +1223,15 @@ class CaseDetail(models.Model):
def default_viewerstate_string(self):
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"
def get_history_pre(self):
if self.collection.show_history_pre:
if self.redact_history:
return "[Redacted]"
if self.override_history and self.override_history != "":
return self.override_history
return self.case.history
return ""
class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
@@ -0,0 +1,34 @@
{% extends 'atlas/exams.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div>
{% if previous %}
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
{% endif %}
</div>
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
<p>This page allows you to configure how the case is displayed as part of the collection.</p>
<form method="POST" class="post-form">
{% csrf_token %}
{% crispy form form.helper %}
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
</form>
{% endblock %}
{% block js %}
<script>
</script>
<style></style>
{% endblock %}
@@ -7,11 +7,11 @@
<div>
{% if previous %}
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
{% endif %}
</div>
@@ -7,11 +7,11 @@
<div>
{% if previous %}
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
{% endif %}
</div>
@@ -40,9 +40,9 @@
Description: {{case.description}}
</div>
{% endif %}
{% if show_history and case.history %}
{% if show_history %}
<div>
History: {{case.history|linebreaks}}
History: {{case_detail.get_history_pre|linebreaks}}
</div>
{% endif %}
+4 -2
View File
@@ -44,14 +44,16 @@
{{casedetail.case.title}}
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>)
(<a href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-info-square" title="Case details"></i></a>)
{% if casedetail.default_viewerstate %}
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
{% endif %}
)
{% if collection.collection_type == "QUE" %}
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>
(<a href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}'>
{% if casedetail.question_schema %}
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
{% else %}
+5
View File
@@ -161,6 +161,11 @@ urlpatterns = [
views.collection_case_details,
name="collection_case_details",
),
path(
"collection/<int:exam_id>/case/<int:case_id>/questions",
views.collection_case_questions,
name="collection_case_questions",
),
path(
"collection/<int:exam_id>/case/<int:case_id>/priors",
views.collection_case_priors,
+40 -8
View File
@@ -51,6 +51,7 @@ from .forms import (
CaseCollectionForm,
CaseCollectionUpdateCaseForm,
CaseDetailForm,
CaseQuestionForm,
CaseDisplaySetForm,
CaseForm,
CaseResourceFormSet,
@@ -2227,7 +2228,7 @@ def collection_case_priors(request, exam_id, case_id):
@user_is_collection_author_or_atlas_editor
def collection_case_details(request, exam_id, case_id):
def collection_case_questions(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
collection = case_detail.collection
@@ -2244,10 +2245,10 @@ def collection_case_details(request, exam_id, case_id):
example_form = JsonAnswerForm(
request.POST, question_schema=case_detail.question_schema
)
form = CaseDetailForm(instance=case_detail)
form = CaseQuestionForm(instance=case_detail)
# Called if the user saves the main form
elif request.POST.get("submit") == "save":
form = CaseDetailForm(request.POST, instance=case_detail)
form = CaseQuestionForm(request.POST, instance=case_detail)
if form.is_valid():
form.save()
# Add any additional logic or redirection here
@@ -2277,11 +2278,11 @@ def collection_case_details(request, exam_id, case_id):
post_data, question_schema=case_detail.question_schema
)
form = CaseDetailForm(instance=case_detail)
form = CaseQuestionForm(instance=case_detail)
# This shouldn't happen
else:
assert False
form = CaseDetailForm(request.POST, instance=case_detail)
form = CaseQuestionForm(request.POST, instance=case_detail)
else:
post_data = request.POST.copy()
@@ -2290,7 +2291,7 @@ def collection_case_details(request, exam_id, case_id):
post_data, question_schema=case_detail.question_schema
)
form = CaseDetailForm(instance=case_detail)
form = CaseQuestionForm(instance=case_detail)
pass
# post_data = None
@@ -2313,7 +2314,7 @@ def collection_case_details(request, exam_id, case_id):
# case_detail.question_answers = answers
# example_form = JsonAnswerForm(post_data, question_schema=case_detail.question_schema)
# form = CaseDetailForm(instance=case_detail)
# form = CaseQuestionForm(instance=case_detail)
# blank_form = JsonAnswerForm(question_schema=case_detail.question_schema)
case_number, case_count = collection.get_index_of_case(
@@ -2325,7 +2326,7 @@ def collection_case_details(request, exam_id, case_id):
return render(
request,
"atlas/collection_case_detail.html",
"atlas/collection_case_questions.html",
{
"case_detail": case_detail,
"form": form,
@@ -2339,6 +2340,36 @@ def collection_case_details(request, exam_id, case_id):
},
)
@user_is_collection_author_or_atlas_editor
def collection_case_details(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
collection = case_detail.collection
form = CaseDetailForm(instance=case_detail)
case_number, case_count = collection.get_index_of_case(
case_detail.case, case_count=True
)
previous = collection.get_previous_case(case_detail.case)
next = collection.get_next_case(case_detail.case)
return render(
request,
"atlas/collection_case_details.html",
{
"case_detail": case_detail,
"form": form,
"collection": collection,
"case": case_detail.case,
"previous": previous,
"next": next,
"collection_length": case_count,
"case_number": case_number,
},
)
@user_is_collection_author_or_atlas_editor
def collection_mark_overview(request, pk):
@@ -2768,6 +2799,7 @@ def collection_case_view_take(
"form": form,
"collection": collection,
"case": case,
"case_detail": case_detail,
"series_list": series_list,
"series_to_load": series_to_load,
"case_number": case_number,
@@ -0,0 +1,24 @@
# Generated by Django 5.1.4 on 2025-09-15 09:31
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shorts', '0009_questionfinding_annotation_json_3d_and_more'),
]
operations = [
migrations.AlterField(
model_name='useranswer',
name='candidate_feedback',
field=models.TextField(blank=True, help_text='Feedback for the candidate, this is optional but WILL be shown to the candidate.', null=True),
),
migrations.AlterField(
model_name='useranswer',
name='score',
field=models.IntegerField(blank=True, default=0, help_text='Score for the answer. If null then the answer is unmarked. This should be number 0-5.', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)]),
),
]