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"{s.name}" for s in syns_qs] return ", ".join(items) # render links for synonyms/canonical group items = [f"{s.name}" for s in syns] return ", ".join(items) def get_link(self): return format_html("{}", 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("{}", 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("{}", 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("{}", 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("{}", 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_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("{}", 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( "{}", 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 = "