Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion bfd9000_web/archive/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Shared code systems and identifier system URLs."""

SYSTEM_RECORD_TYPE = 'http://snomed.info/sct'
SYSTEM_RECORD_TYPE = 'https://orthodontics.case.edu/fhir/identifier-system/record-type'
SYSTEM_ORIENTATION = 'http://snomed.info/sct'
SYSTEM_MODALITY = 'http://dicom.nema.org/resources/ontology/DCM'
SYSTEM_PROCEDURE = 'http://snomed.info/sct'
Expand All @@ -10,6 +10,32 @@
SYSTEM_IDENTIFIER_LANCASTER_SUBJECT = 'https://cleftclinic.org/fhir/identifier-system/lancaster-subject-id'
SYSTEM_IDENTIFIER_IMAGE_TYPE = 'https://orthodontics.case.edu/fhir/identifier-system/image-type'

RECORD_TYPE_MODALITY_MAP = {
# Map CWRU record type codes to DICOM modality codes.
# Used when creating records if modality is not supplied.
# Update this if new record types are added or modality rules change.
'L': 'RG',
'F': 'RG',
'P': 'RG',
'FA': 'RG',
'H': 'RG',
'CS': 'RG',
'E': 'RG',
'K': 'RG',
'RE': 'DOCD',
'RF': 'DOCD',
'SM': 'OSS',
'SU': 'OSS',
'SL': 'OSS',
'FM': 'OSS',
}

VALUESET_EXPAND_URLS = {
# FHIR $expand endpoints used by import_valuesets.
# Update when valueset locations move or new valuesets are added.
'record_types': 'http://terminology.open-ortho.org/fhir/cwru-ortho-record-types/$expand',
}

BFD9000_ROOT_UID = "1.3.6.1.4.1.61741.11.8"
STUDYINSTANCEUID_ROOT = f"{BFD9000_ROOT_UID}.2"
SERIESINSTANCEUID_ROOT = f"{BFD9000_ROOT_UID}.3"
Expand Down
34 changes: 34 additions & 0 deletions bfd9000_web/archive/management/commands/import_valuesets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

from typing import Any
from django.core.management.base import BaseCommand, CommandError
from archive.management.importers.valuesets import import_valueset
from archive.constants import VALUESET_EXPAND_URLS

class Command(BaseCommand):
help = "Import FHIR ValueSet expansions. Use --all or provide --slug and --expand-url."

def add_arguments(self, parser) -> None:
parser.add_argument('--slug', type=str, help='Internal ValueSet slug')
parser.add_argument('--expand-url', type=str, help='FHIR $expand URL')
parser.add_argument('--all', action='store_true', help='Import all valuesets from constants mapping')

def handle(self, *args: Any, **options: Any) -> None:
if options.get('all'):
if not VALUESET_EXPAND_URLS:
raise CommandError('No valuesets configured in VALUESET_EXPAND_URLS.')
for slug, expand_url in VALUESET_EXPAND_URLS.items():
count = import_valueset(expand_url=expand_url, slug=slug)
self.stdout.write(self.style.SUCCESS(
f"Imported {count} codings into ValueSet '{slug}'."
))
return

slug = options.get('slug')
expand_url = options.get('expand_url')
if not slug or not expand_url:
raise CommandError('Use --all or provide both --slug and --expand-url.')
count = import_valueset(expand_url=expand_url, slug=slug)
self.stdout.write(self.style.SUCCESS(
f"Imported {count} codings into ValueSet '{slug}'."
))
13 changes: 13 additions & 0 deletions bfd9000_web/archive/management/commands/initialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from django.contrib.auth import get_user_model
from django.core.management import BaseCommand, CommandError, call_command

from archive.constants import VALUESET_EXPAND_URLS


class Command(BaseCommand):
"""Run migrate, create superuser, and import seed subject datasets."""
Expand Down Expand Up @@ -75,6 +77,17 @@ def handle(self, *args, **options) -> None:
self.stdout.write(self.style.NOTICE("Running migrate..."))
call_command("migrate", verbosity=verbosity)

self.stdout.write(self.style.NOTICE("Importing all valuesets..."))
try:
call_command(
"import_valuesets",
"--all",
verbosity=verbosity,
)
except Exception as exc:
import warnings
self.stdout.write(self.style.WARNING(f"WARNING: import_valuesets --all failed: {exc}"))

if not options["skip_superuser"]:
self._run_createsuperuser(options, verbosity)

Expand Down
110 changes: 110 additions & 0 deletions bfd9000_web/archive/management/importers/valuesets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import annotations

import json
from typing import Any, Dict, List
from urllib.request import urlopen

from archive.models import Coding, ValueSet, ValueSetConcept

def import_valueset(expand_url: str, slug: str) -> int:
"""
Import a FHIR ValueSet via $expand, upsert ValueSet and Coding rows,
and sync ValueSetConcept join links. Returns count of codings.
"""
payload = _fetch_valueset(expand_url)
valueset = _upsert_valueset(payload, slug)
codings = _upsert_codings(valueset, payload)
_sync_valueset_links(valueset, codings)
return len(codings)

def _fetch_valueset(url: str) -> Dict[str, Any]:
with urlopen(url) as response:
raw = response.read().decode("utf-8")
data: Dict[str, Any] = json.loads(raw)
return data

def _upsert_valueset(payload: Dict[str, Any], slug: str) -> ValueSet:
compose = payload.get("compose") or {}
include = list(compose.get("include") or [])
code_system_url = None
if include:
code_system_url = include[0].get("system")
expansion = payload.get("expansion") or {}
contains = list(expansion.get("contains") or [])
if contains and not code_system_url:
code_system_url = contains[0].get("system")

valueset, created = ValueSet.objects.get_or_create(
slug=slug,
defaults={
"url": payload.get("url", ""),
"name": payload.get("name", slug),
"title": payload.get("title", ""),
"description": payload.get("description", ""),
"version": payload.get("version", ""),
"status": payload.get("status", ""),
"publisher": payload.get("publisher", ""),
"code_system_url": code_system_url or "",
},
)

if not created:
updates: Dict[str, str] = {
"url": payload.get("url", ""),
"name": payload.get("name", slug),
"title": payload.get("title", ""),
"description": payload.get("description", ""),
"version": payload.get("version", ""),
"status": payload.get("status", ""),
"publisher": payload.get("publisher", ""),
"code_system_url": code_system_url or "",
}
changed_fields: List[str] = []
for field, value in updates.items():
if getattr(valueset, field) != value:
setattr(valueset, field, value)
changed_fields.append(field)
if changed_fields:
valueset.save(update_fields=changed_fields)

return valueset

def _upsert_codings(valueset: ValueSet, payload: Dict[str, Any]) -> List[Coding]:
expansion = payload.get("expansion") or {}
contains = expansion.get("contains") or []
codings: List[Coding] = []

for concept in contains:
system = str(concept.get("system") or "").strip()
code = str(concept.get("code") or "").strip()
display = str(concept.get("display") or "").strip()
definition = str(concept.get("definition") or "").strip()

if not system or not code:
continue

coding, _ = Coding.objects.get_or_create(
system=system,
version="",
code=code,
defaults={"display": display, "meaning": definition},
)
updates: List[str] = []
if display and coding.display != display:
coding.display = display
updates.append("display")
if definition and coding.meaning != definition:
coding.meaning = definition
updates.append("meaning")
if updates:
coding.save(update_fields=updates)
codings.append(coding)

return codings

def _sync_valueset_links(valueset: ValueSet, codings: List[Coding]) -> None:
for coding in codings:
ValueSetConcept.objects.get_or_create(valueset=valueset, coding=coding)

coding_ids = [coding.id for coding in codings]
ValueSetConcept.objects.filter(valueset=valueset).exclude(coding_id__in=coding_ids).delete()
16 changes: 0 additions & 16 deletions bfd9000_web/archive/migrations/0002_seed_codings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,6 @@


VALUESETS = [
{
"slug": "record_types",
"url": "https://orthodontics.case.edu/fhir/ValueSet/record-types",
"name": "RecordTypes",
"title": "Record types",
"status": "active",
"publisher": "Case Western Reserve University",
},
{
"slug": "orientations",
"url": "https://orthodontics.case.edu/fhir/ValueSet/orientations",
Expand Down Expand Up @@ -88,13 +80,6 @@


CODINGS = {
"record_types": [
(SCT, "201456002", "Cephalogram", ""),
(SCT, "268425006", "Pelvis X-ray", ""),
(SCT, "39714003", "Skeletal X-ray of wrist and hand", ""),
(SCT, "1597004", "Skeletal X-ray of ankle and foot", ""),
(SCT, "302189007", "Dental model", ""),
],
"orientations": [
(SCT, "399198007", "Right lateral projection", ""),
(SCT, "399173006", "Left lateral projection", ""),
Expand Down Expand Up @@ -221,7 +206,6 @@ def unseed_valuesets_and_codings(apps, schema_editor):
ValueSet = apps.get_model("archive", "ValueSet")
ValueSet.objects.filter(
slug__in=[
"record_types",
"orientations",
"modalities",
"body_sites",
Expand Down
42 changes: 28 additions & 14 deletions bfd9000_web/archive/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,12 @@
SYSTEM_RECORD_TYPE,
SYSTEM_IDENTIFIER_BOLTON_SUBJECT,
SYSTEM_IDENTIFIER_IMAGE_TYPE,
RECORD_TYPE_MODALITY_MAP,
)
from .media_utils import generate_thumbnail_jpeg_bytes


RECORD_TYPE_CODES = (
'201456002',
'268425006',
'39714003',
'1597004',
'302189007',
)

LATERAL_IMAGE_TYPE_CODE = 'L'
LATERAL_RECORD_TYPE_CODE = 'L'


def _encode_patient_orientation(value: Optional[list[str]]) -> str:
Expand Down Expand Up @@ -403,13 +396,15 @@ class RecordUploadSerializer(serializers.ModelSerializer):
# Use SlugRelatedField for idiomatic lookup by 'code'
record_type = serializers.SlugRelatedField(
slug_field='code',
queryset=Coding.objects.filter(system=SYSTEM_RECORD_TYPE, code__in=RECORD_TYPE_CODES),
write_only=True
queryset=Coding.objects.filter(system=SYSTEM_RECORD_TYPE),
write_only=True,
)
modality = serializers.SlugRelatedField(
slug_field='code',
queryset=Coding.objects.filter(system=SYSTEM_MODALITY),
write_only=True
required=False,
allow_null=True,
write_only=True,
)

acquisition_date = serializers.DateField(required=False, write_only=True)
Expand Down Expand Up @@ -486,6 +481,22 @@ def validate_image_transform_ops(self, value: Any) -> Any:

return normalized

def _infer_modality(self, record_type: Coding) -> Coding:
record_type_code = str(getattr(record_type, 'code', '') or '')
modality_code = RECORD_TYPE_MODALITY_MAP.get(record_type_code)

if not modality_code:
raise serializers.ValidationError({
'modality': f"Unable to infer modality for record type {record_type_code or 'unknown'}."
})

modality = Coding.objects.filter(system=SYSTEM_MODALITY, code=modality_code).first()
if modality is None:
raise serializers.ValidationError({
'modality': f"Modality code {modality_code} not found in system {SYSTEM_MODALITY}."
})
return modality

def to_representation(self, instance: Record) -> Dict[str, Any]:
"""Use standard RecordSerializer for response."""
return cast(Dict[str, Any], RecordSerializer(instance, context=self.context).data)
Expand Down Expand Up @@ -543,7 +554,7 @@ def create(self, validated_data: Dict[str, Any]) -> Record:

# These are now Coding objects, not strings!
rt_coding = validated_data.pop('record_type')
mod_coding = validated_data.pop('modality')
mod_coding = validated_data.pop('modality', None)

acquisition_date = validated_data.pop('acquisition_date', None)
image_type = validated_data.pop('image_type', None)
Expand All @@ -564,7 +575,7 @@ def create(self, validated_data: Dict[str, Any]) -> Record:
if request and getattr(request, 'user', None) and request.user.is_authenticated:
scan_operator = request.user

if patient_orientation is None and image_type and getattr(image_type, 'code', None) == LATERAL_IMAGE_TYPE_CODE:
if patient_orientation is None and getattr(rt_coding, 'code', None) == LATERAL_RECORD_TYPE_CODE:
Comment thread
zgypa marked this conversation as resolved.
patient_orientation = ['A', 'F']

with transaction.atomic():
Expand All @@ -586,6 +597,9 @@ def create(self, validated_data: Dict[str, Any]) -> Record:
study.collection = collection
study.save(update_fields=['collection'])

if mod_coding is None:
mod_coding = self._infer_modality(rt_coding)

# Get or create Series within the study
series, _ = Series.objects.get_or_create(
imaging_study=study,
Expand Down
5 changes: 0 additions & 5 deletions bfd9000_web/archive/templates/archive/record_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,6 @@ <h2 class="card-title text-lg">Series & Acquisition</h2>
<th>Modality</th>
<td id="modality">-</td>
</tr>
<tr>
<th>Image Type</th>
<td id="image-type">-</td>
</tr>
<tr>
<th>Patient Orientation</th>
<td id="patient-orientation">-</td>
Expand Down Expand Up @@ -182,7 +178,6 @@ <h2 class="card-title text-lg">Series & Acquisition</h2>

// Series & acquisition details come from record payload
document.getElementById('modality').textContent = record.series_modality?.display || '-';
document.getElementById('image-type').textContent = record.image_type?.display || '-';
document.getElementById('patient-orientation').textContent = Array.isArray(record.patient_orientation)
? record.patient_orientation.join('\\')
: '-';
Expand Down
15 changes: 10 additions & 5 deletions bfd9000_web/archive/templates/archive/records.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<th>Subject</th>
<th>Age</th>
<th>File Size</th>
<th>Image Type</th>
<th>Modality</th>
</tr>
</thead>
<tbody id="record_table_body">
Expand Down Expand Up @@ -189,17 +189,22 @@
const fileSize = document.createElement("td");
fileSize.textContent = formatFileSize(record.file_size);

// Image type column (plain text)
const imageType = document.createElement("td");
imageType.textContent = record.image_type?.display || "-";
// Modality column (code with full term tooltip)
const modality = document.createElement("td");
const modalityCode = record.series_modality?.code || "-";
const modalityDisplay = record.series_modality?.display;
modality.textContent = modalityCode;
if (modalityDisplay) {
modality.title = modalityDisplay;
}

row.replaceChildren(
recordId,
encounter,
subject,
age,
fileSize,
imageType
modality
);
return row;
});
Expand Down
Loading