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, SeriesDetail,
Resource, Resource,
CaseDisplaySet, CaseDisplaySet,
UserReportAnswer,
CidReportAnswer,
) )
from django.forms import ModelForm from django.forms import ModelForm
@@ -48,6 +50,8 @@ admin.site.register(UncategorisedDicom)
admin.site.register(SeriesDetail) admin.site.register(SeriesDetail)
admin.site.register(Resource) admin.site.register(Resource)
admin.site.register(CaseDisplaySet) admin.site.register(CaseDisplaySet)
admin.site.register(UserReportAnswer)
admin.site.register(CidReportAnswer)
class DifferentialInline(admin.TabularInline): class DifferentialInline(admin.TabularInline):
+78 -7
View File
@@ -15,6 +15,7 @@ from django.forms import (
CheckboxSelectMultiple, CheckboxSelectMultiple,
SplitDateTimeWidget, SplitDateTimeWidget,
) )
from django.utils.html import escape
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_jsonforms.forms import JSONSchemaField from django_jsonforms.forms import JSONSchemaField
@@ -67,7 +68,8 @@ from autocomplete import (
register as autocomplete_register, register as autocomplete_register,
) )
import logging from loguru import logger
from generic.forms import ( from generic.forms import (
ExamAuthorFormMixin, ExamAuthorFormMixin,
@@ -213,6 +215,8 @@ class CaseCollectionForm(ModelForm):
"feedback_once_collection_complete", "feedback_once_collection_complete",
"collection_type", "collection_type",
"viewer_mode", "viewer_mode",
"question_time_limit",
"prerequisites",
), ),
Fieldset("Valid User Groups", *user_fields) if user_fields else None, Fieldset("Valid User Groups", *user_fields) if user_fields else None,
Div( Div(
@@ -471,7 +475,7 @@ class CaseForm(ModelForm):
self.collection = None self.collection = None
if kwargs["initial"] is not None and "exams" in kwargs["initial"]: if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
collections = kwargs["initial"].pop("exams") collections = kwargs["initial"].pop("exams")
logging.debug(collections) logger.debug(collections)
self.collection = get_object_or_404(CaseCollection, pk=collections[0]) self.collection = get_object_or_404(CaseCollection, pk=collections[0])
@@ -606,13 +610,13 @@ class CaseForm(ModelForm):
instance.save() instance.save()
self.save_m2m() self.save_m2m()
logging.debug(f"{self.collection=}") logger.debug(f"{self.collection=}")
if self.collection is not None: if self.collection is not None:
logging.debug(f"{self.collection=}") logger.debug(f"{self.collection=}")
case_no = self.collection.cases.count() + 1 case_no = self.collection.cases.count() + 1
logging.debug(f"{case_no=}") logger.debug(f"{case_no=}")
logging.debug(f"{instance=}") logger.debug(f"{instance=}")
# Create through model # Create through model
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no) # 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" template_name = "atlas/svelte_jsoneditor_widget_override.html"
class CaseDetailForm(ModelForm): class CaseQuestionForm(ModelForm):
class Meta: class Meta:
model = CaseDetail model = CaseDetail
fields = ["question_schema", "question_answers"] # , "user"] fields = ["question_schema", "question_answers"] # , "user"]
@@ -1020,6 +1024,73 @@ class CaseDetailForm(ModelForm):
"question_answers": SvelteJSONEditorWidgetOverride(), "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 QuestionSchemaForm(ModelForm):
class Meta: 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 import reversion
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@@ -566,14 +567,18 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return False 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 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. 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 = [ images = [
[image.image.url for image in series.images.all()] [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: if as_json:
@@ -581,6 +586,30 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
else: else:
return images 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): def extract_image_dicom_json_from_ds(ds, url, image_index):
to_keep = [ 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.") 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): class COLLECTION_TYPE_CHOICES(models.TextChoices):
REVIEW = ( REVIEW = (
"REV", "REV",
@@ -1071,6 +1115,49 @@ class CaseCollection(ExamOrCollectionGenericBase):
kwargs={"pk": self.pk, "case_number": cases.index(case)}, 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): def get_ohif_dicom_json(self, case_title_as_patient_name=True):
studies = [] studies = []
for n, case in enumerate(self.cases.all()): for n, case in enumerate(self.cases.all()):
@@ -1190,6 +1277,16 @@ class CaseDetail(models.Model):
sort_order = models.IntegerField(default=1000) 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: class Meta:
ordering = ("sort_order",) ordering = ("sort_order",)
@@ -1210,9 +1307,93 @@ class CaseDetail(models.Model):
except UserReportAnswer.DoesNotExist: except UserReportAnswer.DoesNotExist:
return None 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): def default_viewerstate_string(self):
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}" 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): class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE) case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case") 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) 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): def save(self, *args, **kwargs):
self.clean() self.clean()
@@ -1276,18 +1461,25 @@ class BaseReportAnswer(models.Model):
raise ValueError("No cid or user specified") raise ValueError("No cid or user specified")
def get_correct_json_answers(self): 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: if self.question.question_schema is None or not "properties" in self.question.question_schema:
return [] return []
answers = [] answers = []
for name, value in self.question.question_schema["properties"].items(): for name, value in self.question.question_schema["properties"].items():
logger.debug(f"Processing question property '{name}' with schema {value}")
try: # Safely retrieve the user's answer and the stored correct answer.
user_answer = self.json_answer[name] # json_answer or question_answers may be None (or not a dict) if not set,
except KeyError: # If the schema has changed? # so check before subscripting to avoid TypeError.
if isinstance(self.json_answer, dict):
user_answer = self.json_answer.get(name, "")
else:
user_answer = "" user_answer = ""
try:
correct_answer = self.question.question_answers[name] qa = getattr(self.question, "question_answers", None)
except KeyError: # If the schema has changed? if isinstance(qa, dict):
correct_answer = qa.get(name, "")
else:
correct_answer = "" correct_answer = ""
match value: match value:
@@ -1308,6 +1500,22 @@ class BaseReportAnswer(models.Model):
return answers 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: class Meta:
abstract = True abstract = True
@@ -1582,3 +1790,15 @@ class QuestionSchema(models.Model, AuthorMixin):
def __str__(self) -> str: def __str__(self) -> str:
return "{}".format(self.name) 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" <div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;" style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false" data-auto-cache-stack="false"
data-images='{{case.get_series_images_nested}}' data-named-stacks='{{case.get_case_named_stacks}}'
></div> ></div>
</details> </details>
@@ -417,6 +417,13 @@
</p> </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>Previous case:</b> {{ case.previous_case.get_link }}</p>
<p class="pre-whitespace"><b>Next case:</b> <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> <div>
{% if previous %} {% 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 %} {% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}] 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 %} {% 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 %} {% endif %}
</div> </div>
@@ -7,11 +7,11 @@
<div> <div>
{% if previous %} {% 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 %} {% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}] 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 %} {% 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 %} {% endif %}
</div> </div>
@@ -129,7 +129,68 @@
<form method="POST" class="post-form"> <form method="POST" class="post-form">
{% csrf_token %} {% 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> <button type="submit" value="answer" name="submit">Save Correct Answers</button>
</form> </form>
{% endif %} {% endif %}
@@ -172,6 +233,65 @@
const resetQuestionOptionsButton = document.getElementById('reset-question-options'); const resetQuestionOptionsButton = document.getElementById('reset-question-options');
const questionsContainer = document.getElementById('questions-container'); 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({ question_editor.updateProps({
onChange: (updatedContent, previousContent, context) => { onChange: (updatedContent, previousContent, context) => {
// Call the original onChange handler if it exists // Call the original onChange handler if it exists
@@ -268,6 +388,14 @@
currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
} }
if (!currentContent) {
currentContent = {
title: questionBlockTitleInput.value || "Case Questions",
type: "object",
properties: {}
};
}
if (!currentContent.properties) { if (!currentContent.properties) {
currentContent.properties = {}; currentContent.properties = {};
current_question_number = 1; current_question_number = 1;
@@ -402,6 +530,10 @@
} catch { } catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); 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 || {}; const properties = currentContent.properties || {};
// Sort questions by propertyOrder // Sort questions by propertyOrder
@@ -564,8 +696,10 @@
while (questionOptionsContainer.firstChild) { while (questionOptionsContainer.firstChild) {
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild); questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
} }
if (question.enum) { // Support both single-select (question.enum) and multiselect (question.items.enum)
question.enum.forEach((option, index) => { const enumList = getQuestionOptions(question) || [];
if (enumList.length) {
enumList.forEach((option, index) => {
const optionInput = document.createElement('div'); const optionInput = document.createElement('div');
optionInput.className = 'option-input'; optionInput.className = 'option-input';
optionInput.style.marginBottom = '5px'; 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> <button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">&times;</button>
`; `;
questionOptionsContainer.appendChild(optionInput); questionOptionsContainer.appendChild(optionInput);
// Add event listener to remove the option // Add event listener to remove the option
optionInput.querySelector('.remove-option-button').addEventListener('click', () => { optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
questionOptionsContainer.removeChild(optionInput); questionOptionsContainer.removeChild(optionInput);
@@ -584,6 +717,8 @@
} else { } else {
optionsConfigurator.style.display = 'none'; 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 // Update the "Add New Question" button to save changes
addNewQuestionButton.textContent = 'Save Changes'; addNewQuestionButton.textContent = 'Save Changes';
@@ -618,15 +753,13 @@
question.format = questionTypeSelect.value; question.format = questionTypeSelect.value;
// Update the options if applicable // Update the options if applicable
if (question.enum) { const newOptions = questionOptionsContainer ? Array.from(questionOptionsContainer.querySelectorAll('.option-field')).map((input) => input.value.trim()).filter((option) => option !== '') : [];
question.enum = Array.from(questionOptionsContainer.querySelectorAll('.option-field')) const selectedType = questionTypeSelect ? questionTypeSelect.value : null;
.map((input) => input.value.trim()) applyOptionsToQuestion(question, newOptions, selectedType);
.filter((option) => option !== ''); // Exclude empty options
}
// Update the editor with the modified question // Update the editor with the modified question
currentContent.properties[questionKey] = 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.`); toastr.success(`Question "${question.title}" updated successfully.`);
renderQuestions(); // Re-render the questions list renderQuestions(); // Re-render the questions list
@@ -1,4 +1,5 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load crispy_forms_tags %}
{% block content %} {% block content %}
<span class="collection-name-blend">Collection: {{collection}}</span> <span class="collection-name-blend">Collection: {{collection}}</span>
@@ -16,6 +17,20 @@
</h2> </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> {% comment %} <details>
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary> <summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
</details> {% endcomment %} </details> {% endcomment %}
@@ -40,9 +55,9 @@
Description: {{case.description}} Description: {{case.description}}
</div> </div>
{% endif %} {% endif %}
{% if show_history and case.history %} {% if show_history %}
<div> <div>
History: {{case.history|linebreaks}} History: {{case_detail.get_history_pre|linebreaks}}
</div> </div>
{% endif %} {% endif %}
@@ -71,7 +86,17 @@
{% endif %} {% endif %}
{% if collection.show_built_in_viewer %} {% 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> <details open>
<summary> <summary>
Images Images
@@ -101,7 +126,7 @@
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''> <div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
</div> </div>
</div> </div> {% endcomment %}
{% else %} {% else %}
{% endif %} {% endif %}
@@ -148,35 +173,7 @@
{% if collection.collection_type == "QUE" %} {% if collection.collection_type == "QUE" %}
{% if question_completed %} {% if question_completed %}
<h3>Answers</h3> <h3>Answers</h3>
{% include "atlas/partials/collection_question_answer_block.html" %}
<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>
{% if collection.self_review %} {% if collection.self_review %}
<div> <div>
@@ -222,9 +219,11 @@
{{form.json.errors}} {{form.json.errors}}
<div class="form-contents"> <div class="form-contents">
<fieldset {% if question_completed %}disabled="disabled"{% endif %}> <fieldset {% if question_completed %}disabled="disabled"{% endif %}>
{{form}} {{form | crispy}}
</fieldset> </fieldset>
</div> </div>
{% if question_completed %}
<div> <div>
{% if collection.self_review %} {% if collection.self_review %}
<p> <p>
@@ -244,6 +243,7 @@
<br/> <br/>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endif %} {% endif %}
{% if previous %} {% if previous %}
@@ -343,107 +343,213 @@
{{ form.media }} {{ form.media }}
{% comment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %} {% comment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
<script type="text/javascript"> <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 () { // Visual feedback: set timer to Locked and progress to red
setTimeout(() => { $progressInner = $('#question-timer-progress-inner');
window.loadDicomViewer(window.images[0]) $('#question-timer').text('Locked');
}, 500); $progressInner.css('background', '#dc3545');
}) $progressInner.css('width', '0%');
{% comment %} $('document').ready(function() { $("#question-timer-progress-outer").hide();
}
// Get value from either a json string or url pointing to a json file
function process(value) {
var isjson=true;
var result;
try { try {
result = JSON.parse(value); var timeLimit = {{ collection.question_time_limit|default:'null' }};
} catch(e) { var questionCompleted = {{ question_completed|yesno:"true,false" }};
isjson=false; var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
if (!timeLimit || questionCompleted) {
return;
} }
if (isjson) { {% if answer %}
return result; 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 { } else {
return $.getJSON(value) // interpolate from red (0) to orange (30)
.then(function (response) { var t = pct / 50.0; // 0..1
return response; 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() { // Ensure the timed_out flag is included
// Get the DOM Element formValues['timed_out'] = '1';
var element = $(this).get(0);
console.log("el", element)
var options_text = $(this).attr('options') htmx.ajax('POST', window.location.href, {
var schema_text = $(this).attr('schema') values: formValues,
swap: 'none',
var schema = process(schema_text); headers: {
var options = process(options_text); 'X-CSRFToken': document.querySelector('input[name="csrfmiddlewaretoken"]').value,
},
var name = $(this).attr('name'); target: "#timer-htmx-target",
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")
}
})
}); });
}) })();
{% endcomment %} } else {
$timer.text(formatTime(remaining));
updateProgress();
}
}, 1000);
$form.on('submit', function () {
clearInterval(intervalId);
});
} catch (e) {
console.error('Error initializing timer:', e);
}
});
</script> </script>
{% endblock js %} {% endblock js %}
+52 -22
View File
@@ -2,6 +2,34 @@
{% load partials %} {% 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 %} {% block content %}
<h2>{{collection.name}}</h2> <h2>{{collection.name}}</h2>
@@ -14,6 +42,26 @@
Collection Type: {{collection.get_collection_type_display}}<br /> Collection Type: {{collection.get_collection_type_display}}<br />
Self review: {{collection.self_review}}<br /> Self review: {{collection.self_review}}<br />
Open access: {{collection.open_access}}<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> </div>
@@ -30,6 +78,9 @@
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a> <p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% endif %} {% endif %}
<h3>Cases</h3> <h3>Cases</h3>
<ol id="full-question-list" class="sortable"> <ol id="full-question-list" class="sortable">
{% for casedetail in casesdetails %} {% for casedetail in casesdetails %}
@@ -43,28 +94,7 @@
{% endif %} {% endif %}
{{casedetail.case.title}} {{casedetail.case.title}}
{% partial 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 %}
)
{% 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 %}
</li> </li>
@@ -3,6 +3,8 @@
{% block content %} {% block content %}
<h2>{{collection.name}}</h2> <h2>{{collection.name}}</h2>
{% if userexams %}
<h3>Users</h3> <h3>Users</h3>
<ul> <ul>
@@ -23,6 +25,30 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </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 %} {% endblock %}
@@ -10,10 +10,22 @@
<li class="case"> <li class="case">
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4> <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 %} {% if not user_answer %}
<span class="case-not-answered">Case not answered.</span> <span class="case-not-answered">Case not answered.</span>
{% else %} {% else %}
<div class="answer-block"> <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 %} {% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
{% if not user_answer %} {% if not user_answer %}
Not answered Not answered
@@ -43,6 +55,8 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
@@ -1,10 +1,13 @@
{% for cid_user_exam in cid_users %} {% 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" <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-post="{% url 'atlas:collection_reset_answers_user' collection.pk cid_user_exam.user_user.id %}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-confirm="Are you sure you want to reset answers for the user? This action cannot be undone." 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 %} {% endfor %}
@@ -6,6 +6,7 @@
{% if request.user.is_authenticated and valid_user %} {% if request.user.is_authenticated and valid_user %}
User: {{request.user}}<br/> User: {{request.user}}<br/>
{% if cid_exam %} {% if cid_exam %}
Started: {{cid_exam.start_time}} <br/> 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> <a href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}"><button>Overview</button></a>
{% endif %} {% endif %}
{% else %} {% 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="cid-box" type="text" value="Candidate ID"></p>
<p><input id="passcode-box" type="text" value="Passcode"></p> <p><input id="passcode-box" type="text" value="Passcode"></p>
+40 -2
View File
@@ -67,6 +67,10 @@
{% endfor %} {% endfor %}
</details> </details>
</div> </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 %} {% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary> <details class="displayset-detail"><summary>Display Sets:</summary>
{% for ds in case.display_sets.all %} {% for ds in case.display_sets.all %}
@@ -116,10 +120,9 @@
<div id="current-case-history"></div> <div id="current-case-history"></div>
<div id="current-case-discussion"></div> <div id="current-case-discussion"></div>
<div id="current-case-report"></div> <div id="current-case-report"></div>
<div id="current-case-questions" class="mt-3"></div>
</div> </div>
</div> </div>
<iframe id="viewer" style="width: 100%; height: 500px; border: none"></iframe>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -223,6 +226,14 @@
"viewerstate": viewerstate "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() { $('.open-displayset').click(function() {
let c = this; let c = this;
@@ -244,6 +255,15 @@
"annotations": annotations "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() { $('.open-series-local').click(function() {
let c = $(this).closest(".case-item")[0]; let c = $(this).closest(".case-item")[0];
@@ -266,6 +286,14 @@
"series": seriesPk, "series": seriesPk,
"images": JSON.stringify(images), "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() { $('.open-case, .open-series').click(function() {
var url = $(this).data('target'); var url = $(this).data('target');
@@ -291,6 +319,14 @@
$('#current-case-series').html("<span class='title'>Series:</span> " + $(this).data('series')); $('#current-case-series').html("<span class='title'>Series:</span> " + $(this).data('series'));
} }
bc.postMessage({"type": "open", "url" : url}); 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) { //if (win2 == false || win2.closed) {
// win2 = openSecondaryWindow(url); // win2 = openSecondaryWindow(url);
// console.log('opened', win2) // console.log('opened', win2)
@@ -368,4 +404,6 @@
vertical-align: middle; vertical-align: middle;
} }
</style> </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 %} {% 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 %} {% endif %}
{% if collection %} {% if collection %}
<div style="float: right;">
Collection:Case details
{% include "atlas/collection_detail.html#casedetails-management-links" %}
</div>
<div> <div>
{% if previous %} {% if previous %}
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous question</a> <a href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous question</a>
{% endif %} {% endif %}
+39 -4
View File
@@ -1,17 +1,52 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% block content %} {% 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> <ul>
{% for collection in collections %} {% for collection in available_collections %}
<li> <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> </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 %} {% endfor %}
</ul> </ul>
+20
View File
@@ -80,6 +80,11 @@ urlpatterns = [
views.collection_take_start, views.collection_take_start,
name="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>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
path( path(
"collection/<int:pk>/toggle_results_published", "collection/<int:pk>/toggle_results_published",
@@ -128,6 +133,11 @@ urlpatterns = [
views.collection_history_user, views.collection_history_user,
name="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( path(
"collection/<int:pk>/user_status", "collection/<int:pk>/user_status",
@@ -161,6 +171,11 @@ urlpatterns = [
views.collection_case_details, views.collection_case_details,
name="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( path(
"collection/<int:exam_id>/case/<int:case_id>/priors", "collection/<int:exam_id>/case/<int:case_id>/priors",
views.collection_case_priors, views.collection_case_priors,
@@ -196,6 +211,11 @@ urlpatterns = [
views.collection_reset_answers_user, views.collection_reset_answers_user,
name="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( #path(
# "question_schemas_preset", # "question_schemas_preset",
# views.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 Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType
from generic.models import CidUser, CidUserExam, CimarCase from generic.models import CidUser, CidUserExam, CimarCase
from atlas.helpers import get_cases_available_to_user from atlas.helpers import get_cases_available_to_user
@@ -51,6 +52,7 @@ from .forms import (
CaseCollectionForm, CaseCollectionForm,
CaseCollectionUpdateCaseForm, CaseCollectionUpdateCaseForm,
CaseDetailForm, CaseDetailForm,
CaseQuestionForm,
CaseDisplaySetForm, CaseDisplaySetForm,
CaseForm, CaseForm,
CaseResourceFormSet, CaseResourceFormSet,
@@ -103,6 +105,7 @@ from .models import (
SeriesImage, SeriesImage,
UncategorisedDicom, UncategorisedDicom,
UserReportAnswer, UserReportAnswer,
PrerequisiteRequired
) )
from .tables import ( from .tables import (
CaseCollectionTable, CaseCollectionTable,
@@ -302,10 +305,9 @@ def case_displaysets_delete(request, pk):
displayset.delete() displayset.delete()
return HttpResponse("<li class='list-group-item' >Display set deleted successfully.") return HttpResponse("<li class='list-group-item' >Display set deleted successfully.")
@login_required
@user_has_case_view_access def get_case_for_case_detail(pk: int) -> Case:
def case_detail(request, pk): try:
# Prefetch all related objects needed for the template
case = ( case = (
Case.objects Case.objects
.select_related() # Add any FK fields if needed .select_related() # Add any FK fields if needed
@@ -343,6 +345,14 @@ def case_detail(request, pk):
.get(pk=pk) .get(pk=pk)
) )
case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()] 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) can_edit = case.check_user_can_edit(request.user)
return render( return render(
@@ -633,9 +643,46 @@ def index(request):
def user_collections(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 @login_required
def collection_options(request): def collection_options(request):
@@ -2056,9 +2103,11 @@ def collection_history(request, exam_id: int):
"sort_order" "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( return render(
request, request,
@@ -2068,6 +2117,7 @@ def collection_history(request, exam_id: int):
"casesdetails": casedetails, "casesdetails": casedetails,
"can_edit": True, "can_edit": True,
"userexams": userexams, "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): 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: Returns:
_type_: _description_ _type_: _description_
""" """
print(f"Collection take start: {pk}, cid: {cid}, passcode: {passcode}")
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
print(f"Collection: {collection}")
try: try:
collection.check_user_can_take(cid, passcode, request.user) collection.check_user_can_take(cid, passcode, request.user)
valid_user = True valid_user = True
except Http404: except Http404:
valid_user = False 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 cid_exam = None
if collection.collection_type == "REV" or valid_user: 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 = { template_variables = {
"collection": collection, "collection": collection,
@@ -2159,16 +2242,16 @@ def collection_case_priors(request, exam_id, case_id):
p.delete() p.delete()
return HttpResponse(f"Case removed") return HttpResponse(f"Case removed")
elif "prior_case_id" in request.POST: 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"]) prior_case = Case.objects.get(pk=request.POST["prior_case_id"])
p, created = CasePrior.objects.get_or_create( p, created = CasePrior.objects.get_or_create(
case_detail=case_detail, prior_case=prior_case case_detail=case_detail, prior_case=prior_case
) )
p.relation_text = request.POST["relation"] 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.prior_visibility = request.POST["prior_visibility"]
p.save() p.save()
@@ -2227,7 +2310,7 @@ def collection_case_priors(request, exam_id, case_id):
@user_is_collection_author_or_atlas_editor @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) case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
collection = case_detail.collection collection = case_detail.collection
@@ -2244,10 +2327,10 @@ def collection_case_details(request, exam_id, case_id):
example_form = JsonAnswerForm( example_form = JsonAnswerForm(
request.POST, question_schema=case_detail.question_schema 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 # Called if the user saves the main form
elif request.POST.get("submit") == "save": elif request.POST.get("submit") == "save":
form = CaseDetailForm(request.POST, instance=case_detail) form = CaseQuestionForm(request.POST, instance=case_detail)
if form.is_valid(): if form.is_valid():
form.save() form.save()
# Add any additional logic or redirection here # 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 post_data, question_schema=case_detail.question_schema
) )
form = CaseDetailForm(instance=case_detail) form = CaseQuestionForm(instance=case_detail)
# This shouldn't happen # This shouldn't happen
else: else:
assert False assert False
form = CaseDetailForm(request.POST, instance=case_detail) form = CaseQuestionForm(request.POST, instance=case_detail)
else: else:
post_data = request.POST.copy() 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 post_data, question_schema=case_detail.question_schema
) )
form = CaseDetailForm(instance=case_detail) form = CaseQuestionForm(instance=case_detail)
pass pass
# post_data = None # post_data = None
@@ -2313,7 +2396,7 @@ def collection_case_details(request, exam_id, case_id):
# case_detail.question_answers = answers # case_detail.question_answers = answers
# example_form = JsonAnswerForm(post_data, question_schema=case_detail.question_schema) # 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) # blank_form = JsonAnswerForm(question_schema=case_detail.question_schema)
case_number, case_count = collection.get_index_of_case( case_number, case_count = collection.get_index_of_case(
@@ -2325,7 +2408,7 @@ def collection_case_details(request, exam_id, case_id):
return render( return render(
request, request,
"atlas/collection_case_detail.html", "atlas/collection_case_questions.html",
{ {
"case_detail": case_detail, "case_detail": case_detail,
"form": form, "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 @user_is_collection_author_or_atlas_editor
def collection_mark_overview(request, pk): def collection_mark_overview(request, pk):
@@ -2496,7 +2620,11 @@ def collection_take_overview(
""" """
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
try:
collection.check_user_can_take(cid, passcode, request.user) 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_user_exam = collection.get_or_create_cid_user_exam(
cid=cid, user_user=request.user cid=cid, user_user=request.user
@@ -2569,7 +2697,11 @@ def collection_case_view_take_answers(
""" """ """ """
collection = get_object_or_404(CaseCollection, pk=pk) collection = get_object_or_404(CaseCollection, pk=pk)
try:
collection.check_user_can_take(cid, passcode, request.user) 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) case, case_count = collection.get_case_by_index(case_number, case_count=True)
@@ -2618,7 +2750,11 @@ def collection_case_view_take(
form = None form = None
answer: None | CidReportAnswer | UserReportAnswer = None answer: None | CidReportAnswer | UserReportAnswer = None
try:
collection.check_user_can_take(cid, passcode, request.user) 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) case, case_count = collection.get_case_by_index(case_number, case_count=True)
@@ -2651,6 +2787,29 @@ def collection_case_view_take(
).first() ).first()
ReportAnswerForm = UserQuestionAnswerForm 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 request.method == "POST":
if collection.collection_type in ("REP", "QUE"): if collection.collection_type in ("REP", "QUE"):
if not collection.publish_results: if not collection.publish_results:
@@ -2668,6 +2827,8 @@ def collection_case_view_take(
answer = form.save(commit=False) answer = form.save(commit=False)
answer.set_cid_or_user(cid=cid, user=request.user) answer.set_cid_or_user(cid=cid, user=request.user)
answer.question = case_detail answer.question = case_detail
# Record submission timestamp
answer.submitted_at = timezone.now()
# answer.published_date = timezone.now() # answer.published_date = timezone.now()
if "complete_case" in request.POST: if "complete_case" in request.POST:
@@ -2675,11 +2836,44 @@ def collection_case_view_take(
raise Http404("Self review not enabled") raise Http404("Self review not enabled")
answer.completed = True 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() answer.save()
cid_user_exam.end_time = timezone.now() cid_user_exam.end_time = timezone.now()
cid_user_exam.save() 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: if cid is not None:
kwargs = {"pk": pk, "cid": cid, "passcode": passcode} kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
redirect_url = "atlas:collection_case_view_take" redirect_url = "atlas:collection_case_view_take"
@@ -2768,6 +2962,7 @@ def collection_case_view_take(
"form": form, "form": form,
"collection": collection, "collection": collection,
"case": case, "case": case,
"case_detail": case_detail,
"series_list": series_list, "series_list": series_list,
"series_to_load": series_to_load, "series_to_load": series_to_load,
"case_number": case_number, "case_number": case_number,
@@ -2785,6 +2980,7 @@ def collection_case_view_take(
"cid_user_exam": cid_user_exam, "cid_user_exam": cid_user_exam,
"question_completed": question_completed, "question_completed": question_completed,
"self_review": self_review, "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, 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") series_list = case.series.all().prefetch_related("images", "examination", "plane")
previous = case_number > 0 previous = case_number > 0
@@ -2824,6 +3024,7 @@ def collection_case_view(request, pk, case_number):
"collection": collection, "collection": collection,
"collection_length": case_count, "collection_length": case_count,
"case": case, "case": case,
"casedetail": casedetail,
"series_list": series_list, "series_list": series_list,
"case_number": case_number, "case_number": case_number,
"previous": previous, "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): def collection_reset_answers_user_list(request, exam_id: int):
if request.htmx: if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id) 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") raise Http404("Invalid request")
@user_is_collection_author_or_atlas_editor
def collection_reset_answers_user(request, exam_id: int, user_id: int): def collection_reset_answers_user(request, exam_id: int, user_id: int):
if request.htmx: if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id) 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: else:
raise Http404("Invalid request") 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): def collection_reset_answers(request, exam_id: int):
if request.htmx: if request.htmx:
+4
View File
@@ -63,6 +63,10 @@ class AuthorMixin():
"""Remove an author from the object""" """Remove an author from the object"""
self.author.remove(user) 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: class UserConfigurablePaginationMixin:
default_per_page = 25 default_per_page = 25
+34 -1
View File
@@ -768,6 +768,9 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
Raises: Raises:
Http404: If user does not have access 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 we are limiting by dates check that the current date is within the range
if self.restrict_to_dates: if self.restrict_to_dates:
if self.start_date >= timezone.now() or ( 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): def get_question_user_user_answer(self, question_index, user):
raise NotImplementedError raise NotImplementedError
def get_cid_user_exams( def get_cid_and_user_exams(
self, cid_user: Optional["CidUser"] = None, user_user: User | None = None self, cid_user: Optional["CidUser"] = None, user_user: User | None = None
) -> "CidUserExam": ) -> "CidUserExam":
"""Returns a queryset of CidUserExam for this exam/collection"""
content_type = ContentType.objects.get_for_model(self) content_type = ContentType.objects.get_for_model(self)
if cid_user is None and user_user is None: if cid_user is None and user_user is None:
return CidUserExam.objects.filter( 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 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): def clone_model(self):
M2M_fields = ( M2M_fields = (
"exam_questions", "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)]),
),
]