Files
penracourses/atlas/models.py
T

3038 lines
104 KiB
Python

from functools import lru_cache
import json
import os
import pathlib
from typing import Tuple
import uuid
from django.http import Http404, HttpRequest
from generic.mixins import AuthorMixin, QuestionMixin
from rad.settings import REMOTE_URL
from django.db.models.fields.files import ImageField
from django.db.models.fields.related import ForeignKey
from django.db import models
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.core.cache import cache
import pydicom
import pydicom.multival
from pydicom.errors import InvalidDicomError
from django.core.files.storage import FileSystemStorage
from django.conf import settings
from django.utils.html import format_html, mark_safe
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.utils.html import mark_safe
from django.core.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
import string
from collections import defaultdict
from helpers.images import (
get_image_dicom_hash,
get_image_hash,
image_as_base64,
pretty_print_dicom,
)
import mimetypes
from generic.models import (
CidUser,
CidUserExam,
CidUserGroup,
CimarCase,
ExamOrCollectionGenericBase,
ExamUserStatus,
Examination,
# Condition,
ExamBase,
FindingBase,
Plane,
Contrast,
QuestionNote,
SeriesBase,
SeriesImageBase,
Modality,
Site,
UserUserGroup,
)
# from generic.models import Examination, Site, Condition, Sign
import pydicom.errors
import datetime
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
from loguru import logger
image_storage = FileSystemStorage(
# Physical file location ROOT
location="{0}atlas/".format(settings.MEDIA_ROOT),
# Url for file
base_url="{0}atlas/".format(settings.MEDIA_URL),
)
def image_directory_path(instance, filename):
return "atlas/picture/{0}".format(filename)
def uncategorised_dicom_directory_path(instance, filename):
return "atlas/dicom/{0}".format(filename)
def findMiddle(input_list):
middle = float(len(input_list)) / 2
if middle % 2 != 0:
return input_list[int(middle - 0.5)]
else:
return input_list[int(middle)]
return (input_list[int(middle)], input_list[int(middle - 1)])
def _normalise_group_rule_value(value):
if value is None:
return ""
if isinstance(value, pydicom.multival.MultiValue):
value = list(value)
if isinstance(value, (list, tuple)):
return ",".join([str(v).strip() for v in value if str(v).strip()])
text = str(value).strip()
if text == "":
return ""
try:
numeric = float(text)
if abs(numeric - round(numeric)) < 1e-6:
return str(int(round(numeric)))
return f"{numeric:.1f}"
except (TypeError, ValueError):
return text
def _series_declared_groups_cache_key(series_id):
return f"atlas:series_declared_groups:{series_id}"
def invalidate_series_declared_groups_cache(series_id):
if not series_id:
return
try:
cache.delete(_series_declared_groups_cache_key(series_id))
except Exception:
pass
def _build_declared_series_groups(series, series_images_with_urls):
cache_key = _series_declared_groups_cache_key(series.pk)
signature = {
"series_modified": int(series.modified_date.timestamp()) if getattr(series, "modified_date", None) else 0,
"image_count": len(series_images_with_urls),
"images": [
{
"pk": getattr(image_obj, "pk", None),
"position": getattr(image_obj, "position", None),
"url": image_url,
}
for image_obj, image_url in series_images_with_urls
],
}
try:
cached = cache.get(cache_key)
except Exception:
cached = None
if isinstance(cached, dict):
if cached.get("signature") == signature:
return cached.get("groups", [])
rules = [
"dwiBValue",
"dwiDirection",
"temporalPosition",
"triggerTime",
#"contentTime",
"echoTime",
"echoNumber",
]
rows = []
for image_obj, image_url in series_images_with_urls:
ds = image_obj.get_dicom_data()
def get_value(*keys):
for key in keys:
if key in ds:
normalized = _normalise_group_rule_value(ds[key].value)
if normalized != "":
return normalized
return ""
def get_private_b_value():
# Siemens private (0019,100C)
try:
elem = ds.get((0x0019, 0x100C))
if elem is not None:
normalized = _normalise_group_rule_value(getattr(elem, "value", elem))
if normalized != "":
return normalized
except Exception:
pass
# GE private (0043,1039) can be "b\something" format
try:
elem = ds.get((0x0043, 0x1039))
if elem is not None:
raw = getattr(elem, "value", elem)
if isinstance(raw, pydicom.multival.MultiValue):
raw = raw[0] if len(raw) > 0 else ""
text = str(raw).split("\\")[0].strip()
normalized = _normalise_group_rule_value(text)
if normalized != "":
return normalized
except Exception:
pass
return ""
rule_values = {
"dwiBValue": get_value("DiffusionBValue", "DiffusionbValue") or get_private_b_value(),
"dwiDirection": get_value("DiffusionGradientDirection", "DiffusionGradientOrientation"),
"temporalPosition": get_value("TemporalPositionIdentifier"),
"triggerTime": get_value("TriggerTime"),
"contentTime": get_value("ContentTime", "AcquisitionTime"),
"echoTime": get_value("EchoTime"),
"echoNumber": get_value("EchoNumbers", "EchoNumber"),
}
b_value = None
try:
if rule_values["dwiBValue"] != "":
b_value = float(rule_values["dwiBValue"])
except (TypeError, ValueError):
b_value = None
rows.append(
{
"imageId": image_url,
"ruleValues": rule_values,
"bValue": b_value,
}
)
if len(rows) == 0:
return []
active_rules = []
for rule_id in rules:
values = [row["ruleValues"][rule_id] for row in rows if row["ruleValues"][rule_id] != ""]
distinct = set(values)
coverage = len(values) / len(rows)
if len(distinct) > 1 and len(distinct) < max(2, len(rows)) and coverage >= 0.7:
active_rules.append(rule_id)
if len(active_rules) == 0:
result = [
{
"key": f"SERVER_SERIES_{series.pk}_0",
"label": "Series 1",
"imageIds": [row["imageId"] for row in rows],
"seriesInstanceUID": series.series_instance_uid,
"bValue": None,
"groupValues": {},
}
]
try:
cache.set(cache_key, {"signature": signature, "groups": result}, timeout=60 * 60)
except Exception:
pass
return result
grouped = {}
for row in rows:
key_parts = []
for rule_id in active_rules:
value = row["ruleValues"][rule_id]
key_parts.append(f"{rule_id}:{value}" if value != "" else f"{rule_id}:")
group_key = "|".join(key_parts)
if group_key not in grouped:
grouped[group_key] = {
"imageIds": [],
"groupValues": {rule_id: row["ruleValues"][rule_id] for rule_id in active_rules if row["ruleValues"][rule_id] != ""},
"bValue": row["bValue"],
}
grouped[group_key]["imageIds"].append(row["imageId"])
if grouped[group_key]["bValue"] is None and row["bValue"] is not None:
grouped[group_key]["bValue"] = row["bValue"]
ordered_groups = []
for idx, (_, payload) in enumerate(grouped.items()):
details = []
group_values = payload.get("groupValues", {})
if group_values.get("dwiBValue"):
details.append(f"b={group_values['dwiBValue']}")
if group_values.get("dwiDirection"):
details.append(f"dir={group_values['dwiDirection']}")
if group_values.get("temporalPosition"):
details.append(f"tp={group_values['temporalPosition']}")
if group_values.get("triggerTime"):
details.append(f"tr={group_values['triggerTime']}")
if group_values.get("contentTime"):
details.append(f"t={group_values['contentTime']}")
if group_values.get("echoTime"):
details.append(f"TE={group_values['echoTime']}")
if group_values.get("echoNumber"):
details.append(f"echo={group_values['echoNumber']}")
label = "".join(details) if len(details) > 0 else f"Series {idx + 1}"
ordered_groups.append(
{
"key": f"SERVER_SERIES_{series.pk}_{idx}",
"label": label,
"imageIds": payload["imageIds"],
"seriesInstanceUID": series.series_instance_uid,
"bValue": payload.get("bValue"),
"groupValues": group_values,
}
)
try:
cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
except Exception:
pass
return ordered_groups
class SynMixin(object):
# class Meta:
# abstract = True
def __str__(self) -> str:
# Determine whether this record should be displayed as the
# canonical/primary entry. Prefer the new canonical FK pattern
# (`canonical is None` means this object is the master). Fallback
# to legacy `primary` boolean when present.
# If the model supports canonical FK, consider canonical==None as
# the primary/master entry. For models without canonical (legacy
# edge-cases), treat the instance as primary by default.
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return self.name
return f"{self.name} [syn]"
except Exception:
# defensive fallback
return f"{self.name} [syn]"
# No canonical support: assume primary
return self.name
def get_old_str(self) -> str:
# Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
try:
syns = self.get_synonyms()
except Exception:
syns = None
# If the model supports canonical, only canonical==None entries
# should be rendered as the primary name. For models without
# canonical, treat instance as primary.
if hasattr(self, "canonical"):
try:
is_primary = self.canonical is None or self.canonical == self
except Exception:
is_primary = False
else:
is_primary = True
if is_primary:
if syns is None:
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None or (hasattr(synonyms_field, "count") and synonyms_field.count() == 0):
return self.name
synonyms = ",".join([i.name for i in synonyms_field.all()])
return f"{self.name} ({synonyms})"
else:
if not syns:
return self.name
synonyms = ",".join([i.name for i in syns])
return f"{self.name} ({synonyms})"
return f"{self.name} [syn]"
def get_synonym(self):
# Return a readable synonym string. For models that implement
# get_synonyms (Condition with canonical/aliases), use that.
try:
syns = self.get_synonyms()
except Exception:
syns = None
# If canonical is present and this instance is the master, mark Primary
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return "[Primary]"
except Exception:
pass
if syns is None:
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None:
return ""
# If the old M2M exists, prefer those marked primary if such a
# attribute exists on the related objects.
try:
# Select all synonym names; legacy `primary` flag removed.
s = synonyms_field.values_list("name", flat=True)
except Exception:
s = []
return ", ".join(s)
# prefer primary names among synonyms if present
primary_names = []
for i in syns:
# Prefer items that are canonical/master in the group
if getattr(i, "canonical", None) is None:
primary_names.append(i.name)
if primary_names:
return ", ".join(primary_names)
return ", ".join([i.name for i in syns])
def get_synonym_link(self):
try:
syns = self.get_synonyms()
except Exception:
syns = None
# Prefer canonical/master check
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return "[Primary]"
except Exception:
pass
if syns is None:
# fall back to all M2M synonyms (not just primary) for models
# that still use the old synonym field (e.g. Finding, Structure)
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None:
return ""
syns_qs = synonyms_field.all()
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
return ", ".join(items)
# render links for synonyms/canonical group
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
return ", ".join(items)
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class APIToken(models.Model):
"""Personal Access Token for API access.
Stores only a hash of the token. The raw token is returned once at
creation and never stored in plaintext.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens")
name = models.CharField(max_length=200, blank=True, help_text="Friendly name for this token")
token_hash = models.CharField(max_length=64, db_index=True)
scopes = models.CharField(max_length=255, blank=True, help_text="Space-separated scopes")
created = models.DateTimeField(auto_now_add=True)
expires = models.DateTimeField(null=True, blank=True)
revoked = models.BooleanField(default=False)
last_used = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "API token"
verbose_name_plural = "API tokens"
def __str__(self):
return f"APIToken(user={self.user}, name={self.name})"
@classmethod
def create_token(cls, user, name="", scopes="", expires=None):
import secrets, hashlib
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
obj = cls.objects.create(user=user, name=name, token_hash=token_hash, scopes=scopes, expires=expires)
return token, obj
def mark_used(self):
from django.utils import timezone
self.last_used = timezone.now()
self.save(update_fields=["last_used"])
class Finding(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
# New canonical/alias field: if set, this Finding is an alias and points
# to the canonical/master Finding. We remove the old M2M `synonym` and
# `primary` boolean in favour of this single canonical FK.
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Finding is an alias and points to the canonical Finding.",
)
def get_absolute_url(self):
return reverse("atlas:finding_detail", kwargs={"pk": self.pk})
@property
def canonical_finding(self):
return self.canonical if self.canonical else self
def get_synonyms(self):
"""Return other Findings that are aliases/synonyms for this concept.
Behaviour mirrors Condition.get_synonyms: if this Finding is an alias
(has canonical set) return the canonical and other aliases (excluding
self). If this Finding is canonical, return all aliases (excluding
self).
"""
master = self.canonical_finding
qs = Finding.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
class Condition(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
# New canonical/alias field (Option B): if set, this Condition is an alias
# and points to the canonical/master Condition.
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Condition is an alias and points to the canonical Condition.",
)
parent = models.ManyToManyField(
"self",
blank=True,
related_name="child",
symmetrical=False,
through="ConditionRelationship",
help_text="Use if the condition could be considered a subset of another, e.g. 'Alzheimer disease' and 'Dementia'.",
)
# Legacy fields `synonym` (M2M) and `primary` (boolean) have been removed.
# Canonical/aliases are represented by the `canonical` ForeignKey and
# the reverse `aliases` related_name. Use `get_synonyms()` /
# `canonical_condition` to access canonical groups.
subspecialty = models.ManyToManyField(
"subspecialty",
blank=True,
help_text="Sets the subspecialty(/ies) that this condition falls under.",
)
rcr_curriculum_map = models.ManyToManyField(
"self",
blank=True,
limit_choices_to={"rcr_curriculum": True},
help_text="Specifies the related RCR curriculum condition (if it exists).",
)
rcr_curriculum = models.BooleanField(
default=False,
help_text="The condition is from the (non exhaustive) RCR curriculum",
)
def get_absolute_url(self):
return reverse("atlas:condition_detail", kwargs={"pk": self.pk})
@property
def canonical_condition(self):
"""Return the canonical/master condition for this Condition (self if none)."""
return self.canonical if self.canonical else self
def get_synonyms(self):
"""Return other Conditions that are synonyms/aliases for this concept.
Behaviour:
- If this Condition is an alias (canonical set), return the canonical and
any other aliases (excluding self).
- If this Condition is canonical, return all aliases (and exclude self).
"""
master = self.canonical_condition
qs = Condition.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
def get_children(self):
return self.child.all()
def get_descendents(self):
"""TODO"""
def get_parents(self):
return self.parent.all()
def get_with_subspecialty(self):
return f"{', '.join([str(i) for i in self.subspecialty.all()])} / {self.name} "
class ConditionRelationship(models.Model):
child = models.ForeignKey(Condition, on_delete=models.CASCADE)
parent = models.ForeignKey(Condition, on_delete=models.CASCADE, related_name="+")
relationship_type = models.CharField(max_length=255, blank=True, null=True)
def __str__(self):
return f"{self.parent} -> {self.child}"
class Presentation(models.Model):
name = models.CharField(max_length=255)
subspecialty = models.ManyToManyField("subspecialty", blank=True)
def __str__(self) -> str:
if self.subspecialty:
return (
f"{self.name} ({', '.join([str(i) for i in self.subspecialty.all()])})"
)
return self.name
def get_absolute_url(self):
return reverse("atlas:presentation_detail", kwargs={"pk": self.pk})
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class Subspecialty(models.Model):
name = models.CharField(max_length=255)
def __str__(self) -> str:
return self.name
def get_absolute_url(self):
return reverse("atlas:subspecialty_detail", kwargs={"pk": self.pk})
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class PathologicalProcess(models.Model):
name = models.CharField(max_length=255)
def __str__(self) -> str:
return self.name
def get_absolute_url(self):
return reverse("atlas:pathological_process_detail", kwargs={"pk": self.pk})
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class Procedure(SynMixin, models.Model):
"""A procedure or operation performed on the patient (attachable to Cases).
This mirrors the lightweight structure used by `Presentation` / `PathologicalProcess`.
"""
name = models.CharField(max_length=255, unique=True)
# Optional canonical/alias pointer for future synonym handling
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Procedure is an alias and points to the canonical Procedure.",
)
subspecialty = models.ManyToManyField("subspecialty", blank=True)
def __str__(self) -> str:
return self.name
def get_absolute_url(self):
return reverse("atlas:procedure_detail", kwargs={"pk": self.pk})
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class Differential(models.Model):
condition = models.ForeignKey(Condition, on_delete=models.CASCADE)
case = models.ForeignKey(
"Case", on_delete=models.CASCADE, related_name="differentialcase"
)
text = models.TextField(null=True, blank=True)
class Structure(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
# Migrate to canonical/aliases model like Condition and Finding
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Structure is an alias and points to the canonical Structure.",
)
def get_absolute_url(self):
return reverse("atlas:structure_detail", kwargs={"pk": self.pk})
@property
def canonical_structure(self):
return self.canonical if self.canonical else self
def get_synonyms(self):
master = self.canonical_structure
qs = Structure.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
@reversion.register
class Case(models.Model, AuthorMixin, QuestionMixin):
# class SubspecialtyChoices(models.TextChoices):
# BREAST = "BR", _("Breast")
# CARDIAC = "CA", _("Cardiac")
# GASTRO = "GI", _("Gastrointestinal and hepatobiliary")
# HEADNECK = "HN", _("Head and Neck")
# MSK = "MS", _("Musculoskeletal")
# NEURO = "NE", _("Neuroradiology")
# OBSGYN = "OG", _("Obstectric and Gynaecological")
# PAED = "PA", _("Paediatric")
# URO = "UR", _("Uroradiology")
# VASC = "VA", _("Vascular")
# HAEMONC = "HA", _("Haemotology and Oncology")
class CertaintyChoices(models.IntegerChoices):
NONE = 0
POSSIBLE = 1
LIKELY = 2
ALMOST_CERTAIN = 3
CERTAIN = 4
title = models.CharField(max_length=255, help_text="Title of the case", default="")
description = models.TextField(
blank=True,
help_text="Description of the case",
)
history = models.TextField(
null=True, blank=True, help_text="A (brief) summary of the relevant history"
)
discussion = models.TextField(null=True, blank=True)
report = models.TextField(
null=True, blank=True, help_text="A model (sample) report for the case."
)
# findings = models.TextField(null=True, blank=True)
# subspecialty = models.CharField(max_length=2, choices=SubspecialtyChoices.choices)
subspecialty = models.ManyToManyField(
Subspecialty,
blank=True,
help_text="The subspecialties the case is associated with. Multiple subspecialties can be selected.",
)
condition = models.ManyToManyField(
Condition, blank=True, help_text="The condition(s) the case demonstrates."
)
presentation = models.ManyToManyField(
Presentation,
blank=True,
help_text="The presentation(s) the case is associated with.",
)
# Procedures performed on the patient (e.g. operations, interventions)
procedures = models.ManyToManyField(
"Procedure",
blank=True,
help_text="Procedure(s) or operations the patient has undergone.",
)
pathological_process = models.ManyToManyField(PathologicalProcess, blank=True)
differential = models.ManyToManyField(
Condition, through=Differential, related_name="casedifferential",
help_text="The differential diagnosis for the case.",
)
diagnostic_certainty = models.IntegerField(
choices=CertaintyChoices.choices, default=CertaintyChoices.NONE,
help_text="The diagnostic certainty of the case.",
)
verified = models.BooleanField(default=False)
created_date = models.DateTimeField(default=timezone.now)
published_date = models.DateTimeField(blank=True, null=True)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
help_text="Author of the case",
related_name="atlas_authored_cases",
)
editor = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="atlas_edited_cases",
)
archive = models.BooleanField(
default=False,
help_text="Case has been archived, this will remain on the system but will not be shown",
)
open_access = models.BooleanField(
help_text="If a case should be freely available to browse", default=True
)
series = models.ManyToManyField(
"Series", through="SeriesDetail", related_name="case"
)
notes = GenericRelation(QuestionNote)
previous_case = models.OneToOneField(
"Case",
on_delete=models.SET_NULL,
null=True,
related_name="next_case",
blank=True,
help_text="If this case is related to another case on the system (e.g. follow up), link them here.",
)
resource = models.ManyToManyField("Resource", through="CaseResource")
total_images_series_size = models.BigIntegerField(null=True, blank=True)
cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
study_date = models.DateField(
null=True,
blank=True,
help_text="Optional manually overridden study date for linked case series.",
)
def get_series_info(self):
try:
has_next = hasattr(self, 'next_case') and self.next_case is not None
except Case.DoesNotExist:
has_next = False
if not self.previous_case_id and not has_next:
return {"in_series": False}
# Find the start of the chain (the first case)
first_case = self
visited = {self.pk}
while first_case.previous_case:
if first_case.previous_case.pk in visited:
break
first_case = first_case.previous_case
visited.add(first_case.pk)
# Traverse the chain from the first case to find positions and count
chain = []
current = first_case
visited_forward = set()
while current:
if current.pk in visited_forward:
break
chain.append(current)
visited_forward.add(current.pk)
try:
current = current.next_case if hasattr(current, 'next_case') else None
except Case.DoesNotExist:
current = None
total = len(chain)
try:
position = chain.index(self) + 1
except ValueError:
position = 1
return {
"in_series": True,
"position": position,
"total": total,
}
def get_ordered_series_details(self):
cached = getattr(self, "_ordered_series_details_cache", None)
if cached is not None:
return cached
prefetched = getattr(self, "_prefetched_objects_cache", {})
if "seriesdetail_set" in prefetched:
through_objs = sorted(
list(prefetched["seriesdetail_set"]),
key=lambda item: item.sort_order,
)
self._ordered_series_details_cache = through_objs
return through_objs
through_objs = list(
self.series.through.objects
.filter(case=self)
.select_related("series")
.order_by("sort_order")
)
self._ordered_series_details_cache = through_objs
return through_objs
def get_ordered_series(self):
"""Returns the series in the case in order of the SeriesDetail sort_order"""
cached = getattr(self, "_ordered_series_cache", None)
if cached is not None:
return cached
ordered = [sd.series for sd in self.get_ordered_series_details()]
self._ordered_series_cache = ordered
return ordered
def get_case_named_stacks(self):
stacks = []
for series in self.get_ordered_series():
series_images = list(series.get_images())
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
images = [url for _, url in series_images_with_urls]
declared_groups = _build_declared_series_groups(series, series_images_with_urls)
stacks.append(
{
"name": str(series),
"imageIds": images,
"seriesId": series.pk,
"series": declared_groups,
}
)
results = [
{
"caseId": f"CASE-{self.pk}",
"studyId": "Current Case",
"stacks": stacks,
}
]
return json.dumps(results)
@staticmethod
def _parse_dicom_date(value):
if not value:
return None
try:
return datetime.datetime.strptime(str(value), "%Y%m%d").date()
except (TypeError, ValueError):
return None
def _get_series_study_date(self):
for series in self.get_ordered_series():
image = series.images.filter(removed=False).first()
if not image:
continue
tags = getattr(image, "basic_dicom_tags", None) or {}
raw_date = (
tags.get("StudyDate")
or tags.get("AcquisitionDate")
or tags.get("SeriesDate")
)
parsed = self._parse_dicom_date(raw_date)
if parsed:
return parsed
try:
ds = image.get_dicom_data()
except Exception:
ds = None
if not ds:
continue
raw_date = (
getattr(ds, "StudyDate", None)
or getattr(ds, "AcquisitionDate", None)
or getattr(ds, "SeriesDate", None)
)
parsed = self._parse_dicom_date(raw_date)
if parsed:
return parsed
return None
def get_effective_study_date(self):
if self.study_date:
return self.study_date
return self._get_series_study_date()
def save(self, *args, **kwargs):
if self.pk and not self.study_date:
derived_study_date = self._get_series_study_date()
if derived_study_date:
self.study_date = derived_study_date
super().save(*args, **kwargs)
def get_app_name(self):
return "atlas"
# question_file = models.FileField(upload_to=question_file_directory_path, blank=True, null=True)
def get_absolute_url(self):
return reverse("atlas:case_detail", kwargs={"pk": self.pk})
def get_link(self):
# return f"{self.pk}: {self.title}"
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), str(self))
def get_cimar_case(self, raise_404_if_not_found=False):
if raise_404_if_not_found:
return get_object_or_404(CimarCase, uuid=self.cimar_uuid)
try:
return CimarCase.objects.get(uuid=self.cimar_uuid)
except CimarCase.DoesNotExist:
return None
def get_cimar_case_details(self):
cimar_case = self.get_cimar_case(raise_404_if_not_found=True)
return cimar_case.study_details
# def get_base_template(self):
# def get_edit_template_links(self):
def __str__(self):
if self.open_access:
return f"{self.pk}: {self.title} [OA]"
else:
return f"{self.pk}: {self.title}"
def get_series_blocks(self):
html = ""
s: Series
for s in self.series.all():
html += s.get_block()
return mark_safe(html)
def get_long_str(self):
examinations = [series.get_examination() for series in self.series.all()]
return f"{self.pk}:{self.title} {'/'.join([str(c) for c in self.condition.all()])} [{', '.join(examinations)}]"
def get_case_dicom_json(self, case_title_as_patient_name=True, priors=None):
series_json = []
series: Series
for series in self.series.all():
series_json.append(series.get_series_dicom_json())
patient_name = ""
if case_title_as_patient_name:
patient_name = self.title
studies_json = {
"studies": [
{
"StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630178",
#"StudyDate": "20000101",
"StudyTime": "",
"PatientName": patient_name,
"PatientID": "LIDC-IDRI-0001",
"AccessionNumber": "",
"PatientAge": "",
"PatientSex": "",
"series": series_json,
}
]
}
if priors is not None:
for prior in priors:
series_json = []
for series in prior.prior_case.series.all():
series_json.append(series.get_series_dicom_json())
studies_json["studies"].append(
{
"StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630177",
#"StudyDate": "20000101",
"StudyTime": "",
"PatientName": patient_name,
"PatientID": "LIDC-IDRI-0001",
"AccessionNumber": "",
"PatientAge": "",
"PatientSex": "",
"series": series_json,
"StudyDescription": f"Prior: {prior.relation_text}"
}
)
return studies_json
def get_viva_details(self):
return {
"title": self.title,
"description": self.description,
"history": self.history,
"discussion": self.discussion,
"report": self.report,
}
def get_viva_details_json(self):
return json.dumps(self.get_viva_details())
def get_all_prior_cases(self):
prior_cases = []
while self.previous_case is not None:
prior_cases.append(self.previous_case)
self = self.previous_case
return prior_cases
def get_all_future_cases(self):
from django.core.exceptions import ObjectDoesNotExist
future_cases = []
current = self
while True:
try:
next_c = current.next_case
except ObjectDoesNotExist:
break
if next_c is None:
break
future_cases.append(next_c)
current = next_c
return future_cases
def get_series(self):
return self.series.all().prefetch_related("images", "examination", "plane")
def get_series_modalities(self) -> set:
"""Returns a list of the modalities of the series in the case"""
series = self.series.all()
modalities = [s.modality.short_code for s in series if s.modality is not None]
return set(modalities)
def get_total_series_images_size(self, refresh=False):
if self.total_images_series_size is not None and not refresh:
return self.total_images_series_size
series = self.series.all()
size = sum(s.get_total_image_size(refresh=refresh) for s in series)
return size
def check_user_can_edit(self, user) -> bool:
"""Check if the user can edit the case.
Args:
user (User): The user to check.
Returns:
bool: True if the user can edit the case, False otherwise.
"""
if user.is_superuser:
return True
if self.author.filter(id=user.id).exists():
return True
return False
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 = series_qs.exclude(id__in=exclude_series_ids)
# Ensure series are ordered by the through model sort_order
series_list = list(series_qs.order_by('seriesdetail__sort_order').prefetch_related('images'))
images = []
for series in series_list:
# If images were prefetched, use the cached list and filter in Python
prefetched = getattr(series, '_prefetched_objects_cache', {}).get('images')
if prefetched is not None:
imgs = [img.image.url for img in prefetched if not getattr(img, 'removed', False)]
else:
imgs = [image.image.url for image in series.images.filter(removed=False)]
images.append(imgs)
if as_json:
return json.dumps(images)
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():
# series_images = list(series.get_images())
# series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
# images = [url for _, url in series_images_with_urls]
# declared_groups = _build_declared_series_groups(series, series_images_with_urls)
# name = f"{prefix}: {series}" if prefix else str(series)
# stacks.append(
# {
# "name": name,
# "imageIds": images,
# "seriesId": series.pk,
# "series": declared_groups,
# }
# )
# 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)
class NormalCase(models.Model, AuthorMixin):
"""Marks a Case as a 'normal' example used in the Atlas normals section.
This version stores a canonical `age_days` integer (patient age in days).
We keep the examination/modality metadata and authored fields. The
age fields previously stored (`age_years`, `age_value`, `age_unit`) have
been removed in favour of the single canonical `age_days` value.
"""
case = models.OneToOneField(
Case,
on_delete=models.CASCADE,
related_name="normal_case",
help_text="The Case that is marked as a normal example.",
)
# Canonical internal representation: patient age in days
age_days = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Patient age at scan in days (canonical).",
)
# Helpful constants for converting/displaying ages. These are not
# stored directly on the model but are used by forms/views/templates.
AGE_UNIT_YEARS = "years"
AGE_UNIT_MONTHS = "months"
AGE_UNIT_WEEKS = "weeks"
AGE_UNIT_DAYS = "days"
AGE_UNIT_CHOICES = [
(AGE_UNIT_YEARS, "years"),
(AGE_UNIT_MONTHS, "months"),
(AGE_UNIT_WEEKS, "weeks"),
(AGE_UNIT_DAYS, "days"),
]
examination = models.ForeignKey(
Examination,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="normal_cases",
help_text="Primary examination for this normal case (optional).",
)
modality = models.ForeignKey(
Modality,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="normal_cases",
help_text="Primary modality for this normal case (optional).",
)
added_date = models.DateTimeField(default=timezone.now)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="normal_cases",
)
notes = models.TextField(null=True, blank=True)
class Meta:
ordering = ["-added_date"]
def __str__(self):
return f"Normal: {self.case} ({self.display_age()})"
def get_absolute_url(self):
return self.case.get_absolute_url()
@staticmethod
def to_days(value: int | float, unit: str) -> int | None:
"""Convert a numeric value + unit into integer days (rounded).
Examples:
to_days(2, 'years') -> ~730
to_days(6, 'months') -> ~183
"""
try:
if value is None or unit is None:
return None
v = float(value)
if unit == NormalCase.AGE_UNIT_YEARS:
return int(round(v * 365.25))
if unit == NormalCase.AGE_UNIT_MONTHS:
return int(round(v * 30.4375))
if unit == NormalCase.AGE_UNIT_WEEKS:
return int(round(v * 7))
if unit == NormalCase.AGE_UNIT_DAYS:
return int(round(v))
except Exception:
return None
@staticmethod
def days_to_value_unit(days: int | None) -> tuple[int | None, str | None]:
"""Convert canonical days into a (value, unit) tuple for display.
Prefer years for ages >= 365 days, months for ages >= 30 days,
weeks for >=7 days, else days.
"""
if days is None:
return (None, None)
try:
d = int(days)
if d >= 365:
yrs = int(round(d / 365.25))
return (yrs, NormalCase.AGE_UNIT_YEARS)
if d >= 30:
months = int(round(d / 30.4375))
return (months, NormalCase.AGE_UNIT_MONTHS)
if d >= 7:
weeks = int(round(d / 7))
return (weeks, NormalCase.AGE_UNIT_WEEKS)
return (d, NormalCase.AGE_UNIT_DAYS)
except Exception:
return (None, None)
def display_age(self) -> str:
"""Return a human friendly age string derived from age_days."""
if self.age_days is None:
return "age unknown"
val, unit = NormalCase.days_to_value_unit(self.age_days)
if val is None or unit is None:
return "age unknown"
# abbreviate years to 'y' for backwards compatibility where helpful
if unit == NormalCase.AGE_UNIT_YEARS:
return f"{val}y"
return f"{val} {unit}"
def save(self, *args, **kwargs):
# If examination/modality not specified, try to infer from the first series
try:
if (self.examination is None or self.modality is None) and self.case.series.exists():
first_series = self.case.series.first()
if self.examination is None and getattr(first_series, 'examination', None) is not None:
self.examination = first_series.examination
if self.modality is None and getattr(first_series, 'modality', None) is not None:
self.modality = first_series.modality
except Exception:
# Be defensive: if anything goes wrong whilst inferring, continue and save as-is
pass
super().save(*args, **kwargs)
def extract_image_dicom_json_from_ds(ds, url, image_index):
to_keep = [
"Columns",
"Rows",
"InstanceNumber",
"SOPClassUID",
"PhotometricInterpretation",
"BitsAllocated",
"BitsStored",
"PixelRepresentation",
"SamplesPerPixel",
"PixelSpacing",
"HighBit",
"ImageOrientationPatient",
"ImagePositionPatient",
"FrameOfReferenceUID",
"ImageType",
"Modality",
"SOPInstanceUID",
"SeriesInstanceUID",
"StudyInstanceUID",
"WindowCenter",
"WindowWidth",
"SeriesDate",
]
d = {}
for key in to_keep:
if key in ds:
val = ds[key].value
if type(val) == pydicom.multival.MultiValue:
d[key] = list(val)
else:
d[key] = val
# Is it worth trying on fake dicom tags?.....
if d == {}:
d["SOPInstanceUID"] = f"1.2.840.1111.{image_index}"
d["SeriesInstanceUID"] = f"1.2.840.1112.1"
return {"metadata": d, "url": f"dicomweb:{url}"}
class SeriesImage(SeriesImageBase):
image = models.FileField(upload_to=image_directory_path, null=True)
series = models.ForeignKey(
"Series", related_name="images", on_delete=models.CASCADE, null=True
)
replaced = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Reference to the object that has replaced this one.",
)
# def get_image_dicom_json(self, image_index):
# try:
# with pydicom.dcmread(self.image) as ds:
# return extract_image_dicom_json_from_ds(ds, url=f"{REMOTE_URL}{self.image.url}")
# except FileNotFoundError:
# return []
# except InvalidDicomError:
# return {"url": f"dicomweb:{REMOTE_URL}{self.image.url}", "metadata": {
# "SOPInstanceUID" : f"1.2.840.1111.{image_index}"
# }}
@receiver(pre_save, sender=SeriesImage)
def _capture_previous_series_for_series_image(sender, instance, **kwargs):
if not getattr(instance, "pk", None):
instance._previous_series_id_for_cache = None
return
previous_series_id = (
sender.objects.filter(pk=instance.pk).values_list("series_id", flat=True).first()
)
instance._previous_series_id_for_cache = previous_series_id
def _invalidate_and_touch_series(series_id):
if not series_id:
return
invalidate_series_declared_groups_cache(series_id)
Series.objects.filter(pk=series_id).update(modified_date=timezone.now())
@receiver(post_save, sender=SeriesImage)
def _invalidate_declared_groups_on_series_image_save(sender, instance, **kwargs):
affected_series_ids = set()
if instance.series_id:
affected_series_ids.add(instance.series_id)
previous_series_id = getattr(instance, "_previous_series_id_for_cache", None)
if previous_series_id:
affected_series_ids.add(previous_series_id)
for series_id in affected_series_ids:
_invalidate_and_touch_series(series_id)
@receiver(post_delete, sender=SeriesImage)
def _invalidate_declared_groups_on_series_image_delete(sender, instance, **kwargs):
_invalidate_and_touch_series(getattr(instance, "series_id", None))
class SeriesFinding(FindingBase):
series = models.ForeignKey(
"Series", related_name="findings", on_delete=models.SET_NULL, null=True
)
findings = models.ManyToManyField(Finding, blank=True)
structures = models.ManyToManyField(Structure, blank=True)
conditions = models.ManyToManyField(Condition, blank=True)
def __str__(self) -> str:
findings = self.findings.all().values_list("name")
if self.series is None:
return f"SeriesFinding: {findings}/{self.description} (no series)"
return f"SeriesFinding: {self.series.id}/{findings}/{self.description}"
@reversion.register
class Series(SeriesBase):
modality = models.ForeignKey(
Modality,
related_name="atlas_series_modality",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
examination = models.ForeignKey(
Examination,
help_text="Name of the examination",
related_name="atlas_series_examination",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
plane = models.ForeignKey(
Plane,
help_text="Plane of the examination",
related_name="atlas_series_plane",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
contrast = models.ForeignKey(
Contrast,
help_text="MRI / CT contrast",
related_name="atlas_series_contrast",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="series",
)
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
source_series_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
def __str__(self) -> str:
if self.description:
return self.description
else:
return f"{self.examination} ({self.plane})"
def user_can_edit(self, user, allow_if_can_edit_cases=True) -> bool:
"""Check if the user can edit the series.
Args:
user (User): The user to check.
Returns:
bool: True if the user can edit the series, False otherwise.
"""
if user.is_superuser:
return True
if self.author.filter(id=user.id).exists():
return True
if allow_if_can_edit_cases:
# Check if user can edit any of the cases this series is in
for case in self.case.all():
if case.check_user_can_edit(user):
return True
return False
def get_full_str(self):
if self.case:
case_id = ", ".join([str(case) for case in self.case.all()])
# case_id = self.case.pk
else:
case_id = "None"
return "{}/{} : {} [{}]".format(
self.pk, self.get_examination_full(), self.description, case_id
)
def get_viewer_stack_description(self) -> str:
description = self.description or str(self.examination or "Series")
if self.plane:
description = f"{description} ({self.plane})"
if self.contrast:
description = f"{description} / {self.contrast}"
return description
def get_absolute_url(self):
return reverse("atlas:series_detail", kwargs={"pk": self.pk})
def get_link(self):
return format_html(
"<a href='{}'>{}</a>", self.get_absolute_url(), self.get_full_str()
)
# @lru_cache(maxsize=128)
def get_series_dicom_json(self):
instances = []
series_json = {}
to_keep = ["SeriesInstanceUID", "SeriesNumber", "Modality", "SliceThickness"]
# TODO: clean up (this is rather convoluted....)
image: SeriesImage
for series_n, image in enumerate(self.images.filter(removed=False)):
ds = image.get_dicom_data()
if series_n == 0:
for tag in to_keep:
if tag in ds:
val = ds[tag].value
if type(val) == pydicom.multival.MultiValue:
series_json[tag] = list(val)
else:
series_json[tag] = val
instances.append(
extract_image_dicom_json_from_ds(
ds, url=f"{REMOTE_URL}{image.image.url}", image_index=series_n
)
)
# else:
# instances.append(image.get_image_dicom_json(image_index))
series_json["SeriesDescription"] = self.get_viewer_stack_description()
series_json["instances"] = instances
return series_json
def get_ohif_dicom_json(self):
series_json = []
series_json.append(self.get_series_dicom_json())
patient_name = ""
return {
"studies": [
{
"StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630178",
"StudyDate": "20000101",
"StudyTime": "",
"PatientName": patient_name,
"PatientID": "LIDC-IDRI-0001",
"AccessionNumber": "",
"PatientAge": "",
"PatientSex": "",
"series": series_json,
}
]
}
def use_date_as_description(self):
# get first image
image = self.images.first()
with pydicom.dcmread(image.image) as ds:
date = ds.get("StudyDate", "No date")
self.description = f"{date[:4]}-{date[4:6]}-{date[6:]}"
self.save()
def get_base_template(self):
return "atlas/base.html"
def get_link_headers(self):
return "atlas/series_headers.html"
def get_related_findings(self):
"""Returns the related findings for the series, including findings from other series in the same case."""
# Get findings directly related to this series
findings = list(self.findings.all())
# Get findings from other series in the same case
if hasattr(self, "case") and self.case.exists():
for case in self.case.all():
for other_series in case.series.exclude(pk=self.pk):
findings.extend(other_series.findings.all())
# Remove duplicates (by pk)
unique_findings = {f.pk: f for f in findings}.values()
return list(unique_findings)
class CaseCollection(ExamOrCollectionGenericBase):
app_name = "atlas"
cases = models.ManyToManyField(Case, through="CaseDetail")
show_title_pre = models.BooleanField(
default=False, help_text="Show the title of the cases (pre exam)"
)
show_history_pre = models.BooleanField(
default=False, help_text="Show the history of the cases (pre exam)"
)
show_description_pre = models.BooleanField(
default=False, help_text="Show the description of the cases (pre exam)"
)
show_discussion_pre = models.BooleanField(
default=False, help_text="Show the case discussion (pre exam)"
)
show_report_pre = models.BooleanField(
default=False, help_text="Show the case report (pre exam)"
)
show_title_post = models.BooleanField(
default=False, help_text="Show the title of the cases (post exam)"
)
show_history_post = models.BooleanField(
default=False, help_text="Show the history of the cases (post exam)"
)
show_description_post = models.BooleanField(
default=False, help_text="Show the description of the cases (post exam)"
)
show_discussion_post = models.BooleanField(
default=False, help_text="Show the case discussion (post exam)"
)
show_report_post = models.BooleanField(
default=False, help_text="Show the case report (post exam)"
)
# New post-exam display options
show_case_link_post = models.BooleanField(
default=False, help_text="Show a link to the case (post exam)"
)
show_findings_post = models.BooleanField(
default=False, help_text="Show findings related to the case (post exam)"
)
show_displaysets_post = models.BooleanField(
default=False, help_text="Show display sets for the case (post exam)"
)
show_differentials_post = models.BooleanField(
default=False, help_text="Show differentials for the case (post exam)"
)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
help_text="Author of the collection",
related_name="casecollection_authored_cases",
)
markers = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
help_text="Authorised markers for the exam",
related_name="casecollection_markers",
)
valid_cid_users = models.ManyToManyField(
CidUser, blank=True, related_name="casecollection_exams"
)
valid_user_users = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="user_casecollection_exams"
)
cid_user_groups = models.ManyToManyField(
CidUserGroup,
blank=True,
help_text="These groups define which candidates are able to be added to the exams/collection.",
related_name="casecollection_cid_user_groups",
)
user_user_groups = models.ManyToManyField(
UserUserGroup,
blank=True,
help_text="These groups define which candidates are able to be added to the exams/collection.",
related_name="casecollection_user_user_groups",
)
exam_mode = models.BooleanField(default=False)
# This should override the publish setting
self_review = models.BooleanField(
default=False,
help_text="If true allows users self complete and review cases in a self directed way.",
)
exam_user_status = GenericRelation(ExamUserStatus, related_query_name="atlas_exams")
cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="atlas_exams")
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.")
case_query_messaging_enabled = models.BooleanField(
default=True,
help_text="Enable per-case query and messaging for this collection.",
)
show_results_sharing_information = models.BooleanField(
default=True,
help_text="If true, show result-sharing information on candidate start and overview screens.",
)
question_time_limit = models.PositiveIntegerField(
blank=True,
null=True,
help_text="Time limit for answering questions in seconds."
)
answer_entry_grace_period = models.PositiveIntegerField(
blank=True,
null=True,
default=0,
help_text="Additional seconds after the case view is locked during which answers can still be entered."
)
start_screen_content = models.TextField(
blank=True,
null=True,
help_text="Optional HTML content shown on the collection start screen (for example embedded resources).",
)
start_screen_resources = models.ManyToManyField(
"Resource",
blank=True,
related_name="start_screen_collections",
help_text="Optional resources shown on the collection start screen.",
)
end_overview_content = models.TextField(
blank=True,
null=True,
help_text="Optional HTML content shown on the collection end overview screen.",
)
end_overview_resources = models.ManyToManyField(
"Resource",
blank=True,
related_name="end_overview_collections",
help_text="Optional resources shown on the collection end overview screen.",
)
# 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",
_("Review"),
)
REPORT = (
"REP",
_("Report"),
)
QUESTION = (
"QUE",
_("Question"),
)
VIVA = (
"VIV",
_("Viva"),
)
collection_type = models.CharField(
max_length=3,
choices=COLLECTION_TYPE_CHOICES.choices,
default=COLLECTION_TYPE_CHOICES.REPORT,
)
class VIEWER_MODE_CHOICES(models.TextChoices):
BUILT_IN = (
"BUILT_IN",
_("Built in viewer"),
)
OHIF = (
"OHIF",
_("OHIF Viewer"),
)
BUILT_IN_WITH_OHIF = (
"BUILT_IN_WITH_OHIF",
_("Built in viewer with OHIF Viewer option"),
)
viewer_mode = models.CharField(
max_length=100,
choices=VIEWER_MODE_CHOICES.choices,
default=VIEWER_MODE_CHOICES.BUILT_IN,
)
# -----------------------------------------------------------------------
# Marking configuration
# These fields can be defined here so that the same CaseCollection can be
# reused in multiple research studies or standalone with consistent marking.
# -----------------------------------------------------------------------
marking_scheme_name = models.CharField(
max_length=255,
blank=True,
help_text="Human-readable mark scheme name shown to markers and in exports.",
)
marking_guidance = models.TextField(
blank=True,
help_text="Plain-language marking guidance displayed to markers.",
)
case_question_max_score = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum score per case used for normalisation. Defaults to 10 when not set.",
)
# -----------------------------------------------------------------------
# Case question template
# When set, this JSON schema is used as a default for all CaseDetail entries
# in the collection that do not have their own question_schema.
# -----------------------------------------------------------------------
case_question_template = models.JSONField(
null=True,
blank=True,
help_text=(
"JSON schema applied as a default question template for all cases in this collection. "
"Individual cases can override via CaseDetail.question_schema."
),
)
auto_apply_question_template = models.BooleanField(
default=False,
help_text=(
"If enabled, the case_question_template is automatically applied to new cases "
"added to this collection that do not have an existing question_schema."
),
)
def get_app_name(self):
return "atlas"
def get_base_template(self):
return "atlas/base.html"
def get_link_headers(self):
return f"{self.get_app_name()}/collection_headers.html"
def get_absolute_url(self):
return reverse("atlas:collection_detail", kwargs={"pk": self.pk})
def get_take_url(self):
return reverse("atlas:collection_take_start", kwargs={"pk": self.pk})
def get_take_url_exam_mode(self):
return reverse("atlas:collection_take_start", kwargs={"pk": self.pk})
def __str__(self) -> str:
return self.name
def show_built_in_viewer(self) -> bool:
return self.viewer_mode in (
self.VIEWER_MODE_CHOICES.BUILT_IN,
self.VIEWER_MODE_CHOICES.BUILT_IN_WITH_OHIF,
)
def show_ohif_viewer(self) -> bool:
return self.viewer_mode in (
self.VIEWER_MODE_CHOICES.OHIF,
)
def show_ohif_viewer_link(self) -> bool:
return self.viewer_mode in (
self.VIEWER_MODE_CHOICES.BUILT_IN_WITH_OHIF,
)
def review_only(self) -> bool:
"""Returns True if a users cannot submit responses
Returns:
bool: _description_
"""
if self.collection_type == self.COLLECTION_TYPE_CHOICES.REVIEW:
return True
return False
def in_review_mode(self) -> bool:
return self.review_only() or self.publish_results
# def can_answer(self) -> bool:
# """Returns true if the user is able to answer questions in a collection
# (independent of current collection state (e.g. published / not published))"""
# if self.collection_type == self.COLLECTION_TYPE_CHOICES.REPORT:
# return True
# return False
def get_cases(self):
"""Returns the cases in the collection in order of the CaseDetail sort_order"""
return self.cases.all().order_by("casedetail__sort_order")
def get_effective_case_time_limit(self, casedetail):
if casedetail.question_time_limit_override is not None:
return casedetail.question_time_limit_override
return self.question_time_limit
def get_effective_answer_entry_grace_period(self, casedetail):
if casedetail.answer_entry_grace_period_override is not None:
return casedetail.answer_entry_grace_period_override
return self.answer_entry_grace_period or 0
def get_next_case(self, case):
cases = list(self.get_cases())
try:
return cases[cases.index(case) + 1]
except IndexError:
return None
def get_previous_case(self, case):
cases = list(self.get_cases())
new_index = cases.index(case) - 1
if new_index < 0:
return None
else:
return cases[new_index]
def apply_question_template_to_cases(self, overwrite_existing=False):
"""
Apply case_question_template to all CaseDetail entries in this collection.
Args:
overwrite_existing: If True, overwrite existing question_schema on CaseDetails.
If False (default), only apply to cases without a schema.
Returns:
int: Number of CaseDetail entries updated.
"""
if not self.case_question_template:
return 0
casedetails = self.casedetail_set.all()
updated = 0
for cd in casedetails:
if overwrite_existing or not cd.question_schema:
cd.question_schema = self.case_question_template
cd.save(update_fields=["question_schema"])
updated += 1
return updated
def get_effective_question_schema(self, casedetail):
"""
Return the effective question schema for a CaseDetail.
Uses the individual CaseDetail schema if set, otherwise falls back to the collection template.
"""
if casedetail.question_schema:
return casedetail.question_schema
return self.case_question_template
def get_index_of_case(self, case, case_count=False):
cases = list(self.get_cases())
if case_count:
return cases.index(case), len(cases)
else:
return cases.index(case)
def get_case_by_index(
self, case_index: int, case_count=False
) -> Case | Tuple[Case, int]:
# Seems to be a bug when case_number is 0 or 1 the same (first) object gets returned
# forces a list fixes (but is likely inefficient)
cases = list(self.get_cases().prefetch_related())
try:
case = cases[case_index]
except IndexError: # Catch an invalid case_number
s = f"Invalid case number: {case_index}"
raise Http404(s)
if case_count:
return case, len(cases)
else:
return case
def get_case_take_url(self, case):
cases = list(self.get_cases().prefetch_related())
return reverse(
"atlas:collection_case_view_take_user",
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.
"""
logger.error("Checking if user can take collection with prerequisites")
logger.error(f"CID: {cid}, user: {user}, active_only: {active_only}")
#` 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()):
series_json = []
for series in case.series.all():
series_json.append(series.get_series_dicom_json())
patient_name = ""
if case_title_as_patient_name:
patient_name = case.title
studies.append(
{
"StudyInstanceUID": f"1.3.6.1.4.1.14519.5.2.1.6279.6001.{n}",
"StudyDate": "20000101",
"StudyTime": "",
"PatientName": patient_name,
"PatientID": f"ID{n}",
"AccessionNumber": "",
"PatientAge": "",
"PatientSex": "",
"series": series_json,
}
)
return {"studies": studies}
def add_case(self, case):
"""Adds a case to the collection and makes sure order is maintained"""
# We might be better off adding via the case detail model direct
# CaseDetail.objects.create(case=case, collection=self)
self.cases.add(case)
self.order_cases()
def order_cases(self):
"""Modifies the casedetail sort_order to sequentially order the cases"""
for n, c in enumerate(self.casedetail_set.all().order_by("sort_order")):
c.sort_order = n
c.save()
class SeriesDetail(models.Model):
case = models.ForeignKey(Case, on_delete=models.CASCADE)
series = models.ForeignKey(Series, on_delete=models.CASCADE)
sort_order = models.IntegerField(default=1000)
feedback = models.BooleanField(
default=False,
help_text="Set to true if the series should only be shown for feedback purposes.",
)
class Meta:
ordering = ("sort_order",)
@receiver(post_save, sender=SeriesDetail)
def _populate_case_study_date_on_series_link(sender, instance, created, **kwargs):
case = instance.case
if case.study_date:
return
derived_study_date = case._get_series_study_date()
if derived_study_date:
Case.objects.filter(pk=case.pk, study_date__isnull=True).update(study_date=derived_study_date)
class CaseDisplaySet(models.Model, AuthorMixin):
"""
This is analogous to a SeriesFinding but for a Case (it has
access to all the series stacks).
"""
case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="display_sets")
name = models.CharField(
max_length=255,
help_text="Name of the display set",
)
description = models.TextField(
blank=True,
help_text="Description of the display set",
)
viewerstate = models.JSONField(
null=True,
blank=True,
help_text="Viewer state for the display set",
)
annotations = models.JSONField(
null=True,
blank=True,
help_text="Annotations for the display set",
)
findings = models.ManyToManyField(Finding, blank=True)
structures = models.ManyToManyField(Structure, blank=True)
conditions = models.ManyToManyField(Condition, blank=True)
def viewerstate_string(self):
return json.dumps(self.viewerstate) if self.viewerstate else "{}"
def annotations_string(self):
return json.dumps(self.annotations) if self.annotations else "{}"
class CaseDetail(models.Model):
"""A through table that stores the relationship between a case and a collection.
This is what user answers are linked to (as there be different questions for the same case in a different collection)
The collection questions and answers are also defined here.
"""
case = models.ForeignKey(Case, on_delete=models.CASCADE)
collection = models.ForeignKey(CaseCollection, on_delete=models.CASCADE)
question_schema = models.JSONField(null=True, blank=True)
question_answers = models.JSONField(null=True, blank=True)
# TODO add feedback for questions
#question_feedback = models.JSONField(null=True, blank=True)
default_viewerstate = models.JSONField(
null=True,
blank=True,
help_text="Default viewer state for the case"
)
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."
)
question_time_limit_override = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Optional per-case override for the answer time limit in seconds.",
)
answer_entry_grace_period_override = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
)
class SeriesVisibility(models.TextChoices):
ALWAYS = "AL", _("Show all the time")
REVIEW = "RE", _("Show on review")
HIDDEN = "NO", _("Do not show")
series_visibility_config = models.JSONField(
default=dict,
blank=True,
help_text="Per-series visibility map for this case in this collection.",
)
learner_comment = models.TextField(
blank=True,
default="",
help_text="Comment shown to the learner while taking the case and on review.",
)
learner_review_comment = models.TextField(
blank=True,
default="",
help_text="Comment shown to the learner only on review.",
)
class Meta:
ordering = ("sort_order",)
def __str__(self) -> str:
return f"{self.case} -> {self.collection}"
def get_case_index(self):
"""Returns the question index (number) in the collection (0 indexed)"""
case_details = list(self.collection.casedetail_set.all().order_by("sort_order"))
return case_details.index(self)
def get_question_schema(self):
return json.dumps(self.question_schema)
def get_question_answers(self):
"""Returns the correct question answers as a json string"""
return json.dumps(self.question_answers)
def get_user_answers(self, user):
"""Returns the users answers as a json string"""
try:
return UserReportAnswer.objects.get(question=self, user=user)
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_history(self):
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"
def get_series_visibility_map(self) -> dict[str, str]:
raw_map = self.series_visibility_config or {}
if not isinstance(raw_map, dict):
return {}
valid_values = {
self.SeriesVisibility.ALWAYS,
self.SeriesVisibility.REVIEW,
self.SeriesVisibility.HIDDEN,
}
cleaned = {}
for key, value in raw_map.items():
key_str = str(key)
value_str = str(value)
if value_str in valid_values:
cleaned[key_str] = value_str
return cleaned
def get_series_visibility_for(self, series, review_mode: bool = False) -> str:
visibility = self.get_series_visibility_map().get(
str(series.pk), self.SeriesVisibility.ALWAYS
)
if visibility == self.SeriesVisibility.REVIEW and not review_mode:
return self.SeriesVisibility.HIDDEN
return visibility
def get_visible_case_series(self, review_mode: bool = False):
visible = []
for series in self.case.get_ordered_series():
visibility = self.get_series_visibility_for(series, review_mode=review_mode)
if visibility != self.SeriesVisibility.HIDDEN:
visible.append(series)
return visible
def get_case_series_nested(self, include_priors=True, review_mode: bool = False):
case_series_images = []
for series in self.get_visible_case_series(review_mode=review_mode):
case_series_images.append([
image.image.url
for image in series.images.filter(removed=False)
if image.image
])
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():
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
continue
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, review_mode: bool = False):
def build_stacks_for(case_obj, prefix=None):
stacks = []
if case_obj == self.case:
series_iterable = self.get_visible_case_series(review_mode=review_mode)
else:
series_iterable = case_obj.get_ordered_series()
for series in series_iterable:
series_images = list(series.get_images())
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
images = [url for _, url in series_images_with_urls]
declared_groups = _build_declared_series_groups(series, series_images_with_urls)
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append(
{
"name": name,
"imageIds": images,
"seriesId": series.pk,
"series": declared_groups,
}
)
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():
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
continue
prior_case = prior.prior_case
label = "Prior" if prior.relation_type == CasePrior.RelationType.PRIOR else "Future"
results.append(
{
"caseId": f"CASE-{prior_case.pk}",
"studyId": f"{label}: {prior.relation_text}",
"stacks": build_stacks_for(prior_case, prefix=label),
}
)
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):
casedetail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
class RelationType(models.TextChoices):
PRIOR = "PR", _("Prior")
FUTURE = "FU", _("Future")
relation_type = models.CharField(
max_length=2,
choices=RelationType.choices,
default=RelationType.PRIOR,
help_text="Defines whether the related case is a prior or a future exam",
)
class PriorVisibility(models.TextChoices):
NONE = "NO", _("None")
ALWAYS = "AL", _("Always")
REVIEW = "RE", _("Review")
prior_visibility = models.CharField(
max_length=2,
choices=PriorVisibility.choices,
default=PriorVisibility.ALWAYS,
help_text="Defines when the prior case is shown to the user",
)
class Meta:
unique_together = ("casedetail", "prior_case")
def __str__(self) -> str:
return f"{self.casedetail.case} -> {self.prior_case}"
class BaseReportAnswer(models.Model):
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
answer = models.TextField(blank=True)
json_answer = models.JSONField(null=True, blank=True)
feedback = models.TextField(blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
score = models.IntegerField(
blank=True, null=True, validators=[MaxValueValidator(10), MinValueValidator(0)]
)
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()
return super(BaseReportAnswer, self).save(*args, **kwargs)
def clean(self):
if self.answer:
self.answer = self.answer.strip()
def get_answer_score(self):
return self.score
def set_cid_or_user(self, cid=None, user=None):
if cid is not None:
self.cid = cid
elif user is not None:
self.user = user
else:
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}")
# 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 = ""
qa = getattr(self.question, "question_answers", None)
if isinstance(qa, dict):
correct_answer = qa.get(name, "")
else:
correct_answer = ""
match value:
case {"type": "string", "enum": _}:
automark = True
answer_is_correct = user_answer == correct_answer
case {"type": "string"}:
if user_answer == correct_answer:
automark = True
answer_is_correct = True
else:
automark = False
answer_is_correct = False
case _:
automark = False
answer_is_correct = user_answer == correct_answer
answers.append((value, user_answer, correct_answer, answer_is_correct, automark))
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
@reversion.register
class CidReportAnswer(BaseReportAnswer):
cid = models.BigIntegerField(
blank=False,
help_text="Candidate ID",
)
@reversion.register
class UserReportAnswer(BaseReportAnswer):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
class SelfReview(models.Model):
"""Holds data regarding a users self review / reflection"""
user_exam = models.ForeignKey(CidUserExam, on_delete=models.CASCADE)
case = models.ForeignKey(Case, on_delete=models.CASCADE)
comments = models.TextField(null=True, blank=True)
findings = models.IntegerField(
null=True, blank=True, validators=[MaxValueValidator(5), MinValueValidator(1)]
)
interpretation = models.IntegerField(
null=True, blank=True, validators=[MaxValueValidator(5), MinValueValidator(1)]
)
review_date = models.DateTimeField(auto_now_add=True)
review_update_date = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
date = self.review_date
if self.review_update_date is not None:
date = self.review_update_date
return f"Self review: {date:%Y-%m-%d} / {self.user_exam.exam} - {self.case}"
def get_absolute_url(self):
return self.user_exam.exam.get_case_take_url(self.case)
def get_display_block(self):
html = """<div class='self-feedback-block'>
Comments: <span class="comment">{comments}</span><br/>
Findings: <span class="findings">{findings}</span><br/>
Interpretation: <span class="interpretation">{interpretation}</span><br/>
<span class="date">Date: {date}</span><br/><a href="{edit_url}">Edit</a>
</div>
"""
edit_url = reverse(
"atlas:self_review_update",
kwargs={
"user_exam_id": self.user_exam.pk,
"case_id": self.case.pk,
"pk": self.pk,
},
)
# edit_url = "TEST"
return format_html(
html,
comments=self.comments,
findings=self.findings,
interpretation=self.interpretation,
date=self.review_date,
id=self.pk,
edit_url=edit_url,
)
class CaseReviewMessage(models.Model):
"""Conversation message for one learner attempt on one case.
Supervisors/collection authors use this for per-case feedback and replies.
Learners use it to raise questions/queries. Supervisor messages may require
acknowledgement so they continue to flag on the learner-facing pages until
explicitly acknowledged.
"""
class MessageType(models.TextChoices):
FEEDBACK = "feedback", _("Feedback")
QUERY = "query", _("Query")
REPLY = "reply", _("Reply")
cid_user_exam = models.ForeignKey(
CidUserExam,
on_delete=models.CASCADE,
related_name="case_review_messages",
)
case = models.ForeignKey(
Case,
on_delete=models.CASCADE,
related_name="case_review_messages",
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="case_review_messages_authored",
null=True,
blank=True,
)
author_label = models.CharField(max_length=255, blank=True)
message_type = models.CharField(
max_length=20,
choices=MessageType.choices,
default=MessageType.QUERY,
)
message = models.TextField()
requires_acknowledgement = models.BooleanField(default=False)
acknowledged_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("created_at",)
def __str__(self) -> str:
return f"{self.get_message_type_display()} for {self.case}"
@property
def is_acknowledged(self) -> bool:
return self.acknowledged_at is not None
def get_author_display(self) -> str:
if self.author is not None:
full_name = self.author.get_full_name().strip()
return full_name or self.author.username
return self.author_label or "Learner"
def acknowledge(self):
if self.acknowledged_at is None:
self.acknowledged_at = timezone.now()
self.save(update_fields=["acknowledged_at"])
class UncategorisedDicom(models.Model):
# We use image to maintain consitency across apps
image = models.FileField(upload_to=uncategorised_dicom_directory_path)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
help_text="The user that uploaded the file",
)
series = models.ForeignKey(
"Series",
related_name="imported_dicoms",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
image_blake3_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)
created_date = models.DateTimeField(default=timezone.now)
basic_dicom_tags = models.JSONField(null=True, blank=True)
def check_for_duplicates(self, image_hash=None|str):
duplicate = None
if image_hash is None:
image_hash = self.image_blake3_hash
if obj := UncategorisedDicom.objects.filter(
image_blake3_hash=image_hash
).first():
# duplicate = obj
return obj
if obj := SeriesImage.objects.filter(image_blake3_hash=image_hash).first():
duplicate = obj
return duplicate
def save(self, *args, **kwargs):
"""Override save method to add image hash"""
if self.image:
ds = self.get_dicom_info()
try:
self.series_instance_uid = ds["SeriesInstanceUID"].value
except AttributeError:
raise InvalidDicomError("Invalid dicom file")
# if self.check_for_duplicates():
# return DuplicateDicom
image_blake3_hash = get_image_dicom_hash(
None, dataset=ds, hash_type="blake3", direct_pixel_data=True
)
self.image_blake3_hash = image_blake3_hash
duplicate = self.check_for_duplicates(image_blake3_hash)
self.basic_dicom_tags = self.get_basic_dicom_tags(dataset=ds)
if duplicate is not None:
if duplicate != self:
raise DuplicateDicom(duplicate)
# Hack for tests
if image_blake3_hash != "1234":
super().save(*args, **kwargs) # Call the "real" save() method.
def get_dicom_info(self):
try:
ds = pydicom.dcmread(self.image)
return ds
except pydicom.errors.InvalidDicomError:
return {"SeriesInstanceUID": "1234"}
def get_basic_dicom_tags(self, dataset=None):
try:
if dataset is None:
dataset = pydicom.dcmread(self.image)
to_include = (
"StudyInstanceUID",
"StudyDescription",
"Modality",
"ProtocolName",
"SeriesDescription",
"SeriesInstanceUID",
"BodyPartExamined",
"SliceThickness",
"SeriesNumber",
"StudyDate",
"PatientID",
)
tags = {}
for tag in to_include:
if tag in dataset:
tags[tag] = dataset[tag].value
else:
tags[tag] = ""
return tags
except pydicom.errors.InvalidDicomError:
return None
def get_series(self):
return self.series
def get_dicom_url(self):
return f"{REMOTE_URL}{self.image.url}"
class DuplicateDicom(Exception):
def __init__(self, duplicate):
self.duplicate = duplicate
class Resource(models.Model, AuthorMixin):
name = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
url = models.URLField(blank=True, null=True)
file = models.FileField(upload_to="atlas/resources/", blank=True, null=True)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
help_text="Author of the resource",
related_name="resources",
)
created_date = models.DateTimeField(default=timezone.now)
modified_date = models.DateTimeField(auto_now=True)
# Link resources to one or more Sites (from generic.Site)
sites = models.ManyToManyField(
'generic.Site',
blank=True,
help_text='The site(s) this resource is associated with (if any).',
related_name='resources',
)
# Categorise resources by subspecialty
subspecialty = models.ManyToManyField(
Subspecialty,
blank=True,
help_text='Subspecialty or subspecialties this resource relates to.',
)
open_access = models.BooleanField(
default=True,
help_text="If true the resource is available to all users, otherwise only in the context of cases."
)
def __str__(self) -> str:
return self.name
def get_absolute_url(self):
return reverse("atlas:resource_detail", kwargs={"pk": self.pk})
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
def get_display(self) -> str:
if self.url:
html = f"<a target='_blank' href='{self.url}'>{self.name}</a>"
elif self.file:
filetype, _ = mimetypes.guess_type(self.file.url)
match filetype.split("/"):
case "image", _:
html = f"<img src='{self.file.url}'>"
case "video", _:
html = f"<video controls><source src='{self.file.url}'></video>"
case _:
html = f"<a target='_blank' href='{self.file.url}'>{self.name}</a>"
else:
html = self.name
meta = ''
if self.sites.exists():
meta += ' Sites: ' + ', '.join([s.short_code or s.full_name for s in self.sites.all()])
if self.subspecialty.exists():
meta += ' Subspecialty: ' + ', '.join([str(s) for s in self.subspecialty.all()])
return format_html(
"<details class='resource-block'><summary>{}</summary><div>{}</div><div class='text-muted small'>{}</div></details>",
self.name,
mark_safe(html),
meta,
)
class CaseResource(models.Model):
resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
case = models.ForeignKey(Case, on_delete=models.CASCADE)
pre_review = models.BooleanField(
default=False,
help_text="Defines if the resource should be shown pre review (i.e. for pre reading before the case is taken)",
)
def __str__(self) -> str:
return f"{self.resource} - {self.case} (Pre: {self.pre_review})"
class QuestionSchema(models.Model, AuthorMixin):
name = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
schema = models.JSONField()
def get_absolute_url(self):
return reverse("atlas:question_schema_detail", kwargs={"pk": self.pk})
def get_example_form(self):
from .forms import JsonAnswerForm
example_form = JsonAnswerForm(
question_schema=self.schema
)
return example_form
def get_schema_as_json(self) -> str:
return json.dumps(self.schema)
def __str__(self) -> str:
return "{}".format(self.name)
class PrerequisiteRequired(Exception):
"""Raised when a user attempts to access a collection but has not completed a prerequisite.
The exception stores an optional `prereq` attribute pointing to the prerequisite
CaseCollection instance so views can render a helpful page linking to it.
"""
def __init__(self, message=None, *, prereq=None):
super().__init__(message or "Prerequisite required")
self.prereq = prereq
class MultiuserSession(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255, blank=True)
creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="created_multiuser_sessions")
active_case = models.ForeignKey(Case, on_delete=models.SET_NULL, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Session {self.name or str(self.id)[:8]} by {self.creator}"