3038 lines
104 KiB
Python
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}" |