Compare commits
37
Commits
779559997e
...
4986c2faa9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4986c2faa9 | ||
|
|
2c30145d3a | ||
|
|
eeef34bffc | ||
|
|
a3712959ec | ||
|
|
ed4bd95955 | ||
|
|
32d8430f94 | ||
|
|
fa08f9cf76 | ||
|
|
1cf18c6f0d | ||
|
|
41d1fa605b | ||
|
|
a355dd223e | ||
|
|
6f99cf0d53 | ||
|
|
682e8160b5 | ||
|
|
569ef760fa | ||
|
|
bdfa16bbb4 | ||
|
|
974966e855 | ||
|
|
63f399d2c1 | ||
|
|
bb20f7d20d | ||
|
|
850b6ddc84 | ||
|
|
e051a4f15a | ||
|
|
735a29e71b | ||
|
|
4fc1de4300 | ||
|
|
5400959399 | ||
|
|
0839eb7e37 | ||
|
|
f087a6ef49 | ||
|
|
ac562ed1e1 | ||
|
|
5d6c5e8b1a | ||
|
|
73b12dd96b | ||
|
|
990907f8f5 | ||
|
|
ee42d5c1c2 | ||
|
|
85216b36b0 | ||
|
|
32b246b5ad | ||
|
|
3b19ddcb5e | ||
|
|
50f5813a75 | ||
|
|
298493a390 | ||
|
|
de8050a8ca | ||
|
|
0b12d95bdd | ||
|
|
b9f6b7c837 |
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+145
-12
@@ -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%;">×</button>
|
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">×</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"> </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' }};
|
||||||
|
var questionCompleted = {{ question_completed|yesno:"true,false" }};
|
||||||
|
var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
|
||||||
|
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
|
||||||
|
|
||||||
|
if (!timeLimit || questionCompleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if answer %}
|
||||||
|
if ({{answer.completed|yesno:"true,false"}}) {
|
||||||
|
lockQuestion();
|
||||||
|
console.debug('Timer: answer already completed, aborting timer init');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
var $timer = $('#question-timer');
|
||||||
|
var $form = $('form.post-form');
|
||||||
|
|
||||||
|
if ($timer.length === 0 || $form.length === 0) {
|
||||||
|
console.debug('Timer: required elements not found, aborting timer init');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute remaining based on the canonical start time (if provided)
|
||||||
|
var remaining = parseInt(timeLimit, 10);
|
||||||
|
if (answerStartedAtIso) {
|
||||||
|
try {
|
||||||
|
var started = new Date(answerStartedAtIso);
|
||||||
|
var now = new Date();
|
||||||
|
var elapsed = Math.floor((now - started) / 1000);
|
||||||
|
remaining = Math.max(0, remaining - elapsed);
|
||||||
|
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remaining:', remaining);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
isjson=false;
|
console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isjson) {
|
// If time has already elapsed when the page loads, lock the question and do not autosubmit.
|
||||||
return result;
|
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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
</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 %}
|
||||||
|
(<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -3,15 +3,50 @@
|
|||||||
{% 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
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)]),
|
||||||
|
),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user