Compare commits

..
37 Commits
Author SHA1 Message Date
Ross 4986c2faa9 Add sync functionality for prerequisite users and enhance result display 2025-10-13 14:18:34 +01:00
Ross 2c30145d3a Add sync functionality for prerequisite users in collection detail 2025-10-13 14:14:17 +01:00
Ross eeef34bffc Add prerequisites handling to CaseCollection and implement user access checks 2025-10-13 13:46:01 +01:00
Ross a3712959ec Enhance form rendering with crispy forms and improve answer timestamp handling in collection views 2025-10-13 13:22:26 +01:00
Ross ed4bd95955 Refactor self-review display logic and enhance user answer feedback in collection templates; improve form serialization for timeout handling 2025-10-13 11:48:13 +01:00
Ross 32d8430f94 Add CID user support in collection history and reset answers functionality 2025-10-13 11:28:07 +01:00
Ross fa08f9cf76 Implement timed-out submission handling and AJAX response for collection case view 2025-10-13 10:32:25 +01:00
Ross 1cf18c6f0d Fix button formatting for user answer reset functionality 2025-10-13 10:26:11 +01:00
Ross 41d1fa605b Fix button formatting and ensure proper user checks in collection reset answers template 2025-10-13 10:05:43 +01:00
Ross a355dd223e Add timestamps for answer tracking in report models and views; enhance timer display in templates 2025-10-13 10:02:37 +01:00
Ross 6f99cf0d53 Add question time limit to CaseCollection model and update related template for countdown display 2025-10-13 09:45:12 +01:00
Ross 682e8160b5 Refactor user collections view to categorize collections into available, in-progress, and finished; update templates for improved display of collection statuses and question rendering. 2025-10-06 13:51:55 +01:00
Ross 569ef760fa Refactor case detail retrieval into a separate function for improved readability and error handling 2025-10-06 11:32:44 +01:00
Ross bdfa16bbb4 Refactor example answers form and remove unused iframe in collection view 2025-10-05 22:41:49 +01:00
Ross 974966e855 Add example answers form rendering and update viewer iframe 2025-10-05 22:24:40 +01:00
Ross 63f399d2c1 Comment out iframe for viewer to improve layout and prevent unnecessary loading 2025-10-05 22:04:27 +01:00
Ross bb20f7d20d Update login prompt to include redirect after successful login 2025-10-05 21:50:22 +01:00
Ross 850b6ddc84 Fix login URL reference in CID and passcode input section 2025-10-05 21:49:22 +01:00
Ross e051a4f15a Add login link prompt for registered users in CID and passcode input section 2025-10-05 21:48:30 +01:00
Ross 735a29e71b Open DICOM viewer details by default and comment out multi-image block for improved user experience 2025-10-01 20:28:54 +01:00
Ross 4fc1de4300 Refactor code structure and remove redundant functions for improved readability 2025-10-01 20:24:10 +01:00
Ross 5400959399 Enhance error handling in example form by displaying non-field errors and providing a debug view for all form errors 2025-10-01 20:09:36 +01:00
Ross 0839eb7e37 Enhance error handling and user experience for JSON answer input in form 2025-09-22 12:45:32 +01:00
Ross f087a6ef49 Add helper functions for question type inference and options management in question editor 2025-09-22 12:39:05 +01:00
Ross ac562ed1e1 Add default question block structure and validation for properties in question editor 2025-09-22 12:08:50 +01:00
Ross 5d6c5e8b1a Implement code changes to enhance functionality and improve performance 2025-09-22 11:29:35 +01:00
Ross 73b12dd96b Add management links to case details in collection_detail template and update question_link_header to include case details 2025-09-22 11:29:19 +01:00
Ross 990907f8f5 Refactor code structure for improved readability and maintainability 2025-09-22 10:45:21 +01:00
Ross ee42d5c1c2 Convert case series images to JSON format in CaseDetail model for improved data handling 2025-09-15 12:03:24 +01:00
Ross 85216b36b0 Fix typo in prior_cases reference and streamline relationship validation in collection_case_priors 2025-09-15 12:01:12 +01:00
Ross 32b246b5ad Update DICOM viewer data source to use get_case_series_nested method for improved case detail rendering 2025-09-15 11:50:09 +01:00
Ross 3b19ddcb5e Enhance get_case_series_nested method to include prior cases and update collection_case_view_take template to add DICOM viewer details 2025-09-15 11:49:09 +01:00
Ross 50f5813a75 Enhance get_series_images_nested method to support excluding specific series IDs and improve query efficiency 2025-09-15 11:43:18 +01:00
Ross 298493a390 Remove redundant author validation check in collection_take_start and streamline access control in ExamOrCollectionGenericBase 2025-09-15 11:34:17 +01:00
Ross de8050a8ca Add author validation in collection_take_start and implement is_author method in AuthorMixin 2025-09-15 11:31:05 +01:00
Ross 0b12d95bdd Fix navigation links in collection case details and enhance form handling for case detail updates 2025-09-15 11:23:20 +01:00
Ross b9f6b7c837 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.
2025-09-15 11:17:57 +01:00
32 changed files with 2037 additions and 679 deletions
+4
View File
@@ -18,6 +18,8 @@ from .models import (
SeriesDetail,
Resource,
CaseDisplaySet,
UserReportAnswer,
CidReportAnswer,
)
from django.forms import ModelForm
@@ -48,6 +50,8 @@ admin.site.register(UncategorisedDicom)
admin.site.register(SeriesDetail)
admin.site.register(Resource)
admin.site.register(CaseDisplaySet)
admin.site.register(UserReportAnswer)
admin.site.register(CidReportAnswer)
class DifferentialInline(admin.TabularInline):
+78 -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,
@@ -213,6 +215,8 @@ class CaseCollectionForm(ModelForm):
"feedback_once_collection_complete",
"collection_type",
"viewer_mode",
"question_time_limit",
"prerequisites",
),
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
Div(
@@ -471,7 +475,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 +610,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 +1014,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 +1024,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.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-10-13 08:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0075_casedetail_override_history_and_more'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='question_time_limit',
field=models.PositiveIntegerField(help_text='Time limit for answering questions in seconds.', null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-10-13 08:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0076_casecollection_question_time_limit'),
]
operations = [
migrations.AlterField(
model_name='casecollection',
name='question_time_limit',
field=models.PositiveIntegerField(blank=True, help_text='Time limit for answering questions in seconds.', null=True),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.1.4 on 2025-10-13 08:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0077_alter_casecollection_question_time_limit'),
]
operations = [
migrations.AddField(
model_name='cidreportanswer',
name='started_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='cidreportanswer',
name='submitted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='userreportanswer',
name='started_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='userreportanswer',
name='submitted_at',
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-10-13 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0078_cidreportanswer_started_at_and_more'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='prerequisites',
field=models.ManyToManyField(blank=True, help_text='Collections that must be completed before this collection can be taken', related_name='dependents', to='atlas.casecollection'),
),
]
+228 -8
View File
@@ -71,6 +71,7 @@ from django.utils import timezone
import reversion
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -566,14 +567,18 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return False
def get_series_images_nested(self, as_json=True):
def get_series_images_nested(self, as_json: bool =True, exclude_series_ids: None | list[int] = None):
"""
Returns a list of lists, where each inner list contains the image URLs
for a single series in this case, in the order of the series.
"""
series_qs = self.series.all()
if exclude_series_ids is not None:
series_qs = self.series.exclude(id__in=exclude_series_ids)
images = [
[image.image.url for image in series.images.all()]
for series in self.series.all()
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images')
]
if as_json:
@@ -581,6 +586,30 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
else:
return images
def get_case_named_stacks(self):
def build_stacks_for(case_obj, prefix=None):
logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
stacks = []
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append({"name": name, "imageIds": images})
return stacks
results = []
logger.debug(f"Building stacks for case {self.pk}")
# main case entry
results.append(
{
"caseId": f"",
"studyId": f"Current Case",
"stacks": build_stacks_for(self, prefix=None),
}
)
return json.dumps(results)
def extract_image_dicom_json_from_ds(ds, url, image_index):
to_keep = [
@@ -914,6 +943,21 @@ class CaseCollection(ExamOrCollectionGenericBase):
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
question_time_limit = models.PositiveIntegerField(
blank=True,
null=True,
help_text="Time limit for answering questions in seconds."
)
# Collections that must be completed before this collection can be taken
prerequisites = models.ManyToManyField(
"self",
blank=True,
symmetrical=False,
related_name="dependents",
help_text="Collections that must be completed before this collection can be taken",
)
class COLLECTION_TYPE_CHOICES(models.TextChoices):
REVIEW = (
"REV",
@@ -1071,6 +1115,49 @@ class CaseCollection(ExamOrCollectionGenericBase):
kwargs={"pk": self.pk, "case_number": cases.index(case)},
)
def check_user_can_take(self, cid, passcode, user=None, active_only=True):
"""
Extend base check_user_can_take to also require completion of any
prerequisite collections.
"""
# Perform the normal access checks first
super().check_user_can_take(cid, passcode, user=user, active_only=active_only)
# If there are prerequisites, the user (or CID) must have completed them
if not self.prerequisites.exists():
return
for prereq in self.prerequisites.all():
# Look up any existing exam record for this user/cid on the prerequisite
ct = ContentType.objects.get_for_model(prereq)
exam_record = None
if cid is not None:
# Find CidUser by cid
try:
cid_user = CidUser.objects.filter(cid=cid).first()
except Exception:
cid_user = None
if cid_user is None:
exam_record = None
else:
exam_record = CidUserExam.objects.filter(
content_type=ct, object_id=prereq.pk, cid_user=cid_user
).first()
else:
# Check for a normal user_user exam record
exam_record = CidUserExam.objects.filter(
content_type=ct, object_id=prereq.pk, user_user=user
).first()
if exam_record is None or not getattr(exam_record, "completed", False):
# Not allowed to take this collection until prereq completed
# Raise the PrerequisiteRequired exception including the prereq object
raise PrerequisiteRequired(
f"Collection not available until prerequisite '{prereq.name}' is completed.",
prereq=prereq,
)
def get_ohif_dicom_json(self, case_title_as_patient_name=True):
studies = []
for n, case in enumerate(self.cases.all()):
@@ -1190,6 +1277,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",)
@@ -1210,9 +1307,93 @@ class CaseDetail(models.Model):
except UserReportAnswer.DoesNotExist:
return None
def get_cid_answers(self, cid):
"""Returns the cid users answers as a json string"""
try:
return CidReportAnswer.objects.get(question=self, cid=cid)
except CidReportAnswer.DoesNotExist:
return None
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 or "No history provided"
return ""
def get_case_series_nested(self, include_priors=True):
case_series_images = self.case.get_series_images_nested(as_json=False)
if include_priors:
logger.debug(f"Checking for prior cases for case {self.case}")
logger.debug(f"Found {self.case.prior_case.count()} prior cases for case {self.case}")
for prior in self.caseprior_set.all():
logger.debug(f"Adding prior case {prior.prior_case} to case {self.case}")
case_series_images.extend(prior.prior_case.get_series_images_nested(as_json=False))
return json.dumps(case_series_images)
def get_case_named_stacks(self, include_priors=True):
def build_stacks_for(case_obj, prefix=None):
stacks = []
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append({"name": name, "imageIds": images})
return stacks
results = []
# main case entry
results.append(
{
"caseId": f"CASE-{self.case.pk}",
"studyId": f"Current Case",
"stacks": build_stacks_for(self.case, prefix=None),
}
)
# include priors as separate entries
if include_priors:
for prior in self.caseprior_set.all():
prior_case = prior.prior_case
results.append(
{
"caseId": f"CASE-{prior_case.pk}",
"studyId": f"Prior: {prior.relation_text}",
"stacks": build_stacks_for(prior_case, prefix="Prior"),
}
)
return json.dumps(results)
def render_example_form(self, request=None):
"""Build and return a rendered HTML snippet for the example answers form.
If `request` is provided it will be passed to the template renderer so
csrf tokens and other context processors work correctly. The returned
HTML contains the json-editor widget (the `json_answer` field) and a
submit button for saving example answers.
"""
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from .forms import JsonAnswerForm
post_data = {}
# Ensure we pass the stored answers into the form so the widget is populated
post_data["json_answer"] = json.dumps(self.question_answers) if self.question_answers is not None else json.dumps({})
form = JsonAnswerForm(post_data, question_schema=self.question_schema)
html = render_to_string("atlas/_rendered_example_form.html", {"form": form}, request=request)
return mark_safe(html)
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")
@@ -1255,6 +1436,10 @@ class BaseReportAnswer(models.Model):
)
completed = models.BooleanField(default=False)
# Timestamp when the user first loaded the question (started answering)
started_at = models.DateTimeField(null=True, blank=True)
# Timestamp when the answer was submitted/saved
submitted_at = models.DateTimeField(null=True, blank=True)
def save(self, *args, **kwargs):
self.clean()
@@ -1276,18 +1461,25 @@ class BaseReportAnswer(models.Model):
raise ValueError("No cid or user specified")
def get_correct_json_answers(self):
logger.debug(f"Getting correct json answers for question {self.question.pk}")
if self.question.question_schema is None or not "properties" in self.question.question_schema:
return []
answers = []
for name, value in self.question.question_schema["properties"].items():
logger.debug(f"Processing question property '{name}' with schema {value}")
try:
user_answer = self.json_answer[name]
except KeyError: # If the schema has changed?
# Safely retrieve the user's answer and the stored correct answer.
# json_answer or question_answers may be None (or not a dict) if not set,
# so check before subscripting to avoid TypeError.
if isinstance(self.json_answer, dict):
user_answer = self.json_answer.get(name, "")
else:
user_answer = ""
try:
correct_answer = self.question.question_answers[name]
except KeyError: # If the schema has changed?
qa = getattr(self.question, "question_answers", None)
if isinstance(qa, dict):
correct_answer = qa.get(name, "")
else:
correct_answer = ""
match value:
@@ -1308,6 +1500,22 @@ class BaseReportAnswer(models.Model):
return answers
#def get_marked_answers_html(self):
# """Returns an HTML representation of the users answers with correct answers highlighted"""
# html = "<div class='json-answers'>"
# for value, user_answer, correct_answer, answer_is_correct, automark in self.get_correct_json_answers():
# if automark:
# if answer_is_correct:
# html += f"<div class='answer correct'>{user_answer}</div>"
# else:
# html += f"<div class='answer incorrect'>Your answer: {user_answer}<br/>Correct answer: {correct_answer}</div>"
# else:
# html += f"<div class='answer unmarked'>Your answer: {user_answer} (Not auto-marked)</div>"
# html += "</div>"
# return format_html(html)
class Meta:
abstract = True
@@ -1582,3 +1790,15 @@ class QuestionSchema(models.Model, AuthorMixin):
def __str__(self) -> str:
return "{}".format(self.name)
class PrerequisiteRequired(Exception):
"""Raised when a user attempts to access a collection but has not completed a prerequisite.
The exception stores an optional `prereq` attribute pointing to the prerequisite
CaseCollection instance so views can render a helpful page linking to it.
"""
def __init__(self, message=None, *, prereq=None):
super().__init__(message or "Prerequisite required")
self.prereq = prereq
@@ -0,0 +1,9 @@
{# Render a small example answers form for a given CaseDetail - expects a context variable `form` (JsonAnswerForm) #}
<form method="POST" class="post-form">
{% csrf_token %}
{# Include any media the field needs (json-editor) #}
<div class="json-editor-wrapper">
{{ form.as_p}}
</div>
</form>
@@ -125,7 +125,7 @@
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-images='{{case.get_series_images_nested}}'
data-named-stacks='{{case.get_case_named_stacks}}'
></div>
</details>
@@ -417,6 +417,13 @@
</p>
{% if casedetail %}
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail can_edit=can_edit %}
{% endif %}
<p class="pre-whitespace"><b>Previous case:</b> {{ case.previous_case.get_link }}</p>
<p class="pre-whitespace"><b>Next case:</b>
@@ -0,0 +1,34 @@
{% extends 'atlas/exams.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div>
{% if previous %}
<a href="{% url 'atlas:collection_case_details' 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>
{% 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>
@@ -129,7 +129,68 @@
<form method="POST" class="post-form">
{% csrf_token %}
{{example_form}}
{# Show example_form non-field errors first #}
{% if example_form.non_field_errors %}
<div class="alert alert-danger">
<strong>Errors:</strong>
<ul class="mb-0">
{% for err in example_form.non_field_errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Render example_form fields explicitly so we can improve error UX for json_answer #}
{% for field in example_form %}
{% if field.name == 'json_answer' %}
<div class="mb-3">
{% if field.errors %}
<div class="alert alert-warning" role="alert">
<strong>Answer required.</strong>
<div>Some questions do not have a default answer, please select below.</div>
<details style="margin-top:8px;">
<summary class="small">Validation details</summary>
<ul class="mb-0">
{% for err in field.errors %}
<li class="small text-muted">{{ err }}</li>
{% endfor %}
</ul>
</details>
</div>
{% endif %}
{{ field }} {# hidden/JS-bound field stays rendered #}
</div>
{% else %}
<div class="mb-3">
{{ field.label_tag }} {{ field }}
{% if field.errors %}
{% for err in field.errors %}
<div class="text-danger small">{{ err }}</div>
{% endfor %}
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
</div>
{% endif %}
{% endfor %}
{# Debug: list all example_form.errors (field -> [errors]) in a collapsed block so nothing is missed #}
{% if example_form.errors %}
<details style="margin-top:8px;">
<summary class="small">All example form errors (debug)</summary>
<ul class="mb-0">
{% for name, errs in example_form.errors.items %}
<li><strong>{{ name }}</strong>
<ul>
{% for e in errs %}
<li class="small text-muted">{{ e }}</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<button type="submit" value="answer" name="submit">Save Correct Answers</button>
</form>
{% endif %}
@@ -172,6 +233,65 @@
const resetQuestionOptionsButton = document.getElementById('reset-question-options');
const questionsContainer = document.getElementById('questions-container');
// Helper: get options array from a question regardless of schema shape
function getQuestionOptions(question) {
if (!question) return [];
if (Array.isArray(question.enum)) return question.enum.slice();
if (question.items && Array.isArray(question.items.enum)) return question.items.enum.slice();
return [];
}
// Helper: infer a UI questionType from the schema if question.questionType is not present
function inferQuestionType(question) {
if (!question) return 'text';
if (question.type === 'array') return 'multiselect';
if (question.format === 'select2' && question.type === 'string' && Array.isArray(getQuestionOptions(question))) return 'dropdown-select';
if (question.format === 'select' && question.type === 'string') return 'dropdown';
if (question.format === 'radio' && question.type === 'string') return 'radio';
if (question.format === 'textarea') return 'textarea';
if (question.type === 'number') return 'number';
if (question.type === 'range') return 'range';
if (question.enum && Array.isArray(question.enum) && question.enum.length === 2) {
const opts = question.enum.map(String).map(s => s.toLowerCase());
if ((opts.includes('yes') && opts.includes('no'))) return 'yesno';
if ((opts.includes('true') && opts.includes('false'))) return 'truefalse';
}
return 'text';
}
// Helper: apply an options array to a question object according to the selected UI type
function applyOptionsToQuestion(question, optionsArray, selectedType) {
optionsArray = (optionsArray || []).filter(o => o !== '');
if (selectedType === 'multiselect') {
question.type = 'array';
question.items = question.items || { type: 'string', enum: [] };
question.items.enum = optionsArray.length ? optionsArray : (question.items.enum || []);
question.uniqueItems = true;
question.format = 'select2';
delete question.enum;
} else {
// convert array->single if present
if (question.type === 'array' && question.items && Array.isArray(question.items.enum)) {
question.enum = question.items.enum.slice();
delete question.items;
delete question.uniqueItems;
}
if (optionsArray.length) {
question.enum = optionsArray;
}
// ensure string type for single-select/other
if (question.enum && Array.isArray(question.enum)) {
question.type = 'string';
}
// set formats for some known selected types
if (selectedType === 'dropdown-select') question.format = 'select2';
else if (selectedType === 'dropdown') question.format = 'select';
else if (selectedType === 'radio') question.format = 'radio';
}
// always store the UI mapping
question.questionType = selectedType;
}
question_editor.updateProps({
onChange: (updatedContent, previousContent, context) => {
// Call the original onChange handler if it exists
@@ -268,6 +388,14 @@
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
if (!currentContent) {
currentContent = {
title: questionBlockTitleInput.value || "Case Questions",
type: "object",
properties: {}
};
}
if (!currentContent.properties) {
currentContent.properties = {};
current_question_number = 1;
@@ -402,6 +530,10 @@
} catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
if (!currentContent || !currentContent.properties) {
console.warn('No properties found in the current content.');
return;
}
const properties = currentContent.properties || {};
// Sort questions by propertyOrder
@@ -564,8 +696,10 @@
while (questionOptionsContainer.firstChild) {
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
}
if (question.enum) {
question.enum.forEach((option, index) => {
// Support both single-select (question.enum) and multiselect (question.items.enum)
const enumList = getQuestionOptions(question) || [];
if (enumList.length) {
enumList.forEach((option, index) => {
const optionInput = document.createElement('div');
optionInput.className = 'option-input';
optionInput.style.marginBottom = '5px';
@@ -574,7 +708,6 @@
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">&times;</button>
`;
questionOptionsContainer.appendChild(optionInput);
// Add event listener to remove the option
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
questionOptionsContainer.removeChild(optionInput);
@@ -584,6 +717,8 @@
} else {
optionsConfigurator.style.display = 'none';
}
// If questionType is not set, infer it from the schema
questionTypeSelect.value = question.questionType || inferQuestionType(question);
// Update the "Add New Question" button to save changes
addNewQuestionButton.textContent = 'Save Changes';
@@ -618,15 +753,13 @@
question.format = questionTypeSelect.value;
// Update the options if applicable
if (question.enum) {
question.enum = Array.from(questionOptionsContainer.querySelectorAll('.option-field'))
.map((input) => input.value.trim())
.filter((option) => option !== ''); // Exclude empty options
}
const newOptions = questionOptionsContainer ? Array.from(questionOptionsContainer.querySelectorAll('.option-field')).map((input) => input.value.trim()).filter((option) => option !== '') : [];
const selectedType = questionTypeSelect ? questionTypeSelect.value : null;
applyOptionsToQuestion(question, newOptions, selectedType);
// Update the editor with the modified question
currentContent.properties[questionKey] = question;
question_editor.update({ json: currentContent });
try { question_editor.update({ json: currentContent }); } catch (e) { console.warn('Failed to update editor after saveEditedQuestion', e); }
toastr.success(`Question "${question.title}" updated successfully.`);
renderQuestions(); // Re-render the questions list
@@ -1,4 +1,5 @@
{% extends 'atlas/base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<span class="collection-name-blend">Collection: {{collection}}</span>
@@ -16,6 +17,20 @@
</h2>
{% if not question_completed and collection.question_time_limit is not None %}
<div id="question-timer-block" style="margin-top:8px; margin-bottom:8px;" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
<strong>Time remaining:</strong>
<span id="question-timer" aria-live="polite">&nbsp;</span>
<div id="question-timer-progress" style="display:inline-block; vertical-align: middle; width: 200px; margin-left:12px;">
<div id="question-timer-progress-outer" style="background:#e9ecef; border-radius:6px; height:10px; overflow:hidden;">
<div id="question-timer-progress-inner" style="width:100%; height:100%; background:var(--timer-color, #28a745);"></div>
</div>
</div>
<div id="timer-htmx-target" style="display:none;"></div>
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
</div>
{% endif %}
{% comment %} <details>
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
</details> {% endcomment %}
@@ -40,9 +55,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 %}
@@ -71,7 +86,17 @@
{% endif %}
{% if collection.show_built_in_viewer %}
<div class="pre-whitespace multi-image-block">
<details id="dicom-viewer-details" open>
<summary>Viewer</summary>
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-named-stacks='{{case_detail.get_case_named_stacks}}'
></div>
</details>
{% comment %} <div class="pre-whitespace multi-image-block">
<details open>
<summary>
Images
@@ -101,7 +126,7 @@
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
</div>
</div>
</div> {% endcomment %}
{% else %}
{% endif %}
@@ -148,35 +173,7 @@
{% if collection.collection_type == "QUE" %}
{% if question_completed %}
<h3>Answers</h3>
<div class="answer-block">
{% for value, user_answer, correct_answer, answer_is_correct, automark in answer.get_correct_json_answers %}
<div class="{% if answer_is_correct %}
correct
{% else %}
incorrect
{% endif %}
{% if automark %}
automark
{% endif %}
">
<h4>{{value.title}}</h4>
{{value.description}}
<div
>
Answer : {{user_answer}}
{% if not answer_is_correct %}
<br/>Correct answer: {{correct_answer}}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% include "atlas/partials/collection_question_answer_block.html" %}
{% if collection.self_review %}
<div>
@@ -222,9 +219,11 @@
{{form.json.errors}}
<div class="form-contents">
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
{{form}}
{{form | crispy}}
</fieldset>
</div>
{% if question_completed %}
<div>
{% if collection.self_review %}
<p>
@@ -244,6 +243,7 @@
<br/>
{% endif %}
</div>
{% endif %}
{% endif %}
{% if previous %}
@@ -343,107 +343,213 @@
{{ form.media }}
{% comment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
<script type="text/javascript">
window.images = {
{% comment %} {% for series in series_list %}
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
{% endfor %} {% endcomment %}
{% for series, prior, relation in series_to_load %}
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
{% endfor %}
// Question time limit countdown + auto-submit
$(function () {
function lockQuestion() {
// Only disable save buttons to prevent further saves
$form = $('form.post-form');
var $saveBtns = $form.find('button[name="save"], #id_answer');
if ($saveBtns.length) {
$saveBtns.prop('disabled', true);
}
$(document).ready(function () {
setTimeout(() => {
window.loadDicomViewer(window.images[0])
}, 500);
})
// Visual feedback: set timer to Locked and progress to red
$progressInner = $('#question-timer-progress-inner');
$('#question-timer').text('Locked');
$progressInner.css('background', '#dc3545');
$progressInner.css('width', '0%');
{% comment %} $('document').ready(function() {
// Get value from either a json string or url pointing to a json file
function process(value) {
var isjson=true;
var result;
$("#question-timer-progress-outer").hide();
}
try {
result = JSON.parse(value);
} catch(e) {
isjson=false;
var timeLimit = {{ collection.question_time_limit|default:'null' }};
var questionCompleted = {{ question_completed|yesno:"true,false" }};
var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
if (!timeLimit || questionCompleted) {
return;
}
if (isjson) {
return result;
{% if answer %}
if ({{answer.completed|yesno:"true,false"}}) {
lockQuestion();
console.debug('Timer: answer already completed, aborting timer init');
return;
}
{% endif %}
var $timer = $('#question-timer');
var $form = $('form.post-form');
if ($timer.length === 0 || $form.length === 0) {
console.debug('Timer: required elements not found, aborting timer init');
return;
}
// Compute remaining based on the canonical start time (if provided)
var remaining = parseInt(timeLimit, 10);
if (answerStartedAtIso) {
try {
var started = new Date(answerStartedAtIso);
var now = new Date();
var elapsed = Math.floor((now - started) / 1000);
remaining = Math.max(0, remaining - elapsed);
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remaining:', remaining);
} catch (e) {
console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e);
}
}
// If time has already elapsed when the page loads, lock the question and do not autosubmit.
if (remaining <= 0) {
console.debug('Timer: time already expired on load, locking without autosubmit');
lockQuestion();
$timer.text(formatTime(0));
return;
}
function formatTime(s) {
var mins = Math.floor(s / 60);
var secs = s % 60;
return mins + ':' + (secs < 10 ? '0' + secs : secs);
}
var $progressInner = $('#question-timer-progress-inner');
$timer.text(formatTime(remaining));
function updateProgress() {
var pct = Math.max(0, Math.min(100, Math.round((remaining / timeLimit) * 100)));
var widthPct = pct;
$progressInner.css('width', widthPct + '%');
// Smooth color transition: green (120) -> orange (30) -> red (0)
// We use a two-stage interpolation so the midpoint (~50%) is orange.
var hue = 0;
if (pct > 50) {
// interpolate from orange (30) to green (120)
var t = (pct - 50) / 50.0; // 0..1
hue = 30 + t * (120 - 30);
} else {
return $.getJSON(value)
.then(function (response) {
return response;
// interpolate from red (0) to orange (30)
var t = pct / 50.0; // 0..1
hue = 0 + t * (30 - 0);
}
var color = 'hsl(' + Math.round(hue) + ', 75%, 40%)';
$progressInner.css('background', color);
}
updateProgress();
var intervalId = setInterval(function () {
remaining -= 1;
if (remaining <= 0) {
clearInterval(intervalId);
$timer.text('0:00');
$progressInner.css('width', '0%');
$progressInner.css('background', '#dc3545');
//var $next = $form.find('button[name="next"]');
//var $finish = $form.find('button[name="finish"]');
// Instead of navigating away, submit via fetch with timed_out flag
// Use HTMX to POST the timed_out flag and then lock the UI
var onAfter = function (evt) {
try {
var resp = null;
try {
var xhr = evt && evt.detail && evt.detail.xhr;
if (xhr && xhr.responseText) {
resp = JSON.parse(xhr.responseText);
}
} catch (e) {
console.debug('No JSON response from timed_out request', e);
}
lockQuestion();
var msg = 'Answer auto-submitted';
if (resp && resp.submitted_at) {
try {
var dt = new Date(resp.submitted_at);
msg += ' at ' + dt.toLocaleTimeString();
} catch (e) {}
}
toastr.info(msg);
} catch (e) {
console.error('Error handling timed_out htmx response', e);
} finally {
htmx.off('htmx:afterRequest', onAfter);
}
};
htmx.on('htmx:afterRequest', onAfter);
// Serialize the entire form so the server receives the user's answers
// along with the timed_out flag. This ensures the form.save() path
// can validate and persist the submitted answers on timeout.
(function(){
var formValues = {};
try {
$.each($form.serializeArray(), function(i, field) {
if (formValues[field.name] !== undefined) {
if (!Array.isArray(formValues[field.name])) {
formValues[field.name] = [formValues[field.name]];
}
formValues[field.name].push(field.value);
} else {
formValues[field.name] = field.value;
}
});
} catch (e) {
console.debug('Failed to serialize form with jQuery, falling back to manual collection', e);
// Fallback: try to collect inputs manually
var inputs = $form.find('input, textarea, select').not(':disabled');
inputs.each(function () {
var $el = $(this);
var name = $el.attr('name');
if (!name) return;
var val = $el.val();
if (formValues[name] !== undefined) {
if (!Array.isArray(formValues[name])) {
formValues[name] = [formValues[name]];
}
formValues[name].push(val);
} else {
formValues[name] = val;
}
});
}
}
$('.editor_holder').each(function() {
// Get the DOM Element
var element = $(this).get(0);
console.log("el", element)
// Ensure the timed_out flag is included
formValues['timed_out'] = '1';
var options_text = $(this).attr('options')
var schema_text = $(this).attr('schema')
var schema = process(schema_text);
var options = process(options_text);
var name = $(this).attr('name');
var hidden_identifier = 'input[name=' + name + ']';
var initial = $(hidden_identifier).val();
// Check if editor is within form
var form = $(this).closest('form')
console.log("form", form)
//Wait for any ajax requests to complete
$.when(schema, options).done(function(schemaresult, optionsresult) {
optionsresult.form_name_root = name;
// Pass initial value though to editor
if (initial) {
optionsresult.startval = JSON.parse(initial);
}
optionsresult.schema = schemaresult;
// console.log(options);
var editor = new JSONEditor(element, optionsresult);
console.log("editor", editor)
if (form) {
$(form).submit(function(e) {
console.log("submitting")
// Set the hidden field value to the editors value
$(hidden_identifier).val(JSON.stringify(editor.getValue()));
// Disable the editor so it's values wont be submitted
//editor.disable();
// Validate the editor's current value against the schema
const errors = editor.validate();
if (errors.length) {
// errors is an array of objects, each with a `path`, `property`, and `message` parameter
// `property` is the schema keyword that triggered the validation error (e.g. "minLength")
// `path` is a dot separated path into the JSON object (e.g. "root.path.to.field")
console.log(errors);
}
else {
console.log("valid");
}
console.log(editor.getValue());
//e.preventDefault();
})
} else {
console.log("No form found")
}
})
htmx.ajax('POST', window.location.href, {
values: formValues,
swap: 'none',
headers: {
'X-CSRFToken': document.querySelector('input[name="csrfmiddlewaretoken"]').value,
},
target: "#timer-htmx-target",
});
})
{% endcomment %}
})();
} else {
$timer.text(formatTime(remaining));
updateProgress();
}
}, 1000);
$form.on('submit', function () {
clearInterval(intervalId);
});
} catch (e) {
console.error('Error initializing timer:', e);
}
});
</script>
{% endblock js %}
+52 -22
View File
@@ -2,6 +2,34 @@
{% load partials %}
{% partialdef casedetails-management-links %}
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
{% if casedetail.default_viewerstate %}
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
{% endif %}
)
(<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 collection.collection_type == "QUE" %}
(<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 %}
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
{% endif %}
</a>
)
{% endif %}
{% if casedetail.case.previous_case %}
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
{% endif %}
{% endpartialdef %}
{% block content %}
<h2>{{collection.name}}</h2>
@@ -14,6 +42,26 @@
Collection Type: {{collection.get_collection_type_display}}<br />
Self review: {{collection.self_review}}<br />
Open access: {{collection.open_access}}<br />
{% if collection.prerequisites.exists %}
Prerequisite collections:
<ul>
{% for prereq in collection.prerequisites.all %}
<li>
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
&nbsp;
</li>
{% endfor %}
</ul>
<p>
<button
title="Sync users from all prerequisite collections into this collection"
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
hx-swap="outerHTML"
class="btn btn-sm btn-secondary"
>Sync prerequisite users</button>
</p>
{% endif %}
</div>
@@ -30,6 +78,9 @@
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% endif %}
<h3>Cases</h3>
<ol id="full-question-list" class="sortable">
{% for casedetail in casesdetails %}
@@ -43,28 +94,7 @@
{% endif %}
{{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>
{% 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 %}'>
{% if casedetail.question_schema %}
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
{% else %}
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
{% endif %}
</a>
)
{% endif %}
{% if casedetail.case.previous_case %}
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
{% endif %}
{% partial casedetails-management-links %}
</li>
@@ -3,6 +3,8 @@
{% block content %}
<h2>{{collection.name}}</h2>
{% if userexams %}
<h3>Users</h3>
<ul>
@@ -23,6 +25,30 @@
</li>
{% endfor %}
</ul>
{% endif %}
{% if cidexams %}
<h3>CID Users</h3>
<ul>
{% for cidexam in cidexams %}
<li id="cid-history-{{ cidexam.cid_user.pk }}">
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{cidexam.cid_user}}</a><b><br/>
Completed: {{cidexam.completed}}<br/>
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this CID user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
@@ -10,10 +10,22 @@
<li class="case">
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
Question started: {{user_answer.started_at}} - Answer submitted: {{user_answer.submitted_at}}
{% if request.user.is_superuser and user_answer %}
&nbsp;(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
{% endif %}
<br/>
<br/>
{% if not user_answer %}
<span class="case-not-answered">Case not answered.</span>
{% else %}
<div class="answer-block">
User answer:
<div class="user-answer">
{% if user_answer.answer %}
{{user_answer.answer}}
{% else %}
{{user_answer.json_answer}}
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
{% if not user_answer %}
Not answered
@@ -43,6 +55,8 @@
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
@@ -1,10 +1,13 @@
{% for cid_user_exam in cid_users %}
{% if cid_user_exam.user_user and cid_user_exam.user_user.id %}
<button class="btn btn-sm btn-primary" title="This will clear the user answers and attempts"
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk cid_user_exam.user_user.id %}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to reset answers for the user? This action cannot be undone."
>{{cid_user_exam.user_user}}</button>
>{{ cid_user_exam.user_user }}</button>
{% else %}
<button class="btn btn-sm btn-secondary" disabled>Unknown user</button>
{% endif %}
{% endfor %}
@@ -6,6 +6,7 @@
{% if request.user.is_authenticated and valid_user %}
User: {{request.user}}<br/>
{% if cid_exam %}
Started: {{cid_exam.start_time}} <br/>
@@ -26,7 +27,7 @@
<a href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}"><button>Overview</button></a>
{% endif %}
{% else %}
Enter your CID and passcode in the below boxes.<br />
Enter your CID and passcode in the below boxes. (Registered user should login <a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">here</a>)<br />
<p><input id="cid-box" type="text" value="Candidate ID"></p>
<p><input id="passcode-box" type="text" value="Passcode"></p>
+40 -2
View File
@@ -67,6 +67,10 @@
{% endfor %}
</details>
</div>
{# Hidden per-case question snippet to inject when case is loaded #}
<div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail %}
</div>
{% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary>
{% for ds in case.display_sets.all %}
@@ -116,10 +120,9 @@
<div id="current-case-history"></div>
<div id="current-case-discussion"></div>
<div id="current-case-report"></div>
<div id="current-case-questions" class="mt-3"></div>
</div>
</div>
<iframe id="viewer" style="width: 100%; height: 500px; border: none"></iframe>
</div>
</div>
{% endblock %}
@@ -223,6 +226,14 @@
"viewerstate": viewerstate
});
// Inject the per-case question snippet into the current case panel
try {
var snippet = $(c).find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.warn('Unable to inject question snippet', e);
}
});
$('.open-displayset').click(function() {
let c = this;
@@ -244,6 +255,15 @@
"annotations": annotations
});
// Inject the per-case question snippet
try {
var caseItem = $(this).closest('.case-item');
var snippet = caseItem.find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.warn('Unable to inject question snippet for displayset', e);
}
});
$('.open-series-local').click(function() {
let c = $(this).closest(".case-item")[0];
@@ -266,6 +286,14 @@
"series": seriesPk,
"images": JSON.stringify(images),
});
// Inject the per-case question snippet
try {
var snippet = $(c).find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.warn('Unable to inject question snippet for series', e);
}
});
$('.open-case, .open-series').click(function() {
var url = $(this).data('target');
@@ -291,6 +319,14 @@
$('#current-case-series').html("<span class='title'>Series:</span> " + $(this).data('series'));
}
bc.postMessage({"type": "open", "url" : url});
// Inject question snippet for this case
try {
var caseItem = $(this).closest('.case-item');
var snippet = caseItem.find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.warn('Unable to inject question snippet', e);
}
//if (win2 == false || win2.closed) {
// win2 = openSecondaryWindow(url);
// console.log('opened', win2)
@@ -368,4 +404,6 @@
vertical-align: middle;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
{% endblock css %}
@@ -0,0 +1,27 @@
<div class="answer-block">
{% for value, user_answer, correct_answer, answer_is_correct, automark in answer.get_correct_json_answers %}
<div class="{% if answer_is_correct %}
correct
{% else %}
incorrect
{% endif %}
{% if automark %}
automark
{% endif %}
">
<h4>{{value.title}}</h4>
{{value.description}}
<div
>
Answer : {{user_answer}}
{% if not answer_is_correct %}
<br/>Correct answer: {{correct_answer}}
{% endif %}
</div>
</div>
{% endfor %}
</div>
@@ -0,0 +1,63 @@
{# Partial: render questions and answers as HTML. #}
{% if case_detail.question_schema %}
<div class="collection-question-block">
<h4>Questions</h4>
<dl class="row">
{% for name, prop in case_detail.question_schema.properties.items %}
<dt class="col-sm-4">{{ prop.title|default:name }}</dt>
<dd class="col-sm-8">
{% if prop.description %}
<div class="mb-1 text-muted small">{{ prop.description }}</div>
{% endif %}
{% if prop.type == "string" and prop.enum %}
<div class="mt-1 small text-muted">Options: {{ prop.enum|join:", " }}</div>
{% else %}
<div class="mt-1 small text-muted">Type: {{ prop.type }}</div>
{% endif %}
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
{% if case_detail.question_answers %}
{% with correct=case_detail.question_answers|get_item:name %}
<div class="mt-2">
<strong>Example / Correct:</strong>
{% if correct %}
<span class="ms-2">{{ correct }}</span>
{% else %}
<span class="text-muted ms-2">(no answer provided)</span>
{% endif %}
</div>
{% endwith %}
{% else %}
<div class="mt-2"><strong>Example / Correct:</strong> <span class="text-muted ms-2">(no answer provided)</span></div>
{% endif %}
{# Optionally render and compare a user's answer passed as `user_answer` in the context #}
{% if user_answer %}
{% if user_answer|get_item:name %}
{% with ua=user_answer|get_item:name %}
{% with correct=case_detail.question_answers|get_item:name %}
<div class="mt-1">
<strong>Your answer:</strong>
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
{% if ua == correct %}
<span class="badge bg-success ms-2">Correct</span>
{% else %}
<span class="badge bg-danger ms-2">Incorrect</span>
{% endif %}
</div>
{% endwith %}
{% endwith %}
{% else %}
<div class="mt-1"><strong>Your answer:</strong> <span class="text-muted ms-2">(no answer)</span></div>
{% endif %}
{% endif %}
</dd>
{% endfor %}
</dl>
</div>
{% else %}
<div class="collection-question-block text-muted">No questions defined for this case.</div>
{% endif %}
@@ -0,0 +1,12 @@
<div class="alert alert-success">
<strong>Two-way sync complete</strong>
<p>Total added: {{ total_added_cid }} CID(s), {{ total_added_user }} user(s), {{ total_added_cid_groups }} CID group(s), {{ total_added_user_groups }} user group(s).</p>
<details>
<summary>Per-collection changes ({{ per_collection_added|length }})</summary>
<ul>
{% for pk, added_cid, added_user, added_cid_groups, added_user_groups in per_collection_added %}
<li>Collection {{ pk }}: +{{ added_cid }} CID(s), +{{ added_user }} user(s), +{{ added_cid_groups }} CID group(s), +{{ added_user_groups }} user group(s)</li>
{% endfor %}
</ul>
</details>
</div>
@@ -0,0 +1,19 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Collection requires completion of another collection</h2>
<p>{{ message }}</p>
{% if prereq %}
<p>
You must complete the collection:
{% if cid and passcode %}
<a href="{% url 'atlas:collection_take_start' prereq.pk %}?cid={{ cid }}&passcode={{ passcode }}">{{ prereq.name }}</a>
{% else %}
<a href="{% url 'atlas:collection_take_start' prereq.pk %}">{{ prereq.name }}</a>
{% endif %}
</p>
{% endif %}
<p>
If you think this is an error, contact the course administrator.
</p>
{% endblock %}
@@ -15,8 +15,11 @@
{% endif %}
{% if collection %}
<div style="float: right;">
Collection:Case details
{% include "atlas/collection_detail.html#casedetails-management-links" %}
</div>
<div>
{% if previous %}
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous question</a>
{% endif %}
+39 -4
View File
@@ -1,17 +1,52 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Collections</h2>
<h2>Collections</h2>
The following collections are available for you to view / take.
The following collections are available for you to view / take. If you received a directly link to a collection it will not appear here unless you have started it.
<h3>Available to start</h3>
<ul>
{% for collection in collections %}
{% for collection in available_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{collection}} {{collelction.is_complete}}</a>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
{% if collection.description %}
<small class="text-muted">— {{ collection.description }}</small>
{% endif %}
</li>
{% empty %}
<li>No collections available to start.</li>
{% endfor %}
</ul>
<h3>In progress</h3>
<ul>
{% for collection in in_progress_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-warning text-dark">In progress</span>
{% if collection.description %}
<small class="text-muted">— {{ collection.description }}</small>
{% endif %}
</li>
{% empty %}
<li>No in-progress collections.</li>
{% endfor %}
</ul>
<h3>Finished</h3>
<ul>
{% for collection in finished_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-success">Completed</span>
{% if collection.description %}
<small class="text-muted">— {{ collection.description }}</small>
{% endif %}
</li>
{% empty %}
<li>No finished collections.</li>
{% endfor %}
</ul>
+20
View File
@@ -80,6 +80,11 @@ urlpatterns = [
views.collection_take_start,
name="collection_take_start",
),
path(
"collection/<int:pk>/sync_prerequisite_users",
views.collection_sync_prerequisite_users,
name="collection_sync_prerequisite_users",
),
path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
path(
"collection/<int:pk>/toggle_results_published",
@@ -128,6 +133,11 @@ urlpatterns = [
views.collection_history_user,
name="collection_history_user",
),
path(
"collection/<int:exam_id>/history/<int:cid>/ciduser",
views.collection_history_ciduser,
name="collection_history_ciduser",
),
path(
"collection/<int:pk>/user_status",
@@ -161,6 +171,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,
@@ -196,6 +211,11 @@ urlpatterns = [
views.collection_reset_answers_user,
name="collection_reset_answers_user",
),
path(
"collection/<int:exam_id>/cid/<int:cid>/reset_answers",
views.collection_reset_answers_ciduser,
name="collection_reset_answers_ciduser",
),
#path(
# "question_schemas_preset",
# views.question_schemas_preset,
+336 -24
View File
@@ -38,6 +38,7 @@ from django.urls import reverse_lazy, reverse
from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType
from generic.models import CidUser, CidUserExam, CimarCase
from atlas.helpers import get_cases_available_to_user
@@ -51,6 +52,7 @@ from .forms import (
CaseCollectionForm,
CaseCollectionUpdateCaseForm,
CaseDetailForm,
CaseQuestionForm,
CaseDisplaySetForm,
CaseForm,
CaseResourceFormSet,
@@ -103,6 +105,7 @@ from .models import (
SeriesImage,
UncategorisedDicom,
UserReportAnswer,
PrerequisiteRequired
)
from .tables import (
CaseCollectionTable,
@@ -302,10 +305,9 @@ def case_displaysets_delete(request, pk):
displayset.delete()
return HttpResponse("<li class='list-group-item' >Display set deleted successfully.")
@login_required
@user_has_case_view_access
def case_detail(request, pk):
# Prefetch all related objects needed for the template
def get_case_for_case_detail(pk: int) -> Case:
try:
case = (
Case.objects
.select_related() # Add any FK fields if needed
@@ -343,6 +345,14 @@ def case_detail(request, pk):
.get(pk=pk)
)
case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()]
return case
except Case.DoesNotExist:
raise Http404("Case not found.")
@login_required
@user_has_case_view_access
def case_detail(request, pk):
case = get_case_for_case_detail(pk)
can_edit = case.check_user_can_edit(request.user)
return render(
@@ -633,9 +643,46 @@ def index(request):
def user_collections(request):
collections = request.user.user_casecollection_exams.all()
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
available_collections = request.user.user_casecollection_exams.all()
# Collections the user has already taken (recorded in generic.CidUserExam)
in_progress_ids = []
finished_ids = []
try:
ct = ContentType.objects.get_for_model(CaseCollection)
taken_exams = CidUserExam.objects.filter(user_user=request.user, content_type=ct)
in_progress_ids = list(
taken_exams.filter(completed=False).values_list("object_id", flat=True).distinct()
)
finished_ids = list(
taken_exams.filter(completed=True).values_list("object_id", flat=True).distinct()
)
except Exception:
# If anything goes wrong determining taken/finished exams, treat as none
in_progress_ids = []
finished_ids = []
return render(request, "atlas/user_collections.html", {"collections": collections})
# Build querysets for the three groups
taken_all_ids = set(in_progress_ids) | set(finished_ids)
# Available to start: explicitly allowed collections that the user hasn't taken at all
available_qs = available_collections.exclude(pk__in=taken_all_ids).order_by("name")
# In-progress (taken but not completed)
in_progress_qs = CaseCollection.objects.filter(pk__in=list(in_progress_ids)).order_by("name")
# Finished (completed)
finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name")
return render(
request,
"atlas/user_collections.html",
{
"available_collections": available_qs,
"in_progress_collections": in_progress_qs,
"finished_collections": finished_qs,
},
)
@login_required
def collection_options(request):
@@ -2056,9 +2103,11 @@ def collection_history(request, exam_id: int):
"sort_order"
)
userexams = collection.get_cid_user_exams()
userexams = collection.get_user_exams()
cidexams = collection.get_cid_exams()
print(userexams)
print("useruserexams:", userexams)
print("cidexams:", cidexams)
return render(
request,
@@ -2068,6 +2117,7 @@ def collection_history(request, exam_id: int):
"casesdetails": casedetails,
"can_edit": True,
"userexams": userexams,
"cidexams": cidexams,
},
)
@@ -2101,6 +2151,32 @@ def collection_history_user(request, exam_id: int, user_pk: int):
)
@user_is_collection_author_or_atlas_editor
def collection_history_ciduser(request, exam_id: int, cid: int):
collection = get_object_or_404(CaseCollection, pk=exam_id)
casedetails = CaseDetail.objects.filter(
collection=collection,
).order_by("sort_order")
print(casedetails)
user_answers = [
(casedetail, casedetail.get_cid_answers(cid)) for casedetail in casedetails
]
return render(
request,
"atlas/collection_history_user.html",
{
"collection": collection,
"user": cid,
"casedetails": casedetails,
"user_answers": user_answers,
},
)
def collection_take_start(request, pk, cid=None, passcode=None):
@@ -2113,22 +2189,29 @@ def collection_take_start(request, pk, cid=None, passcode=None):
Returns:
_type_: _description_
"""
print(f"Collection take start: {pk}, cid: {cid}, passcode: {passcode}")
collection = get_object_or_404(CaseCollection, pk=pk)
print(f"Collection: {collection}")
try:
collection.check_user_can_take(cid, passcode, request.user)
valid_user = True
except Http404:
valid_user = False
#valid_user = collection.check_logged_in_user(request)
except PrerequisiteRequired as e:
# Show a friendly page linking to the required collection rather than 404
prereq = getattr(e, 'prereq', None)
return render(
request,
'atlas/prerequisite_required.html',
{'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode},
)
#valid_user = collection.check_logged_in_user(request)
cid_exam = None
if collection.collection_type == "REV" or valid_user:
cid_exam = collection.get_cid_user_exams(user_user=request.user).first()
cid_exam = collection.get_cid_and_user_exams(user_user=request.user).first()
template_variables = {
"collection": collection,
@@ -2159,16 +2242,16 @@ def collection_case_priors(request, exam_id, case_id):
p.delete()
return HttpResponse(f"Case removed")
elif "prior_case_id" in request.POST:
if not request.POST["relation"]:
return HttpResponse(
"You need to enter text to describe the relationship between the cases"
)
prior_case = Case.objects.get(pk=request.POST["prior_case_id"])
p, created = CasePrior.objects.get_or_create(
case_detail=case_detail, prior_case=prior_case
)
p.relation_text = request.POST["relation"]
if not p.relation_text:
return HttpResponse(
"You need to enter text to describe the relationship between the cases"
)
p.prior_visibility = request.POST["prior_visibility"]
p.save()
@@ -2227,7 +2310,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 +2327,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 +2360,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 +2373,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 +2396,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 +2408,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 +2422,47 @@ 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
if request.method == "POST":
form = CaseDetailForm(request.POST, instance=case_detail)
if form.is_valid():
form.save()
if request.htmx:
return HttpResponse("Saved")
return redirect(request.path)
else:
if request.htmx:
return HttpResponse("Invalid form", status=400)
else:
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):
@@ -2496,7 +2620,11 @@ def collection_take_overview(
"""
collection = get_object_or_404(CaseCollection, pk=pk)
try:
collection.check_user_can_take(cid, passcode, request.user)
except PrerequisiteRequired as e:
prereq = getattr(e, 'prereq', None)
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
cid_user_exam = collection.get_or_create_cid_user_exam(
cid=cid, user_user=request.user
@@ -2569,7 +2697,11 @@ def collection_case_view_take_answers(
""" """
collection = get_object_or_404(CaseCollection, pk=pk)
try:
collection.check_user_can_take(cid, passcode, request.user)
except PrerequisiteRequired as e:
prereq = getattr(e, 'prereq', None)
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
case, case_count = collection.get_case_by_index(case_number, case_count=True)
@@ -2618,7 +2750,11 @@ def collection_case_view_take(
form = None
answer: None | CidReportAnswer | UserReportAnswer = None
try:
collection.check_user_can_take(cid, passcode, request.user)
except PrerequisiteRequired as e:
prereq = getattr(e, 'prereq', None)
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
case, case_count = collection.get_case_by_index(case_number, case_count=True)
@@ -2651,6 +2787,29 @@ def collection_case_view_take(
).first()
ReportAnswerForm = UserQuestionAnswerForm
# If we have an answer object but it doesn't have a started_at, set it now.
if answer and getattr(answer, 'started_at', None) is None:
try:
answer.started_at = timezone.now()
answer.save()
except Exception:
# Non-fatal: if saving fails, continue without blocking the user
logger.exception("Failed to set started_at on answer", exc_info=True)
# If no answer exists yet, create a placeholder so we have a started_at timestamp
if not answer and collection.collection_type in ("REP", "QUE"):
try:
if cid is not None:
answer = CidReportAnswer(question=case_detail, cid=cid)
else:
answer = UserReportAnswer(question=case_detail, user=request.user)
answer.started_at = timezone.now()
answer.save()
except Exception:
# If creation fails, leave answer as None; the normal flow will handle form creation
answer = None
logger.exception("Failed to create initial answer object", exc_info=True)
if request.method == "POST":
if collection.collection_type in ("REP", "QUE"):
if not collection.publish_results:
@@ -2668,6 +2827,8 @@ def collection_case_view_take(
answer = form.save(commit=False)
answer.set_cid_or_user(cid=cid, user=request.user)
answer.question = case_detail
# Record submission timestamp
answer.submitted_at = timezone.now()
# answer.published_date = timezone.now()
if "complete_case" in request.POST:
@@ -2675,11 +2836,44 @@ def collection_case_view_take(
raise Http404("Self review not enabled")
answer.completed = True
# If this was a timed-out submission, mark as completed
if request.POST.get('timed_out') == '1':
answer.completed = True
## Also mark the overall exam as completed for this user/CID
#try:
# cid_user_exam.completed = True
# cid_user_exam.save()
# # Record an exam status entry
# collection.exam_user_status.create(
# cid_user_exam=cid_user_exam,
# status="submitted",
# extra="autosubmit",
# )
#except Exception:
# logger.exception("Failed to mark cid_user_exam completed on autosubmit")
answer.save()
cid_user_exam.end_time = timezone.now()
cid_user_exam.save()
# If this was an AJAX/HTMX request, return JSON so the client
# can remain on the same page and update the UI without redirect.
# Detect HTMX/XHR requests robustly. HTMX sets the HX-Request header.
is_ajax = (
request.headers.get('HX-Request', '').lower() == 'true'
or request.headers.get('x-requested-with') == 'XMLHttpRequest'
or getattr(request, 'htmx', False)
)
if is_ajax:
return JsonResponse(
{
'status': 'ok',
'locked': answer.completed,
'submitted_at': answer.submitted_at.isoformat() if getattr(answer, 'submitted_at', None) else None,
}
)
if cid is not None:
kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
redirect_url = "atlas:collection_case_view_take"
@@ -2768,6 +2962,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,
@@ -2785,6 +2980,7 @@ def collection_case_view_take(
"cid_user_exam": cid_user_exam,
"question_completed": question_completed,
"self_review": self_review,
"answer_started_at_iso": answer.started_at.isoformat() if answer and getattr(answer, 'started_at', None) else None,
},
)
@@ -2811,6 +3007,10 @@ def collection_case_view(request, pk, case_number):
case, case_count = collection.get_case_by_index(case_number, case_count=True)
case = get_case_for_case_detail(case.pk)
casedetail = CaseDetail.objects.get(case=case, collection=collection)
series_list = case.series.all().prefetch_related("images", "examination", "plane")
previous = case_number > 0
@@ -2824,6 +3024,7 @@ def collection_case_view(request, pk, case_number):
"collection": collection,
"collection_length": case_count,
"case": case,
"casedetail": casedetail,
"series_list": series_list,
"case_number": case_number,
"previous": previous,
@@ -3360,6 +3561,95 @@ def collection_question_schemas(request, exam_id: int):
)
@user_is_collection_author_or_atlas_editor
def collection_sync_prerequisite_users(request, pk: int):
"""Copy valid cid_users and user_users from all prerequisite collections into this collection.
This endpoint is intended to be called via HTMX from the collection detail page.
"""
if not request.htmx:
raise Http404("Invalid request")
collection = get_object_or_404(CaseCollection, pk=pk)
# Collect the closure of collections: the target collection and all prerequisites (recursively)
to_visit = [collection]
visited = {}
while to_visit:
col = to_visit.pop()
if col.pk in visited:
continue
visited[col.pk] = col
for p in col.prerequisites.all():
if p.pk not in visited:
to_visit.append(p)
collections = list(visited.values())
# Compute the union of all cid users and user users across the set
union_cid_users = set()
union_user_users = set()
union_cid_groups = set()
union_user_groups = set()
for col in collections:
union_cid_users.update(list(col.valid_cid_users.all()))
union_user_users.update(list(col.valid_user_users.all()))
union_cid_groups.update(list(col.cid_user_groups.all()))
union_user_groups.update(list(col.user_user_groups.all()))
# Apply the union to each collection and count additions
total_added_cid = 0
total_added_user = 0
total_added_cid_groups = 0
total_added_user_groups = 0
per_collection_added = []
for col in collections:
added_cid = 0
added_user = 0
added_cid_groups = 0
added_user_groups = 0
for cid_user in union_cid_users:
if not col.valid_cid_users.filter(pk=cid_user.pk).exists():
col.valid_cid_users.add(cid_user)
added_cid += 1
for user in union_user_users:
if not col.valid_user_users.filter(pk=user.pk).exists():
col.valid_user_users.add(user)
added_user += 1
# Sync groups as well
for gid in union_cid_groups:
if not col.cid_user_groups.filter(pk=gid.pk).exists():
col.cid_user_groups.add(gid)
added_cid_groups += 1
for ug in union_user_groups:
if not col.user_user_groups.filter(pk=ug.pk).exists():
col.user_user_groups.add(ug)
added_user_groups += 1
added_user += added_user_groups
added_cid += added_cid_groups
if added_cid or added_user:
col.save()
total_added_cid += added_cid
total_added_user += added_user
total_added_cid_groups += added_cid_groups
total_added_user_groups += added_user_groups
per_collection_added.append((col.pk, added_cid, added_user, added_cid_groups, added_user_groups))
html = render_to_string(
"atlas/partials/sync_prereq_result.html",
{
"total_added_cid": total_added_cid,
"total_added_user": total_added_user,
"total_added_cid_groups": total_added_cid_groups,
"total_added_user_groups": total_added_user_groups,
"per_collection_added": per_collection_added,
},
request=request,
)
return HttpResponse(html)
def collection_reset_answers_user_list(request, exam_id: int):
if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id)
@@ -3378,6 +3668,7 @@ def collection_reset_answers_user_list(request, exam_id: int):
raise Http404("Invalid request")
@user_is_collection_author_or_atlas_editor
def collection_reset_answers_user(request, exam_id: int, user_id: int):
if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id)
@@ -3398,6 +3689,27 @@ def collection_reset_answers_user(request, exam_id: int, user_id: int):
else:
raise Http404("Invalid request")
@user_is_collection_author_or_atlas_editor
def collection_reset_answers_ciduser(request, exam_id: int, cid: int):
if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id)
# Select all case details (answers are linked to these)
case_details = collection.casedetail_set.all().prefetch_related()
# Delete all answers
for case in case_details:
cid_answers = case.cidreportanswer_set.filter(cid=cid)
cid_answers.delete()
collection.cid_users.filter(cid=cid).delete()
collection.exam_user_status.filter(cid_user_exam__cid__pk=cid).delete()
return HttpResponse(f"CID [{cid}] answers deleted")
else:
raise Http404("Invalid request")
def collection_reset_answers(request, exam_id: int):
if request.htmx:
+4
View File
@@ -63,6 +63,10 @@ class AuthorMixin():
"""Remove an author from the object"""
self.author.remove(user)
def is_author(self, user: User) -> bool:
"""Returns True if the user is an author of the object"""
return self.author.filter(id=user.id).exists()
class UserConfigurablePaginationMixin:
default_per_page = 25
+34 -1
View File
@@ -768,6 +768,9 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
Raises:
Http404: If user does not have access
"""
if user is not None and self.is_author(user):
return
# If we are limiting by dates check that the current date is within the range
if self.restrict_to_dates:
if self.start_date >= timezone.now() or (
@@ -902,9 +905,10 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
def get_question_user_user_answer(self, question_index, user):
raise NotImplementedError
def get_cid_user_exams(
def get_cid_and_user_exams(
self, cid_user: Optional["CidUser"] = None, user_user: User | None = None
) -> "CidUserExam":
"""Returns a queryset of CidUserExam for this exam/collection"""
content_type = ContentType.objects.get_for_model(self)
if cid_user is None and user_user is None:
return CidUserExam.objects.filter(
@@ -921,6 +925,35 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
content_type=content_type, object_id=self.pk, user_user=user_user
)
def get_cid_exams(self, cid_user: Optional["CidUser"]= None) -> "CidUserExam":
"""Returns a queryset of CidUserExam for this exam/collection"""
content_type = ContentType.objects.get_for_model(self)
if cid_user is None:
# Only return pure CID entries (cid_user set, user_user null)
return CidUserExam.objects.filter(
content_type=content_type,
object_id=self.pk,
cid_user__isnull=False,
user_user__isnull=True,
)
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, cid_user=cid_user
)
def get_user_exams(self, user_user: Optional[User] = None) -> "CidUserExam":
"""Returns a queryset of CidUserExam for this exam/collection"""
content_type = ContentType.objects.get_for_model(self)
if user_user is None:
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, user_user__isnull=False, cid_user__isnull=True
)
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, user_user=user_user
)
def clone_model(self):
M2M_fields = (
"exam_questions",
+157 -157
View File
File diff suppressed because one or more lines are too long
@@ -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)]),
),
]