Compare commits
37
Commits
| 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,
|
||||
Resource,
|
||||
CaseDisplaySet,
|
||||
UserReportAnswer,
|
||||
CidReportAnswer,
|
||||
)
|
||||
|
||||
from django.forms import ModelForm
|
||||
@@ -48,6 +50,8 @@ admin.site.register(UncategorisedDicom)
|
||||
admin.site.register(SeriesDetail)
|
||||
admin.site.register(Resource)
|
||||
admin.site.register(CaseDisplaySet)
|
||||
admin.site.register(UserReportAnswer)
|
||||
admin.site.register(CidReportAnswer)
|
||||
|
||||
|
||||
class DifferentialInline(admin.TabularInline):
|
||||
|
||||
+78
-7
@@ -15,6 +15,7 @@ from django.forms import (
|
||||
CheckboxSelectMultiple,
|
||||
SplitDateTimeWidget,
|
||||
)
|
||||
from django.utils.html import escape
|
||||
from django.forms import inlineformset_factory
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_jsonforms.forms import JSONSchemaField
|
||||
@@ -67,7 +68,8 @@ from autocomplete import (
|
||||
register as autocomplete_register,
|
||||
)
|
||||
|
||||
import logging
|
||||
from loguru import logger
|
||||
|
||||
|
||||
from generic.forms import (
|
||||
ExamAuthorFormMixin,
|
||||
@@ -213,6 +215,8 @@ class CaseCollectionForm(ModelForm):
|
||||
"feedback_once_collection_complete",
|
||||
"collection_type",
|
||||
"viewer_mode",
|
||||
"question_time_limit",
|
||||
"prerequisites",
|
||||
),
|
||||
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
||||
Div(
|
||||
@@ -471,7 +475,7 @@ class CaseForm(ModelForm):
|
||||
self.collection = None
|
||||
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
|
||||
collections = kwargs["initial"].pop("exams")
|
||||
logging.debug(collections)
|
||||
logger.debug(collections)
|
||||
|
||||
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
|
||||
|
||||
@@ -606,13 +610,13 @@ class CaseForm(ModelForm):
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
logging.debug(f"{self.collection=}")
|
||||
logger.debug(f"{self.collection=}")
|
||||
|
||||
if self.collection is not None:
|
||||
logging.debug(f"{self.collection=}")
|
||||
logger.debug(f"{self.collection=}")
|
||||
case_no = self.collection.cases.count() + 1
|
||||
logging.debug(f"{case_no=}")
|
||||
logging.debug(f"{instance=}")
|
||||
logger.debug(f"{case_no=}")
|
||||
logger.debug(f"{instance=}")
|
||||
|
||||
# Create through model
|
||||
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
||||
@@ -1010,7 +1014,7 @@ class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
|
||||
template_name = "atlas/svelte_jsoneditor_widget_override.html"
|
||||
|
||||
|
||||
class CaseDetailForm(ModelForm):
|
||||
class CaseQuestionForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseDetail
|
||||
fields = ["question_schema", "question_answers"] # , "user"]
|
||||
@@ -1020,6 +1024,73 @@ class CaseDetailForm(ModelForm):
|
||||
"question_answers": SvelteJSONEditorWidgetOverride(),
|
||||
}
|
||||
|
||||
class CaseDetailForm(ModelForm):
|
||||
class Meta:
|
||||
model = CaseDetail
|
||||
fields = ["redact_history", "override_history"]
|
||||
|
||||
def __init__(self, *args, case_history: str = None, **kwargs):
|
||||
"""
|
||||
case_history: optional explicit history text to show (falls back to instance.case.history)
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Determine original history text (safe-escaped for HTML)
|
||||
if case_history is None:
|
||||
try:
|
||||
case_history = self.instance.case.history if self.instance and getattr(self.instance, "case", None) else ""
|
||||
except Exception:
|
||||
case_history = ""
|
||||
case_history_html = escape(case_history or "No history available.")
|
||||
|
||||
# Ensure override_history has a stable id we can reference from the inline script
|
||||
override_id = self.fields["override_history"].widget.attrs.get("id", "id_override_history")
|
||||
self.fields["override_history"].widget.attrs["id"] = override_id
|
||||
# Optionally make it a textarea style appearance if not already
|
||||
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
|
||||
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
|
||||
|
||||
# Build crispy helper/layout embedding the original history and buttons tied to override_history
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
|
||||
# Inline HTML block with buttons and a script that copies/clears the override field.
|
||||
# The script references the explicit override_id above.
|
||||
history_block = f"""
|
||||
<details class="mb-3">
|
||||
<summary class="h6" style="cursor:pointer;"><i class="bi bi-info-circle"></i> Show original case history</summary>
|
||||
<div class="mt-2 p-2 bg-dark text-light border rounded">
|
||||
<label class="form-label fw-bold">Original history (read-only)</label>
|
||||
<div id="original-history" style="white-space: pre-wrap;">{case_history_html}</div>
|
||||
<div class="form-text text-secondary">Use the buttons below to copy this into the override field or clear the override.</div>
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-light" id="use-original-history">Copy original into override</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2" id="clear-override-history">Clear override</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {{
|
||||
var copyBtn = document.getElementById('use-original-history');
|
||||
var clearBtn = document.getElementById('clear-override-history');
|
||||
var original = '{case_history_html.replace("'", "\\'").replace("\\n", "\\\\n")}';
|
||||
var overrideField = document.getElementById('{override_id}');
|
||||
function setOverride(val) {{
|
||||
if(!overrideField) return; overrideField.value = val; overrideField.dispatchEvent(new Event('input',{{bubbles:true}})); }}
|
||||
if(copyBtn) copyBtn.addEventListener('click', function(e){{ setOverride(original); }});
|
||||
if(clearBtn) clearBtn.addEventListener('click', function(e){{ setOverride(''); }});
|
||||
}});
|
||||
</script>
|
||||
"""
|
||||
|
||||
# Compose layout: history block then the override field and redact checkbox
|
||||
self.helper.layout = Layout(
|
||||
HTML(history_block),
|
||||
Field("override_history"),
|
||||
Field("redact_history")
|
||||
)
|
||||
|
||||
|
||||
|
||||
class QuestionSchemaForm(ModelForm):
|
||||
class Meta:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.4 on 2025-09-15 09:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0074_alter_seriesimage_image_md5_hash'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='override_history',
|
||||
field=models.TextField(blank=True, help_text='This will override the case history for the purpose of the exam/collection.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casedetail',
|
||||
name='redact_history',
|
||||
field=models.BooleanField(default=False, help_text='Set to true if the history should be redacted whilst taking the case.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 08:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0075_casedetail_override_history_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='question_time_limit',
|
||||
field=models.PositiveIntegerField(help_text='Time limit for answering questions in seconds.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 08:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0076_casecollection_question_time_limit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='casecollection',
|
||||
name='question_time_limit',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Time limit for answering questions in seconds.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 08:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0077_alter_casecollection_question_time_limit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cidreportanswer',
|
||||
name='started_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cidreportanswer',
|
||||
name='submitted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userreportanswer',
|
||||
name='started_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userreportanswer',
|
||||
name='submitted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 12:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0078_cidreportanswer_started_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='prerequisites',
|
||||
field=models.ManyToManyField(blank=True, help_text='Collections that must be completed before this collection can be taken', related_name='dependents', to='atlas.casecollection'),
|
||||
),
|
||||
]
|
||||
+230
-10
@@ -71,6 +71,7 @@ from django.utils import timezone
|
||||
import reversion
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
|
||||
@@ -565,15 +566,19 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_series_images_nested(self, as_json=True):
|
||||
|
||||
def get_series_images_nested(self, as_json: bool =True, exclude_series_ids: None | list[int] = None):
|
||||
"""
|
||||
Returns a list of lists, where each inner list contains the image URLs
|
||||
for a single series in this case, in the order of the series.
|
||||
"""
|
||||
|
||||
series_qs = self.series.all()
|
||||
if exclude_series_ids is not None:
|
||||
series_qs = self.series.exclude(id__in=exclude_series_ids)
|
||||
images = [
|
||||
[image.image.url for image in series.images.all()]
|
||||
for series in self.series.all()
|
||||
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images')
|
||||
]
|
||||
|
||||
if as_json:
|
||||
@@ -581,6 +586,30 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
else:
|
||||
return images
|
||||
|
||||
def get_case_named_stacks(self):
|
||||
def build_stacks_for(case_obj, prefix=None):
|
||||
logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
|
||||
stacks = []
|
||||
for series in case_obj.get_ordered_series():
|
||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
|
||||
name = f"{prefix}: {series}" if prefix else str(series)
|
||||
stacks.append({"name": name, "imageIds": images})
|
||||
return stacks
|
||||
|
||||
results = []
|
||||
|
||||
logger.debug(f"Building stacks for case {self.pk}")
|
||||
|
||||
# main case entry
|
||||
results.append(
|
||||
{
|
||||
"caseId": f"",
|
||||
"studyId": f"Current Case",
|
||||
"stacks": build_stacks_for(self, prefix=None),
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(results)
|
||||
|
||||
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
||||
to_keep = [
|
||||
@@ -914,6 +943,21 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
|
||||
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
|
||||
|
||||
question_time_limit = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Time limit for answering questions in seconds."
|
||||
)
|
||||
|
||||
# Collections that must be completed before this collection can be taken
|
||||
prerequisites = models.ManyToManyField(
|
||||
"self",
|
||||
blank=True,
|
||||
symmetrical=False,
|
||||
related_name="dependents",
|
||||
help_text="Collections that must be completed before this collection can be taken",
|
||||
)
|
||||
|
||||
class COLLECTION_TYPE_CHOICES(models.TextChoices):
|
||||
REVIEW = (
|
||||
"REV",
|
||||
@@ -1071,6 +1115,49 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
kwargs={"pk": self.pk, "case_number": cases.index(case)},
|
||||
)
|
||||
|
||||
def check_user_can_take(self, cid, passcode, user=None, active_only=True):
|
||||
"""
|
||||
Extend base check_user_can_take to also require completion of any
|
||||
prerequisite collections.
|
||||
"""
|
||||
# Perform the normal access checks first
|
||||
super().check_user_can_take(cid, passcode, user=user, active_only=active_only)
|
||||
|
||||
# If there are prerequisites, the user (or CID) must have completed them
|
||||
if not self.prerequisites.exists():
|
||||
return
|
||||
|
||||
for prereq in self.prerequisites.all():
|
||||
# Look up any existing exam record for this user/cid on the prerequisite
|
||||
ct = ContentType.objects.get_for_model(prereq)
|
||||
exam_record = None
|
||||
if cid is not None:
|
||||
# Find CidUser by cid
|
||||
try:
|
||||
cid_user = CidUser.objects.filter(cid=cid).first()
|
||||
except Exception:
|
||||
cid_user = None
|
||||
|
||||
if cid_user is None:
|
||||
exam_record = None
|
||||
else:
|
||||
exam_record = CidUserExam.objects.filter(
|
||||
content_type=ct, object_id=prereq.pk, cid_user=cid_user
|
||||
).first()
|
||||
else:
|
||||
# Check for a normal user_user exam record
|
||||
exam_record = CidUserExam.objects.filter(
|
||||
content_type=ct, object_id=prereq.pk, user_user=user
|
||||
).first()
|
||||
|
||||
if exam_record is None or not getattr(exam_record, "completed", False):
|
||||
# Not allowed to take this collection until prereq completed
|
||||
# Raise the PrerequisiteRequired exception including the prereq object
|
||||
raise PrerequisiteRequired(
|
||||
f"Collection not available until prerequisite '{prereq.name}' is completed.",
|
||||
prereq=prereq,
|
||||
)
|
||||
|
||||
def get_ohif_dicom_json(self, case_title_as_patient_name=True):
|
||||
studies = []
|
||||
for n, case in enumerate(self.cases.all()):
|
||||
@@ -1190,6 +1277,16 @@ class CaseDetail(models.Model):
|
||||
|
||||
sort_order = models.IntegerField(default=1000)
|
||||
|
||||
redact_history = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Set to true if the history should be redacted whilst taking the case."
|
||||
)
|
||||
|
||||
override_history = models.TextField(
|
||||
null=True, blank=True,
|
||||
help_text="This will override the case history for the purpose of the exam/collection."
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("sort_order",)
|
||||
|
||||
@@ -1210,9 +1307,93 @@ class CaseDetail(models.Model):
|
||||
except UserReportAnswer.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_cid_answers(self, cid):
|
||||
"""Returns the cid users answers as a json string"""
|
||||
try:
|
||||
return CidReportAnswer.objects.get(question=self, cid=cid)
|
||||
except CidReportAnswer.DoesNotExist:
|
||||
return None
|
||||
|
||||
def default_viewerstate_string(self):
|
||||
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"
|
||||
|
||||
def get_history_pre(self):
|
||||
if self.collection.show_history_pre:
|
||||
if self.redact_history:
|
||||
return "[Redacted]"
|
||||
if self.override_history and self.override_history != "":
|
||||
return self.override_history
|
||||
return self.case.history or "No history provided"
|
||||
return ""
|
||||
|
||||
def get_case_series_nested(self, include_priors=True):
|
||||
case_series_images = self.case.get_series_images_nested(as_json=False)
|
||||
|
||||
if include_priors:
|
||||
logger.debug(f"Checking for prior cases for case {self.case}")
|
||||
logger.debug(f"Found {self.case.prior_case.count()} prior cases for case {self.case}")
|
||||
|
||||
for prior in self.caseprior_set.all():
|
||||
logger.debug(f"Adding prior case {prior.prior_case} to case {self.case}")
|
||||
case_series_images.extend(prior.prior_case.get_series_images_nested(as_json=False))
|
||||
|
||||
return json.dumps(case_series_images)
|
||||
|
||||
def get_case_named_stacks(self, include_priors=True):
|
||||
def build_stacks_for(case_obj, prefix=None):
|
||||
stacks = []
|
||||
for series in case_obj.get_ordered_series():
|
||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
|
||||
name = f"{prefix}: {series}" if prefix else str(series)
|
||||
stacks.append({"name": name, "imageIds": images})
|
||||
return stacks
|
||||
|
||||
results = []
|
||||
|
||||
# main case entry
|
||||
results.append(
|
||||
{
|
||||
"caseId": f"CASE-{self.case.pk}",
|
||||
"studyId": f"Current Case",
|
||||
"stacks": build_stacks_for(self.case, prefix=None),
|
||||
}
|
||||
)
|
||||
|
||||
# include priors as separate entries
|
||||
if include_priors:
|
||||
for prior in self.caseprior_set.all():
|
||||
prior_case = prior.prior_case
|
||||
results.append(
|
||||
{
|
||||
"caseId": f"CASE-{prior_case.pk}",
|
||||
"studyId": f"Prior: {prior.relation_text}",
|
||||
"stacks": build_stacks_for(prior_case, prefix="Prior"),
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(results)
|
||||
|
||||
def render_example_form(self, request=None):
|
||||
"""Build and return a rendered HTML snippet for the example answers form.
|
||||
|
||||
If `request` is provided it will be passed to the template renderer so
|
||||
csrf tokens and other context processors work correctly. The returned
|
||||
HTML contains the json-editor widget (the `json_answer` field) and a
|
||||
submit button for saving example answers.
|
||||
"""
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.safestring import mark_safe
|
||||
from .forms import JsonAnswerForm
|
||||
|
||||
post_data = {}
|
||||
# Ensure we pass the stored answers into the form so the widget is populated
|
||||
post_data["json_answer"] = json.dumps(self.question_answers) if self.question_answers is not None else json.dumps({})
|
||||
|
||||
form = JsonAnswerForm(post_data, question_schema=self.question_schema)
|
||||
|
||||
html = render_to_string("atlas/_rendered_example_form.html", {"form": form}, request=request)
|
||||
return mark_safe(html)
|
||||
|
||||
class CasePrior(models.Model):
|
||||
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
|
||||
@@ -1255,6 +1436,10 @@ class BaseReportAnswer(models.Model):
|
||||
)
|
||||
|
||||
completed = models.BooleanField(default=False)
|
||||
# Timestamp when the user first loaded the question (started answering)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
# Timestamp when the answer was submitted/saved
|
||||
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
@@ -1276,18 +1461,25 @@ class BaseReportAnswer(models.Model):
|
||||
raise ValueError("No cid or user specified")
|
||||
|
||||
def get_correct_json_answers(self):
|
||||
logger.debug(f"Getting correct json answers for question {self.question.pk}")
|
||||
if self.question.question_schema is None or not "properties" in self.question.question_schema:
|
||||
return []
|
||||
answers = []
|
||||
for name, value in self.question.question_schema["properties"].items():
|
||||
logger.debug(f"Processing question property '{name}' with schema {value}")
|
||||
|
||||
try:
|
||||
user_answer = self.json_answer[name]
|
||||
except KeyError: # If the schema has changed?
|
||||
# Safely retrieve the user's answer and the stored correct answer.
|
||||
# json_answer or question_answers may be None (or not a dict) if not set,
|
||||
# so check before subscripting to avoid TypeError.
|
||||
if isinstance(self.json_answer, dict):
|
||||
user_answer = self.json_answer.get(name, "")
|
||||
else:
|
||||
user_answer = ""
|
||||
try:
|
||||
correct_answer = self.question.question_answers[name]
|
||||
except KeyError: # If the schema has changed?
|
||||
|
||||
qa = getattr(self.question, "question_answers", None)
|
||||
if isinstance(qa, dict):
|
||||
correct_answer = qa.get(name, "")
|
||||
else:
|
||||
correct_answer = ""
|
||||
|
||||
match value:
|
||||
@@ -1308,6 +1500,22 @@ class BaseReportAnswer(models.Model):
|
||||
|
||||
return answers
|
||||
|
||||
#def get_marked_answers_html(self):
|
||||
# """Returns an HTML representation of the users answers with correct answers highlighted"""
|
||||
# html = "<div class='json-answers'>"
|
||||
# for value, user_answer, correct_answer, answer_is_correct, automark in self.get_correct_json_answers():
|
||||
# if automark:
|
||||
# if answer_is_correct:
|
||||
# html += f"<div class='answer correct'>{user_answer}</div>"
|
||||
# else:
|
||||
# html += f"<div class='answer incorrect'>Your answer: {user_answer}<br/>Correct answer: {correct_answer}</div>"
|
||||
# else:
|
||||
# html += f"<div class='answer unmarked'>Your answer: {user_answer} (Not auto-marked)</div>"
|
||||
|
||||
# html += "</div>"
|
||||
|
||||
# return format_html(html)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -1581,4 +1789,16 @@ class QuestionSchema(models.Model, AuthorMixin):
|
||||
return json.dumps(self.schema)
|
||||
|
||||
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"
|
||||
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||
data-auto-cache-stack="false"
|
||||
data-images='{{case.get_series_images_nested}}'
|
||||
data-named-stacks='{{case.get_case_named_stacks}}'
|
||||
></div>
|
||||
</details>
|
||||
|
||||
@@ -416,6 +416,13 @@
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
|
||||
<p>This page allows you to configure how the case is displayed as part of the collection.</p>
|
||||
|
||||
|
||||
|
||||
<form method="POST" class="post-form">
|
||||
{% csrf_token %}
|
||||
{% crispy form form.helper %}
|
||||
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
</script>
|
||||
<style></style>
|
||||
{% endblock %}
|
||||
@@ -7,11 +7,11 @@
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
+337
-204
@@ -7,11 +7,11 @@
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -129,7 +129,68 @@
|
||||
|
||||
<form method="POST" class="post-form">
|
||||
{% csrf_token %}
|
||||
{{example_form}}
|
||||
{# Show example_form non-field errors first #}
|
||||
{% if example_form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Errors:</strong>
|
||||
<ul class="mb-0">
|
||||
{% for err in example_form.non_field_errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Render example_form fields explicitly so we can improve error UX for json_answer #}
|
||||
{% for field in example_form %}
|
||||
{% if field.name == 'json_answer' %}
|
||||
<div class="mb-3">
|
||||
{% if field.errors %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Answer required.</strong>
|
||||
<div>Some questions do not have a default answer, please select below.</div>
|
||||
<details style="margin-top:8px;">
|
||||
<summary class="small">Validation details</summary>
|
||||
<ul class="mb-0">
|
||||
{% for err in field.errors %}
|
||||
<li class="small text-muted">{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ field }} {# hidden/JS-bound field stays rendered #}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-3">
|
||||
{{ field.label_tag }} {{ field }}
|
||||
{% if field.errors %}
|
||||
{% for err in field.errors %}
|
||||
<div class="text-danger small">{{ err }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if field.help_text %}
|
||||
<small class="form-text text-muted">{{ field.help_text }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{# Debug: list all example_form.errors (field -> [errors]) in a collapsed block so nothing is missed #}
|
||||
{% if example_form.errors %}
|
||||
<details style="margin-top:8px;">
|
||||
<summary class="small">All example form errors (debug)</summary>
|
||||
<ul class="mb-0">
|
||||
{% for name, errs in example_form.errors.items %}
|
||||
<li><strong>{{ name }}</strong>
|
||||
<ul>
|
||||
{% for e in errs %}
|
||||
<li class="small text-muted">{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<button type="submit" value="answer" name="submit">Save Correct Answers</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -172,6 +233,65 @@
|
||||
const resetQuestionOptionsButton = document.getElementById('reset-question-options');
|
||||
const questionsContainer = document.getElementById('questions-container');
|
||||
|
||||
// Helper: get options array from a question regardless of schema shape
|
||||
function getQuestionOptions(question) {
|
||||
if (!question) return [];
|
||||
if (Array.isArray(question.enum)) return question.enum.slice();
|
||||
if (question.items && Array.isArray(question.items.enum)) return question.items.enum.slice();
|
||||
return [];
|
||||
}
|
||||
|
||||
// Helper: infer a UI questionType from the schema if question.questionType is not present
|
||||
function inferQuestionType(question) {
|
||||
if (!question) return 'text';
|
||||
if (question.type === 'array') return 'multiselect';
|
||||
if (question.format === 'select2' && question.type === 'string' && Array.isArray(getQuestionOptions(question))) return 'dropdown-select';
|
||||
if (question.format === 'select' && question.type === 'string') return 'dropdown';
|
||||
if (question.format === 'radio' && question.type === 'string') return 'radio';
|
||||
if (question.format === 'textarea') return 'textarea';
|
||||
if (question.type === 'number') return 'number';
|
||||
if (question.type === 'range') return 'range';
|
||||
if (question.enum && Array.isArray(question.enum) && question.enum.length === 2) {
|
||||
const opts = question.enum.map(String).map(s => s.toLowerCase());
|
||||
if ((opts.includes('yes') && opts.includes('no'))) return 'yesno';
|
||||
if ((opts.includes('true') && opts.includes('false'))) return 'truefalse';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
// Helper: apply an options array to a question object according to the selected UI type
|
||||
function applyOptionsToQuestion(question, optionsArray, selectedType) {
|
||||
optionsArray = (optionsArray || []).filter(o => o !== '');
|
||||
if (selectedType === 'multiselect') {
|
||||
question.type = 'array';
|
||||
question.items = question.items || { type: 'string', enum: [] };
|
||||
question.items.enum = optionsArray.length ? optionsArray : (question.items.enum || []);
|
||||
question.uniqueItems = true;
|
||||
question.format = 'select2';
|
||||
delete question.enum;
|
||||
} else {
|
||||
// convert array->single if present
|
||||
if (question.type === 'array' && question.items && Array.isArray(question.items.enum)) {
|
||||
question.enum = question.items.enum.slice();
|
||||
delete question.items;
|
||||
delete question.uniqueItems;
|
||||
}
|
||||
if (optionsArray.length) {
|
||||
question.enum = optionsArray;
|
||||
}
|
||||
// ensure string type for single-select/other
|
||||
if (question.enum && Array.isArray(question.enum)) {
|
||||
question.type = 'string';
|
||||
}
|
||||
// set formats for some known selected types
|
||||
if (selectedType === 'dropdown-select') question.format = 'select2';
|
||||
else if (selectedType === 'dropdown') question.format = 'select';
|
||||
else if (selectedType === 'radio') question.format = 'radio';
|
||||
}
|
||||
// always store the UI mapping
|
||||
question.questionType = selectedType;
|
||||
}
|
||||
|
||||
question_editor.updateProps({
|
||||
onChange: (updatedContent, previousContent, context) => {
|
||||
// Call the original onChange handler if it exists
|
||||
@@ -268,6 +388,14 @@
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
|
||||
if (!currentContent) {
|
||||
currentContent = {
|
||||
title: questionBlockTitleInput.value || "Case Questions",
|
||||
type: "object",
|
||||
properties: {}
|
||||
};
|
||||
}
|
||||
|
||||
if (!currentContent.properties) {
|
||||
currentContent.properties = {};
|
||||
current_question_number = 1;
|
||||
@@ -324,7 +452,7 @@
|
||||
description: description,
|
||||
questionType: selectedType, // Add questionType property
|
||||
};
|
||||
} else if (selectedType === 'multiselect') {
|
||||
} else if (selectedType === 'multiselect') {
|
||||
newQuestion = {
|
||||
type: "array",
|
||||
items: {
|
||||
@@ -382,168 +510,100 @@
|
||||
}
|
||||
|
||||
|
||||
newQuestion.propertyOrder = current_question_number; // Set the property order
|
||||
newQuestion.propertyOrder = current_question_number; // Set the property order
|
||||
// Add the new question to the properties
|
||||
currentContent.properties[`question_${current_question_number}`] = newQuestion;
|
||||
currentContent.properties[`question_${current_question_number}`] = newQuestion;
|
||||
|
||||
// Update the editor with the new question
|
||||
question_editor.update({ json: currentContent });
|
||||
toastr.success(`New question added: ${title}`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
}
|
||||
question_editor.update({ json: currentContent });
|
||||
toastr.success(`New question added: ${title}`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
}
|
||||
|
||||
function renderQuestions() {
|
||||
questionsContainer.innerHTML = ''; // Clear the container
|
||||
function renderQuestions() {
|
||||
questionsContainer.innerHTML = ''; // Clear the container
|
||||
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (!currentContent || !currentContent.properties) {
|
||||
console.warn('No properties found in the current content.');
|
||||
return;
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
|
||||
// Sort questions by propertyOrder
|
||||
const sortedKeys = Object.keys(properties).sort((a, b) => {
|
||||
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000;
|
||||
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000;
|
||||
return orderA - orderB;
|
||||
});
|
||||
// Sort questions by propertyOrder
|
||||
const sortedKeys = Object.keys(properties).sort((a, b) => {
|
||||
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000;
|
||||
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
sortedKeys.forEach((key, index) => {
|
||||
const question = properties[key];
|
||||
const questionElement = document.createElement('div');
|
||||
questionElement.className = 'question-item';
|
||||
questionElement.style.marginBottom = '10px';
|
||||
questionElement.setAttribute('draggable', 'true'); // Make the element draggable
|
||||
questionElement.setAttribute('data-question-key', key);
|
||||
questionElement.setAttribute('data-index', index);
|
||||
sortedKeys.forEach((key, index) => {
|
||||
const question = properties[key];
|
||||
const questionElement = document.createElement('div');
|
||||
questionElement.className = 'question-item';
|
||||
questionElement.style.marginBottom = '10px';
|
||||
questionElement.setAttribute('draggable', 'true'); // Make the element draggable
|
||||
questionElement.setAttribute('data-question-key', key);
|
||||
questionElement.setAttribute('data-index', index);
|
||||
|
||||
questionElement.innerHTML = `
|
||||
questionElement.innerHTML = `
|
||||
<strong>${question.title || key}</strong>
|
||||
<button type="button" class="btn btn-warning btn-sm edit-question-button" data-question-key="${key}" style="margin-left: 10px;">Edit</button>
|
||||
<button type="button" class="btn btn-danger btn-sm delete-question-button" data-question-key="${key}" style="margin-left: 10px;">Delete</button>
|
||||
`;
|
||||
questionsContainer.appendChild(questionElement);
|
||||
questionsContainer.appendChild(questionElement);
|
||||
|
||||
// Add event listener to delete the question
|
||||
questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => {
|
||||
const questionKey = e.target.getAttribute('data-question-key');
|
||||
deleteQuestion(questionKey);
|
||||
});
|
||||
questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => {
|
||||
const questionKey = e.target.getAttribute('data-question-key');
|
||||
deleteQuestion(questionKey);
|
||||
});
|
||||
|
||||
// Add event listener to edit the question
|
||||
questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => {
|
||||
const questionKey = e.target.getAttribute('data-question-key');
|
||||
editQuestion(questionKey);
|
||||
});
|
||||
questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => {
|
||||
const questionKey = e.target.getAttribute('data-question-key');
|
||||
editQuestion(questionKey);
|
||||
});
|
||||
|
||||
// Add drag-and-drop event listeners
|
||||
questionElement.addEventListener('dragstart', handleDragStart);
|
||||
questionElement.addEventListener('dragover', handleDragOver);
|
||||
questionElement.addEventListener('drop', handleDrop);
|
||||
questionElement.addEventListener('dragend', handleDragEnd);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rendering questions:', error);
|
||||
}
|
||||
questionElement.addEventListener('dragstart', handleDragStart);
|
||||
questionElement.addEventListener('dragover', handleDragOver);
|
||||
questionElement.addEventListener('drop', handleDrop);
|
||||
questionElement.addEventListener('dragend', handleDragEnd);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rendering questions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop event handlers
|
||||
let draggedElement = null;
|
||||
let draggedElement = null;
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/html', this.outerHTML);
|
||||
this.classList.add('dragging');
|
||||
}
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/html', this.outerHTML);
|
||||
this.classList.add('dragging');
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
if (draggedElement !== this) {
|
||||
const draggedIndex = parseInt(draggedElement.getAttribute('data-index'));
|
||||
const targetIndex = parseInt(this.getAttribute('data-index'));
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
if (draggedElement !== this) {
|
||||
const draggedIndex = parseInt(draggedElement.getAttribute('data-index'));
|
||||
const targetIndex = parseInt(this.getAttribute('data-index'));
|
||||
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const keys = Object.keys(properties);
|
||||
|
||||
// Sort keys by propertyOrder
|
||||
const sortedKeys = keys.sort((a, b) => {
|
||||
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : Infinity;
|
||||
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
// Move the dragged question to the target position
|
||||
const draggedKey = sortedKeys.splice(draggedIndex, 1)[0]; // Remove dragged key
|
||||
sortedKeys.splice(targetIndex, 0, draggedKey); // Insert dragged key at target position
|
||||
|
||||
// Reassign propertyOrder to ensure uniqueness and correct order
|
||||
sortedKeys.forEach((key, index) => {
|
||||
properties[key].propertyOrder = index + 1;
|
||||
});
|
||||
|
||||
// Assign propertyOrder to any questions without it
|
||||
keys.forEach((key) => {
|
||||
if (properties[key].propertyOrder === undefined) {
|
||||
properties[key].propertyOrder = sortedKeys.length + 1;
|
||||
sortedKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
currentContent.properties = properties;
|
||||
question_editor.update({ json: currentContent });
|
||||
toastr.success('Questions reordered successfully.');
|
||||
renderQuestions(); // Re-render the questions list
|
||||
} catch (error) {
|
||||
console.error('Error reordering questions:', error);
|
||||
toastr.error('Failed to reorder questions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
this.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// Function to delete a question
|
||||
function deleteQuestion(questionKey) {
|
||||
try {
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (currentContent.properties && currentContent.properties[questionKey]) {
|
||||
delete currentContent.properties[questionKey]; // Remove the question
|
||||
question_editor.update({ json: currentContent }); // Update the editor
|
||||
toastr.success(`Question "${questionKey}" has been deleted.`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting question:', error);
|
||||
toastr.error('Failed to delete the question.');
|
||||
}
|
||||
}
|
||||
// Initial render of questions
|
||||
renderQuestions();
|
||||
|
||||
function editQuestion(questionKey) {
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
@@ -552,96 +612,169 @@
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const question = properties[questionKey];
|
||||
const keys = Object.keys(properties);
|
||||
|
||||
if (question) {
|
||||
// Sort keys by propertyOrder
|
||||
const sortedKeys = keys.sort((a, b) => {
|
||||
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : Infinity;
|
||||
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
// Move the dragged question to the target position
|
||||
const draggedKey = sortedKeys.splice(draggedIndex, 1)[0]; // Remove dragged key
|
||||
sortedKeys.splice(targetIndex, 0, draggedKey); // Insert dragged key at target position
|
||||
|
||||
// Reassign propertyOrder to ensure uniqueness and correct order
|
||||
sortedKeys.forEach((key, index) => {
|
||||
properties[key].propertyOrder = index + 1;
|
||||
});
|
||||
|
||||
// Assign propertyOrder to any questions without it
|
||||
keys.forEach((key) => {
|
||||
if (properties[key].propertyOrder === undefined) {
|
||||
properties[key].propertyOrder = sortedKeys.length + 1;
|
||||
sortedKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
currentContent.properties = properties;
|
||||
question_editor.update({ json: currentContent });
|
||||
toastr.success('Questions reordered successfully.');
|
||||
renderQuestions(); // Re-render the questions list
|
||||
} catch (error) {
|
||||
console.error('Error reordering questions:', error);
|
||||
toastr.error('Failed to reorder questions.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
this.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// Function to delete a question
|
||||
function deleteQuestion(questionKey) {
|
||||
try {
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
if (currentContent.properties && currentContent.properties[questionKey]) {
|
||||
delete currentContent.properties[questionKey]; // Remove the question
|
||||
question_editor.update({ json: currentContent }); // Update the editor
|
||||
toastr.success(`Question "${questionKey}" has been deleted.`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting question:', error);
|
||||
toastr.error('Failed to delete the question.');
|
||||
}
|
||||
}
|
||||
// Initial render of questions
|
||||
renderQuestions();
|
||||
|
||||
function editQuestion(questionKey) {
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const question = properties[questionKey];
|
||||
|
||||
if (question) {
|
||||
// Populate the form fields with the question's data
|
||||
questionTitleInput.value = question.title || '';
|
||||
questionDescriptionInput.value = question.description || '';
|
||||
questionTypeSelect.value = question.questionType || 'text';
|
||||
questionTitleInput.value = question.title || '';
|
||||
questionDescriptionInput.value = question.description || '';
|
||||
questionTypeSelect.value = question.questionType || 'text';
|
||||
|
||||
// Clear existing options and populate them if applicable
|
||||
while (questionOptionsContainer.firstChild) {
|
||||
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
|
||||
}
|
||||
if (question.enum) {
|
||||
question.enum.forEach((option, index) => {
|
||||
const optionInput = document.createElement('div');
|
||||
optionInput.className = 'option-input';
|
||||
optionInput.style.marginBottom = '5px';
|
||||
optionInput.innerHTML = `
|
||||
while (questionOptionsContainer.firstChild) {
|
||||
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
|
||||
}
|
||||
// Support both single-select (question.enum) and multiselect (question.items.enum)
|
||||
const enumList = getQuestionOptions(question) || [];
|
||||
if (enumList.length) {
|
||||
enumList.forEach((option, index) => {
|
||||
const optionInput = document.createElement('div');
|
||||
optionInput.className = 'option-input';
|
||||
optionInput.style.marginBottom = '5px';
|
||||
optionInput.innerHTML = `
|
||||
<input type="text" class="form-control option-field" value="${option}" placeholder="Option ${index + 1}" style="display: inline-block; width: 90%;">
|
||||
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">×</button>
|
||||
`;
|
||||
questionOptionsContainer.appendChild(optionInput);
|
||||
|
||||
// Add event listener to remove the option
|
||||
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
|
||||
questionOptionsContainer.removeChild(optionInput);
|
||||
});
|
||||
questionOptionsContainer.appendChild(optionInput);
|
||||
// Add event listener to remove the option
|
||||
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
|
||||
questionOptionsContainer.removeChild(optionInput);
|
||||
});
|
||||
optionsConfigurator.style.display = 'block';
|
||||
} else {
|
||||
optionsConfigurator.style.display = 'none';
|
||||
}
|
||||
});
|
||||
optionsConfigurator.style.display = 'block';
|
||||
} else {
|
||||
optionsConfigurator.style.display = 'none';
|
||||
}
|
||||
// If questionType is not set, infer it from the schema
|
||||
questionTypeSelect.value = question.questionType || inferQuestionType(question);
|
||||
|
||||
// Update the "Add New Question" button to save changes
|
||||
addNewQuestionButton.textContent = 'Save Changes';
|
||||
addNewQuestionButton.onclick = () => {
|
||||
saveEditedQuestion(questionKey);
|
||||
};
|
||||
document.getElementById('add-question-block').open = true; // Open the question block
|
||||
document.getElementById('add-question-block').scrollIntoView({ behavior: 'smooth', block: "center" }); // Scroll to the question block
|
||||
addNewQuestionButton.textContent = 'Save Changes';
|
||||
addNewQuestionButton.onclick = () => {
|
||||
saveEditedQuestion(questionKey);
|
||||
};
|
||||
document.getElementById('add-question-block').open = true; // Open the question block
|
||||
document.getElementById('add-question-block').scrollIntoView({ behavior: 'smooth', block: "center" }); // Scroll to the question block
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error editing question:', error);
|
||||
toastr.error('Failed to edit the question.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error editing question:', error);
|
||||
toastr.error('Failed to edit the question.');
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditedQuestion(questionKey) {
|
||||
function saveEditedQuestion(questionKey) {
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
let currentContent;
|
||||
try {
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const question = properties[questionKey];
|
||||
currentContent = JSON.parse(question_editor.get().text);
|
||||
} catch {
|
||||
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
|
||||
}
|
||||
const properties = currentContent.properties || {};
|
||||
const question = properties[questionKey];
|
||||
|
||||
if (question) {
|
||||
if (question) {
|
||||
// Update the question with the new values from the form
|
||||
question.title = questionTitleInput.value || `Question ${questionKey}`;
|
||||
question.description = questionDescriptionInput.value || '';
|
||||
question.format = questionTypeSelect.value;
|
||||
question.title = questionTitleInput.value || `Question ${questionKey}`;
|
||||
question.description = questionDescriptionInput.value || '';
|
||||
question.format = questionTypeSelect.value;
|
||||
|
||||
// Update the options if applicable
|
||||
if (question.enum) {
|
||||
question.enum = Array.from(questionOptionsContainer.querySelectorAll('.option-field'))
|
||||
.map((input) => input.value.trim())
|
||||
.filter((option) => option !== ''); // Exclude empty options
|
||||
}
|
||||
const newOptions = questionOptionsContainer ? Array.from(questionOptionsContainer.querySelectorAll('.option-field')).map((input) => input.value.trim()).filter((option) => option !== '') : [];
|
||||
const selectedType = questionTypeSelect ? questionTypeSelect.value : null;
|
||||
applyOptionsToQuestion(question, newOptions, selectedType);
|
||||
|
||||
// Update the editor with the modified question
|
||||
currentContent.properties[questionKey] = question;
|
||||
question_editor.update({ json: currentContent });
|
||||
// Update the editor with the modified question
|
||||
currentContent.properties[questionKey] = question;
|
||||
try { question_editor.update({ json: currentContent }); } catch (e) { console.warn('Failed to update editor after saveEditedQuestion', e); }
|
||||
|
||||
toastr.success(`Question "${question.title}" updated successfully.`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
toastr.success(`Question "${question.title}" updated successfully.`);
|
||||
renderQuestions(); // Re-render the questions list
|
||||
|
||||
// Reset the "Add New Question" button
|
||||
addNewQuestionButton.textContent = 'Add New Question';
|
||||
addNewQuestionButton.onclick = addNewQuestion;
|
||||
resetQuestion(); // Reset the question options
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving edited question:', error);
|
||||
toastr.error('Failed to save the question.');
|
||||
addNewQuestionButton.textContent = 'Add New Question';
|
||||
addNewQuestionButton.onclick = addNewQuestion;
|
||||
resetQuestion(); // Reset the question options
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving edited question:', error);
|
||||
toastr.error('Failed to save the question.');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<span class="collection-name-blend">Collection: {{collection}}</span>
|
||||
@@ -16,6 +17,20 @@
|
||||
|
||||
</h2>
|
||||
|
||||
{% if not question_completed and collection.question_time_limit is not None %}
|
||||
<div id="question-timer-block" style="margin-top:8px; margin-bottom:8px;" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
|
||||
<strong>Time remaining:</strong>
|
||||
<span id="question-timer" aria-live="polite"> </span>
|
||||
<div id="question-timer-progress" style="display:inline-block; vertical-align: middle; width: 200px; margin-left:12px;">
|
||||
<div id="question-timer-progress-outer" style="background:#e9ecef; border-radius:6px; height:10px; overflow:hidden;">
|
||||
<div id="question-timer-progress-inner" style="width:100%; height:100%; background:var(--timer-color, #28a745);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="timer-htmx-target" style="display:none;"></div>
|
||||
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} <details>
|
||||
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
|
||||
</details> {% endcomment %}
|
||||
@@ -40,22 +55,22 @@
|
||||
Description: {{case.description}}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if show_history and case.history %}
|
||||
{% if show_history %}
|
||||
<div>
|
||||
History: {{case.history|linebreaks}}
|
||||
History: {{case_detail.get_history_pre|linebreaks}}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer %}
|
||||
<div>
|
||||
|
||||
|
||||
{% if question_completed %}
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe>
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe>
|
||||
{% else %}
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe>
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -63,15 +78,25 @@
|
||||
{% if collection.show_ohif_viewer_link %}
|
||||
<div>
|
||||
{% if question_completed %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% else %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_built_in_viewer %}
|
||||
<div class="pre-whitespace multi-image-block">
|
||||
|
||||
<details id="dicom-viewer-details" open>
|
||||
<summary>Viewer</summary>
|
||||
<div id="main_viewer" class="dicom-viewer-root"
|
||||
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||
data-auto-cache-stack="false"
|
||||
data-named-stacks='{{case_detail.get_case_named_stacks}}'
|
||||
></div>
|
||||
</details>
|
||||
|
||||
{% comment %} <div class="pre-whitespace multi-image-block">
|
||||
<details open>
|
||||
<summary>
|
||||
Images
|
||||
@@ -101,7 +126,7 @@
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
{% else %}
|
||||
{% endif %}
|
||||
|
||||
@@ -148,35 +173,7 @@
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
{% if question_completed %}
|
||||
<h3>Answers</h3>
|
||||
|
||||
<div class="answer-block">
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in answer.get_correct_json_answers %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h4>{{value.title}}</h4>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "atlas/partials/collection_question_answer_block.html" %}
|
||||
|
||||
{% if collection.self_review %}
|
||||
<div>
|
||||
@@ -222,28 +219,31 @@
|
||||
{{form.json.errors}}
|
||||
<div class="form-contents">
|
||||
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
|
||||
{{form}}
|
||||
{{form | crispy}}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div>
|
||||
{% if collection.self_review %}
|
||||
<p>
|
||||
<a target="_blank" title="Add self review, this will open in a new page / tab" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button type="button">Add self review</button></a>
|
||||
</p>
|
||||
{% if self_review %}
|
||||
<h4>Self Feedback</h4>
|
||||
|
||||
{% for review in self_review %}
|
||||
{{review.get_display_block}}
|
||||
{% if question_completed %}
|
||||
<div>
|
||||
{% if collection.self_review %}
|
||||
<p>
|
||||
<a target="_blank" title="Add self review, this will open in a new page / tab" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button type="button">Add self review</button></a>
|
||||
</p>
|
||||
{% if self_review %}
|
||||
<h4>Self Feedback</h4>
|
||||
|
||||
{% endfor %}
|
||||
{% for review in self_review %}
|
||||
{{review.get_display_block}}
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h4>Answer score: {{answer.score}}</h4>
|
||||
Answer feedback: {{answer.feedback|safe}}
|
||||
<br/>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h4>Answer score: {{answer.score}}</h4>
|
||||
Answer feedback: {{answer.feedback|safe}}
|
||||
<br/>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% if previous %}
|
||||
@@ -343,107 +343,213 @@
|
||||
{{ form.media }}
|
||||
{% comment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||
<script type="text/javascript">
|
||||
window.images = {
|
||||
{% comment %} {% for series in series_list %}
|
||||
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
|
||||
{% endfor %} {% endcomment %}
|
||||
{% for series, prior, relation in series_to_load %}
|
||||
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
|
||||
{% endfor %}
|
||||
|
||||
}
|
||||
// Question time limit countdown + auto-submit
|
||||
$(function () {
|
||||
|
||||
$(document).ready(function () {
|
||||
setTimeout(() => {
|
||||
window.loadDicomViewer(window.images[0])
|
||||
}, 500);
|
||||
})
|
||||
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);
|
||||
}
|
||||
|
||||
{% comment %} $('document').ready(function() {
|
||||
// Visual feedback: set timer to Locked and progress to red
|
||||
$progressInner = $('#question-timer-progress-inner');
|
||||
$('#question-timer').text('Locked');
|
||||
$progressInner.css('background', '#dc3545');
|
||||
$progressInner.css('width', '0%');
|
||||
|
||||
// Get value from either a json string or url pointing to a json file
|
||||
function process(value) {
|
||||
var isjson=true;
|
||||
var result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(value);
|
||||
} catch(e) {
|
||||
isjson=false;
|
||||
}
|
||||
|
||||
if (isjson) {
|
||||
return result;
|
||||
} else {
|
||||
return $.getJSON(value)
|
||||
.then(function (response) {
|
||||
return response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$('.editor_holder').each(function() {
|
||||
// Get the DOM Element
|
||||
var element = $(this).get(0);
|
||||
console.log("el", element)
|
||||
|
||||
var options_text = $(this).attr('options')
|
||||
var schema_text = $(this).attr('schema')
|
||||
|
||||
var schema = process(schema_text);
|
||||
var options = process(options_text);
|
||||
|
||||
var name = $(this).attr('name');
|
||||
var hidden_identifier = 'input[name=' + name + ']';
|
||||
var initial = $(hidden_identifier).val();
|
||||
|
||||
// Check if editor is within form
|
||||
var form = $(this).closest('form')
|
||||
console.log("form", form)
|
||||
|
||||
//Wait for any ajax requests to complete
|
||||
$.when(schema, options).done(function(schemaresult, optionsresult) {
|
||||
optionsresult.form_name_root = name;
|
||||
|
||||
// Pass initial value though to editor
|
||||
if (initial) {
|
||||
optionsresult.startval = JSON.parse(initial);
|
||||
$("#question-timer-progress-outer").hide();
|
||||
}
|
||||
|
||||
optionsresult.schema = schemaresult;
|
||||
// console.log(options);
|
||||
var editor = new JSONEditor(element, optionsresult);
|
||||
try {
|
||||
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});
|
||||
|
||||
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 (!timeLimit || questionCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
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")
|
||||
{% if answer %}
|
||||
if ({{answer.completed|yesno:"true,false"}}) {
|
||||
lockQuestion();
|
||||
console.debug('Timer: answer already completed, aborting timer init');
|
||||
return;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
var $timer = $('#question-timer');
|
||||
var $form = $('form.post-form');
|
||||
|
||||
if ($timer.length === 0 || $form.length === 0) {
|
||||
console.debug('Timer: required elements not found, aborting timer init');
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute remaining based on the canonical start time (if provided)
|
||||
var remaining = parseInt(timeLimit, 10);
|
||||
if (answerStartedAtIso) {
|
||||
try {
|
||||
var started = new Date(answerStartedAtIso);
|
||||
var now = new Date();
|
||||
var elapsed = Math.floor((now - started) / 1000);
|
||||
remaining = Math.max(0, remaining - elapsed);
|
||||
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remaining:', remaining);
|
||||
} catch (e) {
|
||||
console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e);
|
||||
}
|
||||
}
|
||||
|
||||
// If time has already elapsed when the page loads, lock the question and do not autosubmit.
|
||||
if (remaining <= 0) {
|
||||
console.debug('Timer: time already expired on load, locking without autosubmit');
|
||||
lockQuestion();
|
||||
$timer.text(formatTime(0));
|
||||
return;
|
||||
}
|
||||
|
||||
function formatTime(s) {
|
||||
var mins = Math.floor(s / 60);
|
||||
var secs = s % 60;
|
||||
return mins + ':' + (secs < 10 ? '0' + secs : secs);
|
||||
}
|
||||
|
||||
var $progressInner = $('#question-timer-progress-inner');
|
||||
$timer.text(formatTime(remaining));
|
||||
|
||||
function updateProgress() {
|
||||
var pct = Math.max(0, Math.min(100, Math.round((remaining / timeLimit) * 100)));
|
||||
var widthPct = pct;
|
||||
$progressInner.css('width', widthPct + '%');
|
||||
|
||||
// Smooth color transition: green (120) -> orange (30) -> red (0)
|
||||
// We use a two-stage interpolation so the midpoint (~50%) is orange.
|
||||
var hue = 0;
|
||||
if (pct > 50) {
|
||||
// interpolate from orange (30) to green (120)
|
||||
var t = (pct - 50) / 50.0; // 0..1
|
||||
hue = 30 + t * (120 - 30);
|
||||
} else {
|
||||
// interpolate from red (0) to orange (30)
|
||||
var t = pct / 50.0; // 0..1
|
||||
hue = 0 + t * (30 - 0);
|
||||
}
|
||||
|
||||
var color = 'hsl(' + Math.round(hue) + ', 75%, 40%)';
|
||||
$progressInner.css('background', color);
|
||||
}
|
||||
|
||||
updateProgress();
|
||||
|
||||
var intervalId = setInterval(function () {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(intervalId);
|
||||
$timer.text('0:00');
|
||||
$progressInner.css('width', '0%');
|
||||
$progressInner.css('background', '#dc3545');
|
||||
//var $next = $form.find('button[name="next"]');
|
||||
//var $finish = $form.find('button[name="finish"]');
|
||||
|
||||
// Instead of navigating away, submit via fetch with timed_out flag
|
||||
// Use HTMX to POST the timed_out flag and then lock the UI
|
||||
|
||||
var onAfter = function (evt) {
|
||||
try {
|
||||
var resp = null;
|
||||
try {
|
||||
var xhr = evt && evt.detail && evt.detail.xhr;
|
||||
if (xhr && xhr.responseText) {
|
||||
resp = JSON.parse(xhr.responseText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('No JSON response from timed_out request', e);
|
||||
}
|
||||
|
||||
lockQuestion();
|
||||
|
||||
var msg = 'Answer auto-submitted';
|
||||
if (resp && resp.submitted_at) {
|
||||
try {
|
||||
var dt = new Date(resp.submitted_at);
|
||||
msg += ' at ' + dt.toLocaleTimeString();
|
||||
} catch (e) {}
|
||||
}
|
||||
toastr.info(msg);
|
||||
} catch (e) {
|
||||
console.error('Error handling timed_out htmx response', e);
|
||||
} finally {
|
||||
htmx.off('htmx:afterRequest', onAfter);
|
||||
}
|
||||
};
|
||||
|
||||
htmx.on('htmx:afterRequest', onAfter);
|
||||
// Serialize the entire form so the server receives the user's answers
|
||||
// along with the timed_out flag. This ensures the form.save() path
|
||||
// can validate and persist the submitted answers on timeout.
|
||||
(function(){
|
||||
var formValues = {};
|
||||
try {
|
||||
$.each($form.serializeArray(), function(i, field) {
|
||||
if (formValues[field.name] !== undefined) {
|
||||
if (!Array.isArray(formValues[field.name])) {
|
||||
formValues[field.name] = [formValues[field.name]];
|
||||
}
|
||||
formValues[field.name].push(field.value);
|
||||
} else {
|
||||
formValues[field.name] = field.value;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('Failed to serialize form with jQuery, falling back to manual collection', e);
|
||||
// Fallback: try to collect inputs manually
|
||||
var inputs = $form.find('input, textarea, select').not(':disabled');
|
||||
inputs.each(function () {
|
||||
var $el = $(this);
|
||||
var name = $el.attr('name');
|
||||
if (!name) return;
|
||||
var val = $el.val();
|
||||
if (formValues[name] !== undefined) {
|
||||
if (!Array.isArray(formValues[name])) {
|
||||
formValues[name] = [formValues[name]];
|
||||
}
|
||||
formValues[name].push(val);
|
||||
} else {
|
||||
formValues[name] = val;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure the timed_out flag is included
|
||||
formValues['timed_out'] = '1';
|
||||
|
||||
htmx.ajax('POST', window.location.href, {
|
||||
values: formValues,
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('input[name="csrfmiddlewaretoken"]').value,
|
||||
},
|
||||
target: "#timer-htmx-target",
|
||||
});
|
||||
})();
|
||||
} else {
|
||||
$timer.text(formatTime(remaining));
|
||||
updateProgress();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
$form.on('submit', function () {
|
||||
clearInterval(intervalId);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error initializing timer:', e);
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
{% endcomment %}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock js %}
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef casedetails-management-links %}
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
|
||||
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
|
||||
{% endif %}
|
||||
)
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-info-square" title="Case details"></i></a>)
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
(<a href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
|
||||
{% endif %}
|
||||
{% endpartialdef %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
@@ -14,6 +42,26 @@
|
||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
||||
Self review: {{collection.self_review}}<br />
|
||||
Open access: {{collection.open_access}}<br />
|
||||
{% if collection.prerequisites.exists %}
|
||||
Prerequisite collections:
|
||||
<ul>
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
|
||||
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,41 +78,23 @@
|
||||
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
<h3>Cases</h3>
|
||||
<ol id="full-question-list" class="sortable">
|
||||
{% for casedetail in casesdetails %}
|
||||
<li data-question_pk={{casedetail.case.pk}}>
|
||||
|
||||
|
||||
|
||||
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||
:
|
||||
:
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>
|
||||
{% endif %}
|
||||
{{casedetail.case.title}}
|
||||
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
|
||||
{% endif %}
|
||||
)
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
|
||||
{% endif %}
|
||||
{% partial casedetails-management-links %}
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
@@ -3,26 +3,52 @@
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
<h3>Users</h3>
|
||||
|
||||
<ul>
|
||||
{% for userexam in userexams %}
|
||||
<li id="user-history-{{ userexam.user_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
|
||||
Completed: {{userexam.completed}}<br/>
|
||||
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
|
||||
{% if userexams %}
|
||||
<h3>Users</h3>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
|
||||
hx-target="#user-history-{{ userexam.user_user.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<ul>
|
||||
{% for userexam in userexams %}
|
||||
<li id="user-history-{{ userexam.user_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
|
||||
Completed: {{userexam.completed}}<br/>
|
||||
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
|
||||
hx-target="#user-history-{{ userexam.user_user.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if cidexams %}
|
||||
<h3>CID Users</h3>
|
||||
|
||||
<ul>
|
||||
{% for cidexam in cidexams %}
|
||||
<li id="cid-history-{{ cidexam.cid_user.pk }}">
|
||||
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{cidexam.cid_user}}</a><b><br/>
|
||||
Completed: {{cidexam.completed}}<br/>
|
||||
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
|
||||
|
||||
<button
|
||||
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
|
||||
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this CID user's collection history?"
|
||||
class="btn btn-danger btn-sm remove-button">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,39 +10,53 @@
|
||||
<li class="case">
|
||||
|
||||
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
|
||||
Question started: {{user_answer.started_at}} - Answer submitted: {{user_answer.submitted_at}}
|
||||
{% if request.user.is_superuser and user_answer %}
|
||||
(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
|
||||
{% endif %}
|
||||
<br/>
|
||||
<br/>
|
||||
{% if not user_answer %}
|
||||
<span class="case-not-answered">Case not answered.</span>
|
||||
{% else %}
|
||||
<div class="answer-block">
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
|
||||
{% if not user_answer %}
|
||||
Not answered
|
||||
{% else %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
User answer:
|
||||
<div class="user-answer">
|
||||
{% if user_answer.answer %}
|
||||
{{user_answer.answer}}
|
||||
{% else %}
|
||||
{{user_answer.json_answer}}
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
|
||||
{% if not user_answer %}
|
||||
Not answered
|
||||
{% else %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h5>{{value.title}}</h5>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h5>{{value.title}}</h5>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
|
||||
{% for cid_user_exam in cid_users %}
|
||||
{% if cid_user_exam.user_user and cid_user_exam.user_user.id %}
|
||||
<button class="btn btn-sm btn-primary" title="This will clear the user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk cid_user_exam.user_user.id %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset answers for the user? This action cannot be undone."
|
||||
>{{cid_user_exam.user_user}}</button>
|
||||
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk cid_user_exam.user_user.id %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset answers for the user? This action cannot be undone."
|
||||
>{{ cid_user_exam.user_user }}</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary" disabled>Unknown user</button>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{% if request.user.is_authenticated and valid_user %}
|
||||
User: {{request.user}}<br/>
|
||||
|
||||
|
||||
{% if cid_exam %}
|
||||
Started: {{cid_exam.start_time}} <br/>
|
||||
|
||||
@@ -26,7 +27,7 @@
|
||||
<a href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}"><button>Overview</button></a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Enter your CID and passcode in the below boxes.<br />
|
||||
Enter your CID and passcode in the below boxes. (Registered user should login <a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">here</a>)<br />
|
||||
<p><input id="cid-box" type="text" value="Candidate ID"></p>
|
||||
<p><input id="passcode-box" type="text" value="Passcode"></p>
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
{% endfor %}
|
||||
</details>
|
||||
</div>
|
||||
{# Hidden per-case question snippet to inject when case is loaded #}
|
||||
<div class="question-block-snippet" style="display:none;">
|
||||
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail %}
|
||||
</div>
|
||||
{% if case.display_sets.all %}
|
||||
<details class="displayset-detail"><summary>Display Sets:</summary>
|
||||
{% for ds in case.display_sets.all %}
|
||||
@@ -116,10 +120,9 @@
|
||||
<div id="current-case-history"></div>
|
||||
<div id="current-case-discussion"></div>
|
||||
<div id="current-case-report"></div>
|
||||
<div id="current-case-questions" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe id="viewer" style="width: 100%; height: 500px; border: none"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -223,6 +226,14 @@
|
||||
"viewerstate": viewerstate
|
||||
});
|
||||
|
||||
// Inject the per-case question snippet into the current case panel
|
||||
try {
|
||||
var snippet = $(c).find('.question-block-snippet').html();
|
||||
$('#current-case-questions').html(snippet || '');
|
||||
} catch (e) {
|
||||
console.warn('Unable to inject question snippet', e);
|
||||
}
|
||||
|
||||
});
|
||||
$('.open-displayset').click(function() {
|
||||
let c = this;
|
||||
@@ -244,6 +255,15 @@
|
||||
"annotations": annotations
|
||||
});
|
||||
|
||||
// Inject the per-case question snippet
|
||||
try {
|
||||
var caseItem = $(this).closest('.case-item');
|
||||
var snippet = caseItem.find('.question-block-snippet').html();
|
||||
$('#current-case-questions').html(snippet || '');
|
||||
} catch (e) {
|
||||
console.warn('Unable to inject question snippet for displayset', e);
|
||||
}
|
||||
|
||||
});
|
||||
$('.open-series-local').click(function() {
|
||||
let c = $(this).closest(".case-item")[0];
|
||||
@@ -266,6 +286,14 @@
|
||||
"series": seriesPk,
|
||||
"images": JSON.stringify(images),
|
||||
});
|
||||
|
||||
// Inject the per-case question snippet
|
||||
try {
|
||||
var snippet = $(c).find('.question-block-snippet').html();
|
||||
$('#current-case-questions').html(snippet || '');
|
||||
} catch (e) {
|
||||
console.warn('Unable to inject question snippet for series', e);
|
||||
}
|
||||
});
|
||||
$('.open-case, .open-series').click(function() {
|
||||
var url = $(this).data('target');
|
||||
@@ -291,6 +319,14 @@
|
||||
$('#current-case-series').html("<span class='title'>Series:</span> " + $(this).data('series'));
|
||||
}
|
||||
bc.postMessage({"type": "open", "url" : url});
|
||||
// Inject question snippet for this case
|
||||
try {
|
||||
var caseItem = $(this).closest('.case-item');
|
||||
var snippet = caseItem.find('.question-block-snippet').html();
|
||||
$('#current-case-questions').html(snippet || '');
|
||||
} catch (e) {
|
||||
console.warn('Unable to inject question snippet', e);
|
||||
}
|
||||
//if (win2 == false || win2.closed) {
|
||||
// win2 = openSecondaryWindow(url);
|
||||
// console.log('opened', win2)
|
||||
@@ -368,4 +404,6 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
|
||||
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
|
||||
{% endblock css %}
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="answer-block">
|
||||
{% for value, user_answer, correct_answer, answer_is_correct, automark in answer.get_correct_json_answers %}
|
||||
<div class="{% if answer_is_correct %}
|
||||
correct
|
||||
{% else %}
|
||||
incorrect
|
||||
{% endif %}
|
||||
{% if automark %}
|
||||
automark
|
||||
{% endif %}
|
||||
|
||||
">
|
||||
<h4>{{value.title}}</h4>
|
||||
{{value.description}}
|
||||
<div
|
||||
>
|
||||
Answer : {{user_answer}}
|
||||
|
||||
{% if not answer_is_correct %}
|
||||
<br/>Correct answer: {{correct_answer}}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
{# Partial: render questions and answers as HTML. #}
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
<div class="collection-question-block">
|
||||
<h4>Questions</h4>
|
||||
<dl class="row">
|
||||
{% for name, prop in case_detail.question_schema.properties.items %}
|
||||
<dt class="col-sm-4">{{ prop.title|default:name }}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if prop.description %}
|
||||
<div class="mb-1 text-muted small">{{ prop.description }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if prop.type == "string" and prop.enum %}
|
||||
<div class="mt-1 small text-muted">Options: {{ prop.enum|join:", " }}</div>
|
||||
{% else %}
|
||||
<div class="mt-1 small text-muted">Type: {{ prop.type }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
|
||||
{% if case_detail.question_answers %}
|
||||
{% with correct=case_detail.question_answers|get_item:name %}
|
||||
<div class="mt-2">
|
||||
<strong>Example / Correct:</strong>
|
||||
{% if correct %}
|
||||
<span class="ms-2">{{ correct }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted ms-2">(no answer provided)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="mt-2"><strong>Example / Correct:</strong> <span class="text-muted ms-2">(no answer provided)</span></div>
|
||||
{% endif %}
|
||||
|
||||
{# Optionally render and compare a user's answer passed as `user_answer` in the context #}
|
||||
{% if user_answer %}
|
||||
{% if user_answer|get_item:name %}
|
||||
{% with ua=user_answer|get_item:name %}
|
||||
{% with correct=case_detail.question_answers|get_item:name %}
|
||||
<div class="mt-1">
|
||||
<strong>Your answer:</strong>
|
||||
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
|
||||
{% if ua == correct %}
|
||||
<span class="badge bg-success ms-2">Correct</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger ms-2">Incorrect</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="mt-1"><strong>Your answer:</strong> <span class="text-muted ms-2">(no answer)</span></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="collection-question-block text-muted">No questions defined for this case.</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="alert alert-success">
|
||||
<strong>Two-way sync complete</strong>
|
||||
<p>Total added: {{ total_added_cid }} CID(s), {{ total_added_user }} user(s), {{ total_added_cid_groups }} CID group(s), {{ total_added_user_groups }} user group(s).</p>
|
||||
<details>
|
||||
<summary>Per-collection changes ({{ per_collection_added|length }})</summary>
|
||||
<ul>
|
||||
{% for pk, added_cid, added_user, added_cid_groups, added_user_groups in per_collection_added %}
|
||||
<li>Collection {{ pk }}: +{{ added_cid }} CID(s), +{{ added_user }} user(s), +{{ added_cid_groups }} CID group(s), +{{ added_user_groups }} user group(s)</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Collection requires completion of another collection</h2>
|
||||
<p>{{ message }}</p>
|
||||
{% if prereq %}
|
||||
<p>
|
||||
You must complete the collection:
|
||||
{% if cid and passcode %}
|
||||
<a href="{% url 'atlas:collection_take_start' prereq.pk %}?cid={{ cid }}&passcode={{ passcode }}">{{ prereq.name }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'atlas:collection_take_start' prereq.pk %}">{{ prereq.name }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
If you think this is an error, contact the course administrator.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -15,8 +15,11 @@
|
||||
{% endif %}
|
||||
|
||||
{% if collection %}
|
||||
<div style="float: right;">
|
||||
Collection:Case details
|
||||
{% include "atlas/collection_detail.html#casedetails-management-links" %}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous question</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Collections</h2>
|
||||
<h2>Collections</h2>
|
||||
|
||||
The following collections are available for you to view / take.
|
||||
The following collections are available for you to view / take. If you received a directly link to a collection it will not appear here unless you have started it.
|
||||
|
||||
|
||||
|
||||
<h3>Available to start</h3>
|
||||
<ul>
|
||||
{% for collection in collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{collection}} {{collelction.is_complete}}</a>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
{% for collection in available_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
{% if collection.description %}
|
||||
<small class="text-muted">— {{ collection.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No collections available to start.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
|
||||
<h3>In progress</h3>
|
||||
<ul>
|
||||
{% for collection in in_progress_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-warning text-dark">In progress</span>
|
||||
{% if collection.description %}
|
||||
<small class="text-muted">— {{ collection.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No in-progress collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h3>Finished</h3>
|
||||
<ul>
|
||||
{% for collection in finished_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% if collection.description %}
|
||||
<small class="text-muted">— {{ collection.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No finished collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -80,6 +80,11 @@ urlpatterns = [
|
||||
views.collection_take_start,
|
||||
name="collection_take_start",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/sync_prerequisite_users",
|
||||
views.collection_sync_prerequisite_users,
|
||||
name="collection_sync_prerequisite_users",
|
||||
),
|
||||
path("collection/<int:pk>/authors", views.CaseCollectionAuthorUpdate.as_view(), name="collection_authors"),
|
||||
path(
|
||||
"collection/<int:pk>/toggle_results_published",
|
||||
@@ -128,6 +133,11 @@ urlpatterns = [
|
||||
views.collection_history_user,
|
||||
name="collection_history_user",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/history/<int:cid>/ciduser",
|
||||
views.collection_history_ciduser,
|
||||
name="collection_history_ciduser",
|
||||
),
|
||||
|
||||
path(
|
||||
"collection/<int:pk>/user_status",
|
||||
@@ -161,6 +171,11 @@ urlpatterns = [
|
||||
views.collection_case_details,
|
||||
name="collection_case_details",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/questions",
|
||||
views.collection_case_questions,
|
||||
name="collection_case_questions",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/priors",
|
||||
views.collection_case_priors,
|
||||
@@ -196,6 +211,11 @@ urlpatterns = [
|
||||
views.collection_reset_answers_user,
|
||||
name="collection_reset_answers_user",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/cid/<int:cid>/reset_answers",
|
||||
views.collection_reset_answers_ciduser,
|
||||
name="collection_reset_answers_ciduser",
|
||||
),
|
||||
#path(
|
||||
# "question_schemas_preset",
|
||||
# views.question_schemas_preset,
|
||||
|
||||
+374
-62
@@ -38,6 +38,7 @@ from django.urls import reverse_lazy, reverse
|
||||
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from generic.models import CidUser, CidUserExam, CimarCase
|
||||
from atlas.helpers import get_cases_available_to_user
|
||||
@@ -51,6 +52,7 @@ from .forms import (
|
||||
CaseCollectionForm,
|
||||
CaseCollectionUpdateCaseForm,
|
||||
CaseDetailForm,
|
||||
CaseQuestionForm,
|
||||
CaseDisplaySetForm,
|
||||
CaseForm,
|
||||
CaseResourceFormSet,
|
||||
@@ -103,6 +105,7 @@ from .models import (
|
||||
SeriesImage,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
PrerequisiteRequired
|
||||
)
|
||||
from .tables import (
|
||||
CaseCollectionTable,
|
||||
@@ -302,47 +305,54 @@ def case_displaysets_delete(request, pk):
|
||||
displayset.delete()
|
||||
return HttpResponse("<li class='list-group-item' >Display set deleted successfully.")
|
||||
|
||||
|
||||
def get_case_for_case_detail(pk: int) -> Case:
|
||||
try:
|
||||
case = (
|
||||
Case.objects
|
||||
.select_related() # Add any FK fields if needed
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"seriesdetail_set",
|
||||
queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related(
|
||||
Prefetch(
|
||||
"series",
|
||||
queryset=Series.objects.select_related("modality", "examination", "plane", "contrast")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"findings",
|
||||
queryset=SeriesFinding.objects.prefetch_related(
|
||||
"findings", "conditions", "structures"
|
||||
)
|
||||
),
|
||||
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")),
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
Prefetch("subspecialty", queryset=Subspecialty.objects.all()),
|
||||
Prefetch("condition", queryset=Condition.objects.all()),
|
||||
Prefetch("presentation", queryset=Presentation.objects.all()),
|
||||
Prefetch("pathological_process", queryset=PathologicalProcess.objects.all()),
|
||||
Prefetch("caseresource_set", queryset=CaseResource.objects.all()),
|
||||
Prefetch("casecollection_set", queryset=CaseCollection.objects.all()),
|
||||
Prefetch("display_sets", queryset=CaseDisplaySet.objects.prefetch_related(
|
||||
Prefetch("findings", queryset=Finding.objects.all()),
|
||||
Prefetch("structures", queryset=Structure.objects.all()),
|
||||
Prefetch("conditions", queryset=Condition.objects.all()),
|
||||
)),
|
||||
)
|
||||
.get(pk=pk)
|
||||
)
|
||||
case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()]
|
||||
return case
|
||||
except Case.DoesNotExist:
|
||||
raise Http404("Case not found.")
|
||||
|
||||
@login_required
|
||||
@user_has_case_view_access
|
||||
def case_detail(request, pk):
|
||||
# Prefetch all related objects needed for the template
|
||||
case = (
|
||||
Case.objects
|
||||
.select_related() # Add any FK fields if needed
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"seriesdetail_set",
|
||||
queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related(
|
||||
Prefetch(
|
||||
"series",
|
||||
queryset=Series.objects.select_related("modality", "examination", "plane", "contrast")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"findings",
|
||||
queryset=SeriesFinding.objects.prefetch_related(
|
||||
"findings", "conditions", "structures"
|
||||
)
|
||||
),
|
||||
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")),
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
Prefetch("subspecialty", queryset=Subspecialty.objects.all()),
|
||||
Prefetch("condition", queryset=Condition.objects.all()),
|
||||
Prefetch("presentation", queryset=Presentation.objects.all()),
|
||||
Prefetch("pathological_process", queryset=PathologicalProcess.objects.all()),
|
||||
Prefetch("caseresource_set", queryset=CaseResource.objects.all()),
|
||||
Prefetch("casecollection_set", queryset=CaseCollection.objects.all()),
|
||||
Prefetch("display_sets", queryset=CaseDisplaySet.objects.prefetch_related(
|
||||
Prefetch("findings", queryset=Finding.objects.all()),
|
||||
Prefetch("structures", queryset=Structure.objects.all()),
|
||||
Prefetch("conditions", queryset=Condition.objects.all()),
|
||||
)),
|
||||
)
|
||||
.get(pk=pk)
|
||||
)
|
||||
case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()]
|
||||
case = get_case_for_case_detail(pk)
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
|
||||
return render(
|
||||
@@ -633,9 +643,46 @@ def index(request):
|
||||
|
||||
|
||||
def user_collections(request):
|
||||
collections = request.user.user_casecollection_exams.all()
|
||||
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
|
||||
available_collections = request.user.user_casecollection_exams.all()
|
||||
# Collections the user has already taken (recorded in generic.CidUserExam)
|
||||
in_progress_ids = []
|
||||
finished_ids = []
|
||||
try:
|
||||
ct = ContentType.objects.get_for_model(CaseCollection)
|
||||
taken_exams = CidUserExam.objects.filter(user_user=request.user, content_type=ct)
|
||||
in_progress_ids = list(
|
||||
taken_exams.filter(completed=False).values_list("object_id", flat=True).distinct()
|
||||
)
|
||||
finished_ids = list(
|
||||
taken_exams.filter(completed=True).values_list("object_id", flat=True).distinct()
|
||||
)
|
||||
except Exception:
|
||||
# If anything goes wrong determining taken/finished exams, treat as none
|
||||
in_progress_ids = []
|
||||
finished_ids = []
|
||||
|
||||
return render(request, "atlas/user_collections.html", {"collections": collections})
|
||||
# Build querysets for the three groups
|
||||
taken_all_ids = set(in_progress_ids) | set(finished_ids)
|
||||
|
||||
# Available to start: explicitly allowed collections that the user hasn't taken at all
|
||||
available_qs = available_collections.exclude(pk__in=taken_all_ids).order_by("name")
|
||||
|
||||
# In-progress (taken but not completed)
|
||||
in_progress_qs = CaseCollection.objects.filter(pk__in=list(in_progress_ids)).order_by("name")
|
||||
|
||||
# Finished (completed)
|
||||
finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/user_collections.html",
|
||||
{
|
||||
"available_collections": available_qs,
|
||||
"in_progress_collections": in_progress_qs,
|
||||
"finished_collections": finished_qs,
|
||||
},
|
||||
)
|
||||
|
||||
@login_required
|
||||
def collection_options(request):
|
||||
@@ -2056,9 +2103,11 @@ def collection_history(request, exam_id: int):
|
||||
"sort_order"
|
||||
)
|
||||
|
||||
userexams = collection.get_cid_user_exams()
|
||||
userexams = collection.get_user_exams()
|
||||
cidexams = collection.get_cid_exams()
|
||||
|
||||
print(userexams)
|
||||
print("useruserexams:", userexams)
|
||||
print("cidexams:", cidexams)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -2068,6 +2117,7 @@ def collection_history(request, exam_id: int):
|
||||
"casesdetails": casedetails,
|
||||
"can_edit": True,
|
||||
"userexams": userexams,
|
||||
"cidexams": cidexams,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2101,6 +2151,32 @@ def collection_history_user(request, exam_id: int, user_pk: int):
|
||||
)
|
||||
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_history_ciduser(request, exam_id: int, cid: int):
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
|
||||
casedetails = CaseDetail.objects.filter(
|
||||
collection=collection,
|
||||
).order_by("sort_order")
|
||||
|
||||
print(casedetails)
|
||||
|
||||
user_answers = [
|
||||
(casedetail, casedetail.get_cid_answers(cid)) for casedetail in casedetails
|
||||
]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/collection_history_user.html",
|
||||
{
|
||||
"collection": collection,
|
||||
"user": cid,
|
||||
"casedetails": casedetails,
|
||||
"user_answers": user_answers,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def collection_take_start(request, pk, cid=None, passcode=None):
|
||||
@@ -2113,22 +2189,29 @@ def collection_take_start(request, pk, cid=None, passcode=None):
|
||||
Returns:
|
||||
_type_: _description_
|
||||
"""
|
||||
print(f"Collection take start: {pk}, cid: {cid}, passcode: {passcode}")
|
||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||
print(f"Collection: {collection}")
|
||||
|
||||
try:
|
||||
try:
|
||||
collection.check_user_can_take(cid, passcode, request.user)
|
||||
valid_user = True
|
||||
except Http404:
|
||||
valid_user = False
|
||||
#valid_user = collection.check_logged_in_user(request)
|
||||
except PrerequisiteRequired as e:
|
||||
# Show a friendly page linking to the required collection rather than 404
|
||||
prereq = getattr(e, 'prereq', None)
|
||||
return render(
|
||||
request,
|
||||
'atlas/prerequisite_required.html',
|
||||
{'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode},
|
||||
)
|
||||
|
||||
|
||||
#valid_user = collection.check_logged_in_user(request)
|
||||
|
||||
|
||||
cid_exam = None
|
||||
if collection.collection_type == "REV" or valid_user:
|
||||
cid_exam = collection.get_cid_user_exams(user_user=request.user).first()
|
||||
cid_exam = collection.get_cid_and_user_exams(user_user=request.user).first()
|
||||
|
||||
template_variables = {
|
||||
"collection": collection,
|
||||
@@ -2159,16 +2242,16 @@ def collection_case_priors(request, exam_id, case_id):
|
||||
p.delete()
|
||||
return HttpResponse(f"Case removed")
|
||||
elif "prior_case_id" in request.POST:
|
||||
if not request.POST["relation"]:
|
||||
return HttpResponse(
|
||||
"You need to enter text to describe the relationship between the cases"
|
||||
)
|
||||
prior_case = Case.objects.get(pk=request.POST["prior_case_id"])
|
||||
p, created = CasePrior.objects.get_or_create(
|
||||
case_detail=case_detail, prior_case=prior_case
|
||||
)
|
||||
p.relation_text = request.POST["relation"]
|
||||
|
||||
if not p.relation_text:
|
||||
return HttpResponse(
|
||||
"You need to enter text to describe the relationship between the cases"
|
||||
)
|
||||
p.prior_visibility = request.POST["prior_visibility"]
|
||||
|
||||
p.save()
|
||||
@@ -2227,7 +2310,7 @@ def collection_case_priors(request, exam_id, case_id):
|
||||
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_case_details(request, exam_id, case_id):
|
||||
def collection_case_questions(request, exam_id, case_id):
|
||||
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
|
||||
|
||||
collection = case_detail.collection
|
||||
@@ -2244,10 +2327,10 @@ def collection_case_details(request, exam_id, case_id):
|
||||
example_form = JsonAnswerForm(
|
||||
request.POST, question_schema=case_detail.question_schema
|
||||
)
|
||||
form = CaseDetailForm(instance=case_detail)
|
||||
form = CaseQuestionForm(instance=case_detail)
|
||||
# Called if the user saves the main form
|
||||
elif request.POST.get("submit") == "save":
|
||||
form = CaseDetailForm(request.POST, instance=case_detail)
|
||||
form = CaseQuestionForm(request.POST, instance=case_detail)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
# Add any additional logic or redirection here
|
||||
@@ -2277,11 +2360,11 @@ def collection_case_details(request, exam_id, case_id):
|
||||
post_data, question_schema=case_detail.question_schema
|
||||
)
|
||||
|
||||
form = CaseDetailForm(instance=case_detail)
|
||||
form = CaseQuestionForm(instance=case_detail)
|
||||
# This shouldn't happen
|
||||
else:
|
||||
assert False
|
||||
form = CaseDetailForm(request.POST, instance=case_detail)
|
||||
form = CaseQuestionForm(request.POST, instance=case_detail)
|
||||
|
||||
else:
|
||||
post_data = request.POST.copy()
|
||||
@@ -2290,7 +2373,7 @@ def collection_case_details(request, exam_id, case_id):
|
||||
post_data, question_schema=case_detail.question_schema
|
||||
)
|
||||
|
||||
form = CaseDetailForm(instance=case_detail)
|
||||
form = CaseQuestionForm(instance=case_detail)
|
||||
pass
|
||||
# post_data = None
|
||||
|
||||
@@ -2313,7 +2396,7 @@ def collection_case_details(request, exam_id, case_id):
|
||||
# case_detail.question_answers = answers
|
||||
# example_form = JsonAnswerForm(post_data, question_schema=case_detail.question_schema)
|
||||
|
||||
# form = CaseDetailForm(instance=case_detail)
|
||||
# form = CaseQuestionForm(instance=case_detail)
|
||||
|
||||
# blank_form = JsonAnswerForm(question_schema=case_detail.question_schema)
|
||||
case_number, case_count = collection.get_index_of_case(
|
||||
@@ -2325,7 +2408,7 @@ def collection_case_details(request, exam_id, case_id):
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/collection_case_detail.html",
|
||||
"atlas/collection_case_questions.html",
|
||||
{
|
||||
"case_detail": case_detail,
|
||||
"form": form,
|
||||
@@ -2339,6 +2422,47 @@ def collection_case_details(request, exam_id, case_id):
|
||||
},
|
||||
)
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_case_details(request, exam_id, case_id):
|
||||
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
|
||||
|
||||
collection = case_detail.collection
|
||||
|
||||
if request.method == "POST":
|
||||
form = CaseDetailForm(request.POST, instance=case_detail)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
if request.htmx:
|
||||
return HttpResponse("Saved")
|
||||
return redirect(request.path)
|
||||
else:
|
||||
if request.htmx:
|
||||
return HttpResponse("Invalid form", status=400)
|
||||
else:
|
||||
form = CaseDetailForm(instance=case_detail)
|
||||
|
||||
case_number, case_count = collection.get_index_of_case(
|
||||
case_detail.case, case_count=True
|
||||
)
|
||||
|
||||
previous = collection.get_previous_case(case_detail.case)
|
||||
next = collection.get_next_case(case_detail.case)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"atlas/collection_case_details.html",
|
||||
{
|
||||
"case_detail": case_detail,
|
||||
"form": form,
|
||||
"collection": collection,
|
||||
"case": case_detail.case,
|
||||
"previous": previous,
|
||||
"next": next,
|
||||
"collection_length": case_count,
|
||||
"case_number": case_number,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_mark_overview(request, pk):
|
||||
@@ -2496,7 +2620,11 @@ def collection_take_overview(
|
||||
"""
|
||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||
|
||||
collection.check_user_can_take(cid, passcode, request.user)
|
||||
try:
|
||||
collection.check_user_can_take(cid, passcode, request.user)
|
||||
except PrerequisiteRequired as e:
|
||||
prereq = getattr(e, 'prereq', None)
|
||||
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
|
||||
|
||||
cid_user_exam = collection.get_or_create_cid_user_exam(
|
||||
cid=cid, user_user=request.user
|
||||
@@ -2569,7 +2697,11 @@ def collection_case_view_take_answers(
|
||||
""" """
|
||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||
|
||||
collection.check_user_can_take(cid, passcode, request.user)
|
||||
try:
|
||||
collection.check_user_can_take(cid, passcode, request.user)
|
||||
except PrerequisiteRequired as e:
|
||||
prereq = getattr(e, 'prereq', None)
|
||||
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
|
||||
|
||||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||||
|
||||
@@ -2618,7 +2750,11 @@ def collection_case_view_take(
|
||||
form = None
|
||||
answer: None | CidReportAnswer | UserReportAnswer = None
|
||||
|
||||
collection.check_user_can_take(cid, passcode, request.user)
|
||||
try:
|
||||
collection.check_user_can_take(cid, passcode, request.user)
|
||||
except PrerequisiteRequired as e:
|
||||
prereq = getattr(e, 'prereq', None)
|
||||
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode})
|
||||
|
||||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||||
|
||||
@@ -2651,6 +2787,29 @@ def collection_case_view_take(
|
||||
).first()
|
||||
ReportAnswerForm = UserQuestionAnswerForm
|
||||
|
||||
# If we have an answer object but it doesn't have a started_at, set it now.
|
||||
if answer and getattr(answer, 'started_at', None) is None:
|
||||
try:
|
||||
answer.started_at = timezone.now()
|
||||
answer.save()
|
||||
except Exception:
|
||||
# Non-fatal: if saving fails, continue without blocking the user
|
||||
logger.exception("Failed to set started_at on answer", exc_info=True)
|
||||
|
||||
# If no answer exists yet, create a placeholder so we have a started_at timestamp
|
||||
if not answer and collection.collection_type in ("REP", "QUE"):
|
||||
try:
|
||||
if cid is not None:
|
||||
answer = CidReportAnswer(question=case_detail, cid=cid)
|
||||
else:
|
||||
answer = UserReportAnswer(question=case_detail, user=request.user)
|
||||
answer.started_at = timezone.now()
|
||||
answer.save()
|
||||
except Exception:
|
||||
# If creation fails, leave answer as None; the normal flow will handle form creation
|
||||
answer = None
|
||||
logger.exception("Failed to create initial answer object", exc_info=True)
|
||||
|
||||
if request.method == "POST":
|
||||
if collection.collection_type in ("REP", "QUE"):
|
||||
if not collection.publish_results:
|
||||
@@ -2668,6 +2827,8 @@ def collection_case_view_take(
|
||||
answer = form.save(commit=False)
|
||||
answer.set_cid_or_user(cid=cid, user=request.user)
|
||||
answer.question = case_detail
|
||||
# Record submission timestamp
|
||||
answer.submitted_at = timezone.now()
|
||||
# answer.published_date = timezone.now()
|
||||
|
||||
if "complete_case" in request.POST:
|
||||
@@ -2675,11 +2836,44 @@ def collection_case_view_take(
|
||||
raise Http404("Self review not enabled")
|
||||
answer.completed = True
|
||||
|
||||
# If this was a timed-out submission, mark as completed
|
||||
if request.POST.get('timed_out') == '1':
|
||||
answer.completed = True
|
||||
## Also mark the overall exam as completed for this user/CID
|
||||
#try:
|
||||
# cid_user_exam.completed = True
|
||||
# cid_user_exam.save()
|
||||
# # Record an exam status entry
|
||||
# collection.exam_user_status.create(
|
||||
# cid_user_exam=cid_user_exam,
|
||||
# status="submitted",
|
||||
# extra="autosubmit",
|
||||
# )
|
||||
#except Exception:
|
||||
# logger.exception("Failed to mark cid_user_exam completed on autosubmit")
|
||||
|
||||
answer.save()
|
||||
|
||||
cid_user_exam.end_time = timezone.now()
|
||||
cid_user_exam.save()
|
||||
|
||||
# If this was an AJAX/HTMX request, return JSON so the client
|
||||
# can remain on the same page and update the UI without redirect.
|
||||
# Detect HTMX/XHR requests robustly. HTMX sets the HX-Request header.
|
||||
is_ajax = (
|
||||
request.headers.get('HX-Request', '').lower() == 'true'
|
||||
or request.headers.get('x-requested-with') == 'XMLHttpRequest'
|
||||
or getattr(request, 'htmx', False)
|
||||
)
|
||||
if is_ajax:
|
||||
return JsonResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'locked': answer.completed,
|
||||
'submitted_at': answer.submitted_at.isoformat() if getattr(answer, 'submitted_at', None) else None,
|
||||
}
|
||||
)
|
||||
|
||||
if cid is not None:
|
||||
kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
|
||||
redirect_url = "atlas:collection_case_view_take"
|
||||
@@ -2768,6 +2962,7 @@ def collection_case_view_take(
|
||||
"form": form,
|
||||
"collection": collection,
|
||||
"case": case,
|
||||
"case_detail": case_detail,
|
||||
"series_list": series_list,
|
||||
"series_to_load": series_to_load,
|
||||
"case_number": case_number,
|
||||
@@ -2785,6 +2980,7 @@ def collection_case_view_take(
|
||||
"cid_user_exam": cid_user_exam,
|
||||
"question_completed": question_completed,
|
||||
"self_review": self_review,
|
||||
"answer_started_at_iso": answer.started_at.isoformat() if answer and getattr(answer, 'started_at', None) else None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2811,6 +3007,10 @@ def collection_case_view(request, pk, case_number):
|
||||
|
||||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||||
|
||||
case = get_case_for_case_detail(case.pk)
|
||||
|
||||
casedetail = CaseDetail.objects.get(case=case, collection=collection)
|
||||
|
||||
series_list = case.series.all().prefetch_related("images", "examination", "plane")
|
||||
|
||||
previous = case_number > 0
|
||||
@@ -2824,6 +3024,7 @@ def collection_case_view(request, pk, case_number):
|
||||
"collection": collection,
|
||||
"collection_length": case_count,
|
||||
"case": case,
|
||||
"casedetail": casedetail,
|
||||
"series_list": series_list,
|
||||
"case_number": case_number,
|
||||
"previous": previous,
|
||||
@@ -3360,6 +3561,95 @@ def collection_question_schemas(request, exam_id: int):
|
||||
)
|
||||
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_sync_prerequisite_users(request, pk: int):
|
||||
"""Copy valid cid_users and user_users from all prerequisite collections into this collection.
|
||||
|
||||
This endpoint is intended to be called via HTMX from the collection detail page.
|
||||
"""
|
||||
if not request.htmx:
|
||||
raise Http404("Invalid request")
|
||||
|
||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||
|
||||
# Collect the closure of collections: the target collection and all prerequisites (recursively)
|
||||
to_visit = [collection]
|
||||
visited = {}
|
||||
while to_visit:
|
||||
col = to_visit.pop()
|
||||
if col.pk in visited:
|
||||
continue
|
||||
visited[col.pk] = col
|
||||
for p in col.prerequisites.all():
|
||||
if p.pk not in visited:
|
||||
to_visit.append(p)
|
||||
|
||||
collections = list(visited.values())
|
||||
|
||||
# Compute the union of all cid users and user users across the set
|
||||
union_cid_users = set()
|
||||
union_user_users = set()
|
||||
union_cid_groups = set()
|
||||
union_user_groups = set()
|
||||
for col in collections:
|
||||
union_cid_users.update(list(col.valid_cid_users.all()))
|
||||
union_user_users.update(list(col.valid_user_users.all()))
|
||||
union_cid_groups.update(list(col.cid_user_groups.all()))
|
||||
union_user_groups.update(list(col.user_user_groups.all()))
|
||||
|
||||
# Apply the union to each collection and count additions
|
||||
total_added_cid = 0
|
||||
total_added_user = 0
|
||||
total_added_cid_groups = 0
|
||||
total_added_user_groups = 0
|
||||
per_collection_added = []
|
||||
for col in collections:
|
||||
added_cid = 0
|
||||
added_user = 0
|
||||
added_cid_groups = 0
|
||||
added_user_groups = 0
|
||||
for cid_user in union_cid_users:
|
||||
if not col.valid_cid_users.filter(pk=cid_user.pk).exists():
|
||||
col.valid_cid_users.add(cid_user)
|
||||
added_cid += 1
|
||||
for user in union_user_users:
|
||||
if not col.valid_user_users.filter(pk=user.pk).exists():
|
||||
col.valid_user_users.add(user)
|
||||
added_user += 1
|
||||
# Sync groups as well
|
||||
for gid in union_cid_groups:
|
||||
if not col.cid_user_groups.filter(pk=gid.pk).exists():
|
||||
col.cid_user_groups.add(gid)
|
||||
added_cid_groups += 1
|
||||
for ug in union_user_groups:
|
||||
if not col.user_user_groups.filter(pk=ug.pk).exists():
|
||||
col.user_user_groups.add(ug)
|
||||
added_user_groups += 1
|
||||
added_user += added_user_groups
|
||||
added_cid += added_cid_groups
|
||||
if added_cid or added_user:
|
||||
col.save()
|
||||
total_added_cid += added_cid
|
||||
total_added_user += added_user
|
||||
total_added_cid_groups += added_cid_groups
|
||||
total_added_user_groups += added_user_groups
|
||||
per_collection_added.append((col.pk, added_cid, added_user, added_cid_groups, added_user_groups))
|
||||
|
||||
html = render_to_string(
|
||||
"atlas/partials/sync_prereq_result.html",
|
||||
{
|
||||
"total_added_cid": total_added_cid,
|
||||
"total_added_user": total_added_user,
|
||||
"total_added_cid_groups": total_added_cid_groups,
|
||||
"total_added_user_groups": total_added_user_groups,
|
||||
"per_collection_added": per_collection_added,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
return HttpResponse(html)
|
||||
|
||||
|
||||
def collection_reset_answers_user_list(request, exam_id: int):
|
||||
if request.htmx:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
@@ -3378,6 +3668,7 @@ def collection_reset_answers_user_list(request, exam_id: int):
|
||||
raise Http404("Invalid request")
|
||||
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_reset_answers_user(request, exam_id: int, user_id: int):
|
||||
if request.htmx:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
@@ -3398,6 +3689,27 @@ def collection_reset_answers_user(request, exam_id: int, user_id: int):
|
||||
else:
|
||||
raise Http404("Invalid request")
|
||||
|
||||
@user_is_collection_author_or_atlas_editor
|
||||
def collection_reset_answers_ciduser(request, exam_id: int, cid: int):
|
||||
if request.htmx:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
|
||||
# Select all case details (answers are linked to these)
|
||||
case_details = collection.casedetail_set.all().prefetch_related()
|
||||
|
||||
# Delete all answers
|
||||
for case in case_details:
|
||||
cid_answers = case.cidreportanswer_set.filter(cid=cid)
|
||||
cid_answers.delete()
|
||||
|
||||
collection.cid_users.filter(cid=cid).delete()
|
||||
collection.exam_user_status.filter(cid_user_exam__cid__pk=cid).delete()
|
||||
|
||||
return HttpResponse(f"CID [{cid}] answers deleted")
|
||||
|
||||
else:
|
||||
raise Http404("Invalid request")
|
||||
|
||||
|
||||
def collection_reset_answers(request, exam_id: int):
|
||||
if request.htmx:
|
||||
|
||||
@@ -63,6 +63,10 @@ class AuthorMixin():
|
||||
"""Remove an author from the object"""
|
||||
self.author.remove(user)
|
||||
|
||||
def is_author(self, user: User) -> bool:
|
||||
"""Returns True if the user is an author of the object"""
|
||||
return self.author.filter(id=user.id).exists()
|
||||
|
||||
|
||||
class UserConfigurablePaginationMixin:
|
||||
default_per_page = 25
|
||||
|
||||
+34
-1
@@ -768,6 +768,9 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
Raises:
|
||||
Http404: If user does not have access
|
||||
"""
|
||||
if user is not None and self.is_author(user):
|
||||
return
|
||||
|
||||
# If we are limiting by dates check that the current date is within the range
|
||||
if self.restrict_to_dates:
|
||||
if self.start_date >= timezone.now() or (
|
||||
@@ -902,9 +905,10 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
def get_question_user_user_answer(self, question_index, user):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_cid_user_exams(
|
||||
def get_cid_and_user_exams(
|
||||
self, cid_user: Optional["CidUser"] = None, user_user: User | None = None
|
||||
) -> "CidUserExam":
|
||||
"""Returns a queryset of CidUserExam for this exam/collection"""
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
if cid_user is None and user_user is None:
|
||||
return CidUserExam.objects.filter(
|
||||
@@ -921,6 +925,35 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
content_type=content_type, object_id=self.pk, user_user=user_user
|
||||
)
|
||||
|
||||
def get_cid_exams(self, cid_user: Optional["CidUser"]= None) -> "CidUserExam":
|
||||
"""Returns a queryset of CidUserExam for this exam/collection"""
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
|
||||
if cid_user is None:
|
||||
# Only return pure CID entries (cid_user set, user_user null)
|
||||
return CidUserExam.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=self.pk,
|
||||
cid_user__isnull=False,
|
||||
user_user__isnull=True,
|
||||
)
|
||||
return CidUserExam.objects.filter(
|
||||
content_type=content_type, object_id=self.pk, cid_user=cid_user
|
||||
)
|
||||
|
||||
def get_user_exams(self, user_user: Optional[User] = None) -> "CidUserExam":
|
||||
"""Returns a queryset of CidUserExam for this exam/collection"""
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
if user_user is None:
|
||||
return CidUserExam.objects.filter(
|
||||
content_type=content_type, object_id=self.pk, user_user__isnull=False, cid_user__isnull=True
|
||||
)
|
||||
return CidUserExam.objects.filter(
|
||||
content_type=content_type, object_id=self.pk, user_user=user_user
|
||||
)
|
||||
|
||||
|
||||
|
||||
def clone_model(self):
|
||||
M2M_fields = (
|
||||
"exam_questions",
|
||||
|
||||
+157
-157
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