diff --git a/bfd9000_web/archive/constants.py b/bfd9000_web/archive/constants.py index 59069a3..60b438c 100644 --- a/bfd9000_web/archive/constants.py +++ b/bfd9000_web/archive/constants.py @@ -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' @@ -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" diff --git a/bfd9000_web/archive/management/commands/import_valuesets.py b/bfd9000_web/archive/management/commands/import_valuesets.py new file mode 100644 index 0000000..ed2c9d1 --- /dev/null +++ b/bfd9000_web/archive/management/commands/import_valuesets.py @@ -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}'." + )) diff --git a/bfd9000_web/archive/management/commands/initialize.py b/bfd9000_web/archive/management/commands/initialize.py index 847afb3..703b7e6 100644 --- a/bfd9000_web/archive/management/commands/initialize.py +++ b/bfd9000_web/archive/management/commands/initialize.py @@ -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.""" @@ -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) diff --git a/bfd9000_web/archive/management/importers/valuesets.py b/bfd9000_web/archive/management/importers/valuesets.py new file mode 100644 index 0000000..ebdb883 --- /dev/null +++ b/bfd9000_web/archive/management/importers/valuesets.py @@ -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() diff --git a/bfd9000_web/archive/migrations/0002_seed_codings.py b/bfd9000_web/archive/migrations/0002_seed_codings.py index 998ee31..5ad4b2f 100644 --- a/bfd9000_web/archive/migrations/0002_seed_codings.py +++ b/bfd9000_web/archive/migrations/0002_seed_codings.py @@ -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", @@ -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", ""), @@ -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", diff --git a/bfd9000_web/archive/serializers.py b/bfd9000_web/archive/serializers.py index a2f6e7a..f11dc64 100644 --- a/bfd9000_web/archive/serializers.py +++ b/bfd9000_web/archive/serializers.py @@ -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: @@ -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) @@ -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) @@ -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) @@ -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: patient_orientation = ['A', 'F'] with transaction.atomic(): @@ -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, diff --git a/bfd9000_web/archive/templates/archive/record_detail.html b/bfd9000_web/archive/templates/archive/record_detail.html index b20da12..f86590f 100644 --- a/bfd9000_web/archive/templates/archive/record_detail.html +++ b/bfd9000_web/archive/templates/archive/record_detail.html @@ -114,10 +114,6 @@

Series & Acquisition

Modality - - - Image Type - - - Patient Orientation - @@ -182,7 +178,6 @@

Series & Acquisition

// 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('\\') : '-'; diff --git a/bfd9000_web/archive/templates/archive/records.html b/bfd9000_web/archive/templates/archive/records.html index 4714676..e9da3a2 100644 --- a/bfd9000_web/archive/templates/archive/records.html +++ b/bfd9000_web/archive/templates/archive/records.html @@ -28,7 +28,7 @@ Subject Age File Size - Image Type + Modality @@ -189,9 +189,14 @@ 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, @@ -199,7 +204,7 @@ subject, age, fileSize, - imageType + modality ); return row; }); diff --git a/bfd9000_web/archive/templates/archive/scan.html b/bfd9000_web/archive/templates/archive/scan.html index a086f8e..faea645 100644 --- a/bfd9000_web/archive/templates/archive/scan.html +++ b/bfd9000_web/archive/templates/archive/scan.html @@ -143,17 +143,7 @@

Record Metadata

- - - - -
- -
@@ -247,11 +237,8 @@

Scanner Device Info

aiFile: null, requiresServerTransform: false, transformOps: [], - aiImageTypeCode: null, - aiPatientOrientation: null, valuesets: { record_types: [], - modalities: [] } }; const defaultOperatorDisplay = "{{ operator_display|default:''|escapejs }}"; @@ -306,7 +293,7 @@

Scanner Device Info

async function createRecord(encounterId, formData) { const csrftoken = getCsrfToken(); - + const response = await fetch((window.script_name || '') + `/api/encounters/${encounterId}/records/`, { method: 'POST', headers: { @@ -390,7 +377,12 @@

Scanner Device Info

const r = await resp2.json(); console.log(r); - return result; + return { + ...result, + class_code: r.code, + class_prediction: r.prediction, + class_probability: r.probability, + }; } async function performScannerScan() { @@ -583,38 +575,10 @@

Scanner Device Info

state.aiFile = file; state.requiresServerTransform = false; state.transformOps = []; - state.aiImageTypeCode = null; - state.aiPatientOrientation = null; showFilePreview(file, file); updateSubmitButton(); } - function mapAIType(typePrediction) { - const mapping = { - 'Lateral': { - recordType: '201456002', - imageType: 'L', - patientOrientation: ['A', 'F'], - }, - 'Hip & Pelvis': { - recordType: '268425006', - imageType: 'P', - patientOrientation: null, - }, - 'Foot & Ankle': { - recordType: '1597004', - imageType: 'FA', - patientOrientation: null, - }, - 'Hand & Wrist': { - recordType: '39714003', - imageType: 'H', - patientOrientation: null, - }, - }; - return mapping[typePrediction] || null; - } - // ======================================== // UI UPDATE FUNCTIONS // ======================================== @@ -695,17 +659,21 @@

Scanner Device Info

function populateSelect(selectId, options) { const select = document.getElementById(selectId); select.innerHTML = ''; + const maxCodeLength = Math.max( + 1, + ...options.map(opt => String(opt.id || '').length) + ); options.forEach(opt => { const option = document.createElement('option'); option.value = opt.id; - option.textContent = opt.display; + const code = String(opt.id || '').padEnd(maxCodeLength, ' '); + option.textContent = `[${code}] - ${opt.display}`; select.appendChild(option); }); } function clearFormFields() { document.getElementById('record-type').value = ''; - document.getElementById('modality').value = ''; document.getElementById('operator').value = defaultOperatorDisplay; // Reset acquisition date to today document.getElementById('acquisition-date').valueAsDate = new Date(); @@ -718,9 +686,8 @@

Scanner Device Info

const hasEncounter = state.encounter !== null; const hasFile = state.file !== null; const hasRecordType = document.getElementById('record-type').value !== ''; - const hasModality = document.getElementById('modality').value !== ''; - const canSubmit = hasSubject && hasEncounter && hasFile && hasRecordType && hasModality; + const canSubmit = hasSubject && hasEncounter && hasFile && hasRecordType; document.getElementById('submit-button').disabled = !canSubmit; // Update AI and image manipulation buttons (need file and must be PNG) @@ -792,7 +759,7 @@

Scanner Device Info

// ======================================== // IMAGE MANIPULATION FUNCTIONS // ======================================== - + // modify image by rotating / flipping async function modifyImage(degrees, flip = false) { if (!state.aiFile) return; @@ -934,8 +901,6 @@

Scanner Device Info

state.aiFile = null; state.requiresServerTransform = false; state.transformOps = []; - state.aiImageTypeCode = null; - state.aiPatientOrientation = null; document.getElementById('file-preview').classList.add('hidden'); clearFormFields(); updateSubmitButton(); @@ -965,8 +930,6 @@

Scanner Device Info

state.file = file; state.transformOps = []; - state.aiImageTypeCode = null; - state.aiPatientOrientation = null; if (ext === 'png') { state.requiresServerTransform = false; state.previewFile = file; @@ -1046,17 +1009,22 @@

Scanner Device Info

try { const result = await queryAI(state.aiFile); - // Map AI type_prediction to record_type select value - if (result.type_prediction) { - const mapped = mapAIType(result.type_prediction); - const recordTypeSelect = document.getElementById('record-type'); - if (mapped && mapped.recordType) { - recordTypeSelect.value = mapped.recordType; - state.aiImageTypeCode = mapped.imageType; - state.aiPatientOrientation = mapped.patientOrientation; + const recordTypeSelect = document.getElementById('record-type'); + const aiCode = result.class_code || result.code; + if (aiCode) { + const option = state.valuesets.record_types.find(x => x.id === aiCode); + if (option) { + recordTypeSelect.value = aiCode; + } + } else if (result.type_prediction) { + const option = state.valuesets.record_types.find(x => x.display === result.type_prediction); + if (option) { + recordTypeSelect.value = option.id; } + } else { + showAIStatus('AI classification failed: no valid code returned. Please classify manually.', true); } - + if (result.flip || result.rotation) { try { await modifyImage(result.rotation || 0, result.flip || false); @@ -1065,7 +1033,7 @@

Scanner Device Info

} } - showAIStatus(`Fields populated from AI analysis. Confidence: ${result.type_probability.toFixed(2)}`); + showAIStatus(`Fields populated from AI analysis. Confidence: ${(result.class_probability ?? result.type_probability)?.toFixed(2) ?? 'N/A'}`); updateSubmitButton(); } catch (error) { showAIStatus('AI processing failed: ' + error.message, true); @@ -1089,16 +1057,16 @@

Scanner Device Info

if (state.previewFile) { formData.append('thumbnail_preview', state.previewFile, 'thumbnail_preview.png'); } - formData.append('record_type', document.getElementById('record-type').value); - formData.append('modality', document.getElementById('modality').value); - if (state.aiImageTypeCode) { - formData.append('image_type', state.aiImageTypeCode); + const recordType = document.getElementById('record-type').value; + if (!recordType) { + throw new Error('Record type is required.'); } - if (state.aiPatientOrientation && state.aiPatientOrientation.length === 2) { - formData.append('patient_orientation', state.aiPatientOrientation[0]); - formData.append('patient_orientation', state.aiPatientOrientation[1]); + if (!state.valuesets.record_types.find(x => x.id === recordType)) { + throw new Error(`Expected record type ${recordType} to exist in valueset record_types`); } + formData.append('record_type', recordType); + if (state.requiresServerTransform && state.transformOps.length > 0) { formData.append('image_transform_ops', JSON.stringify(state.transformOps)); } @@ -1154,8 +1122,6 @@

Scanner Device Info

state.aiFile = null; state.requiresServerTransform = false; state.transformOps = []; - state.aiImageTypeCode = null; - state.aiPatientOrientation = null; document.getElementById('file-upload').value = ''; document.getElementById('file-preview').classList.add('hidden'); document.getElementById('scan-status').textContent = ''; @@ -1179,17 +1145,12 @@

Scanner Device Info

// Load valuesets try { - const [recordTypes, modalities] = await Promise.all([ - fetchValueset('record_types'), - fetchValueset('modalities') - ]); + const record_types = await fetchValueset('record_types'); - state.valuesets.record_types = recordTypes; - state.valuesets.modalities = modalities; + state.valuesets.record_types = record_types; console.log(state.valuesets); - populateSelect('record-type', recordTypes); - populateSelect('modality', modalities); + populateSelect('record-type', record_types); } catch (error) { console.error('Failed to load valuesets:', error); alert('Failed to load form options. Please refresh the page.'); @@ -1215,7 +1176,7 @@

Scanner Device Info

}); // Watch form fields for changes - ['record-type', 'modality'].forEach(id => { + ['record-type'].forEach(id => { document.getElementById(id).addEventListener('change', updateSubmitButton); }); diff --git a/bfd9000_web/archive/tests/test_api_flows.py b/bfd9000_web/archive/tests/test_api_flows.py index c0baca7..59e916e 100644 --- a/bfd9000_web/archive/tests/test_api_flows.py +++ b/bfd9000_web/archive/tests/test_api_flows.py @@ -33,8 +33,8 @@ def setUp(self): # Ensure codings exist self.rt, _ = Coding.objects.get_or_create( system=SYSTEM_RECORD_TYPE, - code='201456002', - defaults={'display': 'Cephalogram'}, + code='L', + defaults={'display': 'Lateral Cephalogram'}, ) self.orient, _ = Coding.objects.get_or_create( system=SYSTEM_ORIENTATION, diff --git a/bfd9000_web/archive/tests/test_records.py b/bfd9000_web/archive/tests/test_records.py index 01794dc..2f433bf 100644 --- a/bfd9000_web/archive/tests/test_records.py +++ b/bfd9000_web/archive/tests/test_records.py @@ -85,8 +85,8 @@ def setUp(self): # Create codings self.rt_lateral, _ = Coding.objects.get_or_create( system=SYSTEM_RECORD_TYPE, - code='201456002', - defaults={'display': 'Cephalogram'} + code='L', + defaults={'display': 'Lateral Cephalogram'} ) self.orient_left, _ = Coding.objects.get_or_create( system=SYSTEM_ORIENTATION, diff --git a/bfd9000_web/archive/tests/test_role_permissions.py b/bfd9000_web/archive/tests/test_role_permissions.py index aea3e83..7e34928 100644 --- a/bfd9000_web/archive/tests/test_role_permissions.py +++ b/bfd9000_web/archive/tests/test_role_permissions.py @@ -38,8 +38,8 @@ def setUp(self): ) self.record_type, _ = Coding.objects.get_or_create( system=SYSTEM_RECORD_TYPE, - code="201456002", - defaults={"display": "Cephalogram"}, + code="L", + defaults={"display": "Lateral Cephalogram"}, ) self.orientation, _ = Coding.objects.get_or_create( system=SYSTEM_ORIENTATION, diff --git a/bfd9000_web/archive/tests/test_valuesets.py b/bfd9000_web/archive/tests/test_valuesets.py index 8fe227d..6644075 100644 --- a/bfd9000_web/archive/tests/test_valuesets.py +++ b/bfd9000_web/archive/tests/test_valuesets.py @@ -33,9 +33,9 @@ def setUp(self): self.record_types_valueset, _ = ValueSet.objects.get_or_create( slug="record_types", defaults={ - "url": "https://orthodontics.case.edu/fhir/ValueSet/record-types", - "name": "RecordTypes", - "title": "Record types", + "url": "https://orthodontics.case.edu/fhir/cwru-ortho-record-types", + "name": "CWRUOrthoRecordTypes", + "title": "CWRU Ortho Record Types", }, ) self.orientations_valueset, _ = ValueSet.objects.get_or_create( @@ -71,11 +71,11 @@ def setUp(self): }, ) - # Record types (using SNOMED codes from migration) + # Record types (CWRU codes) self.rt_lateral, _ = Coding.objects.get_or_create( system=SYSTEM_RECORD_TYPE, - code='201456002', - defaults={'display': 'Cephalogram'}, + code='L', + defaults={'display': 'Lateral Cephalogram'}, ) ValueSetConcept.objects.get_or_create( valueset=self.record_types_valueset, @@ -83,8 +83,8 @@ def setUp(self): ) self.rt_pa, _ = Coding.objects.get_or_create( system=SYSTEM_RECORD_TYPE, - code='268425006', - defaults={'display': 'Pelvis X-ray'}, + code='F', + defaults={'display': 'Frontal Cephalogram'}, ) ValueSetConcept.objects.get_or_create( valueset=self.record_types_valueset, @@ -227,10 +227,10 @@ def test_record_types(self): self.assertIn('display', item) self.assertEqual(len(item), 2, "Should only have 'id' and 'display' fields") - # Verify expected values (SNOMED codes) + # Verify expected values (CWRU codes) ids = [item['id'] for item in response.data] - self.assertIn('201456002', ids) # Cephalogram - self.assertIn('268425006', ids) # Pelvis X-ray + self.assertIn('L', ids) # Lateral Cephalogram + self.assertIn('F', ids) # Frontal Cephalogram def test_orientations(self): """Should return orientations with correct structure""" diff --git a/bfd9000_web/docker-compose.yml b/bfd9000_web/docker-compose.yml index 2dc89f4..5b955bd 100644 --- a/bfd9000_web/docker-compose.yml +++ b/bfd9000_web/docker-compose.yml @@ -29,7 +29,18 @@ services: # Enable BOTH lines below to emulate a proxy-mounted prefix locally. - DJANGO_FORCE_SCRIPT_NAME=/bfd9000 - SCRIPT_NAME=/bfd9000 - - BFD9020_BASE_URL=https://wingate.case.edu/bfd9020 + - BFD9020_BASE_URL=http://localhost:9020 + # - BFD9020_BASE_URL=https://wingate.case.edu/bfd9020 + + bfd9020: + image: ghcr.io/open-ortho/edu.case.bfd9020:local + container_name: bfd9020 + ports: + - "9020:9020" + environment: + LOG_LEVEL: "INFO" + ROOT_PATH: "" + ENABLE_DOCS: "true" volumes: media_volume: diff --git a/bfd9000_web/docs/api_requirements.md b/bfd9000_web/docs/api_requirements.md index 612e45a..7476451 100644 --- a/bfd9000_web/docs/api_requirements.md +++ b/bfd9000_web/docs/api_requirements.md @@ -222,7 +222,7 @@ Based on the use cases defined in `use_cases.md`, the following API endpoints ar **Supported Valueset Types**: -- **`record_types`**: Available record type options +- **`record_types`**: Available record type options (CWRU Ortho Record Types ValueSet: `https://orthodontics.case.edu/fhir/cwru-ortho-record-types`) - **`orientations`**: Available orientation options - **`collections`**: Available collection names - **`sex_options`**: Available sex/gender options @@ -232,9 +232,9 @@ Based on the use cases defined in `use_cases.md`, the following API endpoints ar ```json [ - {"id": "lateral", "display": "Lateral"}, - {"id": "pa", "display": "PA"}, - {"id": "hand", "display": "Hand"} + {"id": "L", "display": "Lateral Cephalogram"}, + {"id": "F", "display": "Frontal Cephalogram"}, + {"id": "H", "display": "Radiograph of Hand & Wrist"} ] ``` @@ -321,7 +321,7 @@ Based on the use cases defined in `use_cases.md`, the following API endpoints ar - `thumbnail_preview` (file upload, optional): preprocessed preview PNG from UI pipeline; used as thumbnail source when provided - `record_type` (string, required): value from `/api/valuesets/?type=record_types` - `orientation` (string, required): value from `/api/valuesets/?type=orientations` - - `modality` (string, required): value from `/api/valuesets/?type=modalities` + - `modality` (string, optional): value from `/api/valuesets/?type=modalities` (if omitted, inferred from `record_type`) - `operator` (string, optional - defaults to authenticated user) - `acquisition_date` (date, optional - defaults to today) - `notes` (string, optional) @@ -614,7 +614,7 @@ All error responses follow this structure: - Maximum file size: 100MB (configurable) - `record_type` must be a valid value from `/api/valuesets/?type=record_types` - `orientation` must be a valid value from `/api/valuesets/?type=orientations` -- `modality` must be a valid value from `/api/valuesets/?type=modalities` +- `modality` must be a valid value from `/api/valuesets/?type=modalities` when provided; otherwise it is inferred from `record_type` - Encounter subject must already belong to a valid collection; uploads are rejected otherwise - `acquisition_date` cannot be in the future - File content must match file extension (validated via magic bytes) diff --git a/bfd9000_web/docs/data_model.md b/bfd9000_web/docs/data_model.md index ee75d59..9e65d6d 100644 --- a/bfd9000_web/docs/data_model.md +++ b/bfd9000_web/docs/data_model.md @@ -2,7 +2,6 @@ This document defines the archive concepts and rationale used by the Django models. - ## Core hierarchy Imaging data is organized as: @@ -103,7 +102,7 @@ Owns per-instance/acquisition fields: These are different concepts and must never be substituted. - `record_type`: - - SNOMED clinical study type. + - CWRU record type code (ValueSet: `https://orthodontics.case.edu/fhir/cwru-ortho-record-types`). - Owned by `Series`. - Used for clinical grouping/filtering. - `image_type`: @@ -111,6 +110,18 @@ These are different concepts and must never be substituted. - Owned by `Record`. - Used for source compatibility/import semantics. +## Codes, Valuesets, and Dropdowns + +Clinical codes (record types, modalities, orientations, procedures) are stored as `Coding` rows and grouped into `ValueSet`s. The UI uses the valueset API (`/api/valuesets/?type=...`) to populate dropdowns, and the same codes are used when exporting to open standards (FHIR/DICOM) so integrations stay interoperable. + +Valuesets are refreshed from canonical FHIR sources using the generic importer: + +``` +python manage.py import_valuesets --all +``` + +This is idempotent: it updates existing codes, adds new ones, and removes valueset links for retired codes so they no longer appear in dropdowns. Update valueset sources in `bfd9000_web/archive/constants.py` if a terminology endpoint changes or a new valueset should be managed by the importer. + ## Clarifications from the refactor - `Record.sop_class_uid` is removed; instance identity is `Record.sop_instance_uid`.