From 98293b87cb4e161b63571814796ec14f026851b2 Mon Sep 17 00:00:00 2001 From: Brandon Li <48413902+aspiringLich@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:19:42 -0500 Subject: [PATCH 01/10] replace selectors for record type / modality with image type --- .../archive/templates/archive/scan.html | 124 +++++++++--------- 1 file changed, 59 insertions(+), 65 deletions(-) diff --git a/bfd9000_web/archive/templates/archive/scan.html b/bfd9000_web/archive/templates/archive/scan.html index a086f8e..be1c9c1 100644 --- a/bfd9000_web/archive/templates/archive/scan.html +++ b/bfd9000_web/archive/templates/archive/scan.html @@ -138,22 +138,12 @@

Record Metadata

min-width: 8rem; } - +
- -
- - -
- -
@@ -250,6 +240,7 @@

Scanner Device Info

aiImageTypeCode: null, aiPatientOrientation: null, valuesets: { + image_types: [], record_types: [], modalities: [] } @@ -306,7 +297,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: { @@ -589,32 +580,6 @@

Scanner Device Info

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 // ======================================== @@ -698,14 +663,13 @@

Scanner Device Info

options.forEach(opt => { const option = document.createElement('option'); option.value = opt.id; - option.textContent = opt.display; + option.textContent = `${opt.display} (${opt.id})`; select.appendChild(option); }); } function clearFormFields() { - document.getElementById('record-type').value = ''; - document.getElementById('modality').value = ''; + document.getElementById('image-type').value = ''; document.getElementById('operator').value = defaultOperatorDisplay; // Reset acquisition date to today document.getElementById('acquisition-date').valueAsDate = new Date(); @@ -717,10 +681,9 @@

Scanner Device Info

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

Scanner Device Info

// ======================================== // IMAGE MANIPULATION FUNCTIONS // ======================================== - + // modify image by rotating / flipping async function modifyImage(degrees, flip = false) { if (!state.aiFile) return; @@ -1046,17 +1009,19 @@

Scanner Device Info

try { const result = await queryAI(state.aiFile); - // Map AI type_prediction to record_type select value + // Map AI type_prediction to image_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; + let id = state.valuesets.image_types.find(x => x.display === result.type_prediction).id; + console.log(id); + + const imageTypeSelect = document.getElementById('image-type'); + if (id) { + imageTypeSelect.value = id; + state.aiImageTypeCode = id; + state.aiPatientOrientation = null; // TODO: does this do anything? } } - + if (result.flip || result.rotation) { try { await modifyImage(result.rotation || 0, result.flip || false); @@ -1089,12 +1054,40 @@

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); + // FIXME: currently this is sorta hardcoded because we only support dental scans rn but this should + // be updated with more logic if we support more types of scans + // Find any modality where x.id == 'RG' + + const set_record_type_and_modality = (record_type, modality) => { + console.assert(state.valuesets.record_types.find(x => x.id === record_type), `Expected record type ${record_type} to exist is valueset record_types`); + formData.append('record_type', record_type); + console.assert(state.valuesets.modalities.find(x => x.id === modality), `Expected modality ${modality} to exist in valueset modalities`); + formData.append('modality', modality); + console.log(`Record type: ${record_type}, Modality: ${modality}`); + }; + + let image_type = document.getElementById('image-type').value; + switch (image_type) { + // Skeletal X-ray of ankle and foot, Radiographic imaging + case 'FA': set_record_type_and_modality('1597004', 'RG'); break; + // Skeletal X-ray of wrist and hand, Radiographic imaging + case 'H': set_record_type_and_modality('39714003', 'RG'); break; + // Cephalogram, Radiographic imaging + case 'L': set_record_type_and_modality('201456002', 'RG'); break; + // Pelvis X-ray, Radiographic imaging + case 'P': set_record_type_and_modality('268425006', 'RG'); break; + // Dental model, 3D Manufacturing Modeling System + case 'SM': set_record_type_and_modality('302189007', 'M3D'); break; + case 'FM': + case 'F': + throw new Error('Image type has no match in record type codings'); + default: + throw new Error(`Unexpected image type ${image_type}. This is a bug.`); + } + formData.append('image_type', image_type); + if (state.aiPatientOrientation && state.aiPatientOrientation.length === 2) { formData.append('patient_orientation', state.aiPatientOrientation[0]); formData.append('patient_orientation', state.aiPatientOrientation[1]); @@ -1179,17 +1172,18 @@

Scanner Device Info

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

Scanner Device Info

}); // Watch form fields for changes - ['record-type', 'modality'].forEach(id => { + ['image-type'].forEach(id => { document.getElementById(id).addEventListener('change', updateSubmitButton); }); From 8d1553052a47b81ef7ecdfe6f8c477466ec8fc7c Mon Sep 17 00:00:00 2001 From: Brandon Li <48413902+aspiringLich@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:35:57 -0500 Subject: [PATCH 02/10] removed unused fields in scan state: aiImageTypeCode, aiPatientOrientation --- bfd9000_web/archive/templates/archive/scan.html | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/bfd9000_web/archive/templates/archive/scan.html b/bfd9000_web/archive/templates/archive/scan.html index be1c9c1..7ca4fe3 100644 --- a/bfd9000_web/archive/templates/archive/scan.html +++ b/bfd9000_web/archive/templates/archive/scan.html @@ -237,8 +237,6 @@

Scanner Device Info

aiFile: null, requiresServerTransform: false, transformOps: [], - aiImageTypeCode: null, - aiPatientOrientation: null, valuesets: { image_types: [], record_types: [], @@ -574,8 +572,6 @@

Scanner Device Info

state.aiFile = file; state.requiresServerTransform = false; state.transformOps = []; - state.aiImageTypeCode = null; - state.aiPatientOrientation = null; showFilePreview(file, file); updateSubmitButton(); } @@ -897,8 +893,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(); @@ -928,8 +922,6 @@

Scanner Device Info

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

Scanner Device Info

const imageTypeSelect = document.getElementById('image-type'); if (id) { imageTypeSelect.value = id; - state.aiImageTypeCode = id; - state.aiPatientOrientation = null; // TODO: does this do anything? } } @@ -1088,10 +1078,6 @@

Scanner Device Info

} formData.append('image_type', image_type); - if (state.aiPatientOrientation && state.aiPatientOrientation.length === 2) { - formData.append('patient_orientation', state.aiPatientOrientation[0]); - formData.append('patient_orientation', state.aiPatientOrientation[1]); - } if (state.requiresServerTransform && state.transformOps.length > 0) { formData.append('image_transform_ops', JSON.stringify(state.transformOps)); } @@ -1147,8 +1133,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 = ''; From 746dda859c63dadd5a8ac9aacf236df4edb770b5 Mon Sep 17 00:00:00 2001 From: Brandon Li <48413902+aspiringLich@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:10:34 -0500 Subject: [PATCH 03/10] review pass --- bfd9000_web/archive/templates/archive/scan.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bfd9000_web/archive/templates/archive/scan.html b/bfd9000_web/archive/templates/archive/scan.html index 7ca4fe3..d9c5f4a 100644 --- a/bfd9000_web/archive/templates/archive/scan.html +++ b/bfd9000_web/archive/templates/archive/scan.html @@ -141,7 +141,7 @@

Record Metadata

+
@@ -238,9 +238,7 @@

Scanner Device Info

requiresServerTransform: false, transformOps: [], valuesets: { - image_types: [], record_types: [], - modalities: [] } }; const defaultOperatorDisplay = "{{ operator_display|default:''|escapejs }}"; @@ -379,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() { @@ -665,7 +668,7 @@

Scanner Device Info

} function clearFormFields() { - document.getElementById('image-type').value = ''; + document.getElementById('record-type').value = ''; document.getElementById('operator').value = defaultOperatorDisplay; // Reset acquisition date to today document.getElementById('acquisition-date').valueAsDate = new Date(); @@ -677,9 +680,9 @@

Scanner Device Info

const hasSubject = state.subject !== null; const hasEncounter = state.encounter !== null; const hasFile = state.file !== null; - const hasImageType = document.getElementById('image-type').value !== ''; + const hasRecordType = document.getElementById('record-type').value !== ''; - const canSubmit = hasSubject && hasEncounter && hasFile && hasImageType; + const canSubmit = hasSubject && hasEncounter && hasFile && hasRecordType; document.getElementById('submit-button').disabled = !canSubmit; // Update AI and image manipulation buttons (need file and must be PNG) @@ -1001,13 +1004,17 @@

Scanner Device Info

try { const result = await queryAI(state.aiFile); - // Map AI type_prediction to image_type select value - if (result.type_prediction) { - let id = state.valuesets.image_types.find(x => x.display === result.type_prediction).id; - - const imageTypeSelect = document.getElementById('image-type'); - if (id) { - imageTypeSelect.value = id; + const recordTypeSelect = document.getElementById('record-type'); + const aiCode = result.code || result.record_type || result.prediction || result.class_code || result.class_prediction; + 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; } } @@ -1044,40 +1051,14 @@

Scanner Device Info

formData.append('thumbnail_preview', state.previewFile, 'thumbnail_preview.png'); } - // FIXME: currently this is sorta hardcoded because we only support dental scans rn but this should - // be updated with more logic if we support more types of scans - // Find any modality where x.id == 'RG' - - const set_record_type_and_modality = (record_type, modality) => { - if (!state.valuesets.record_types.find(x => x.id === record_type)) - throw new Error(`Expected record type ${record_type} to exist in valueset record_types`) - formData.append('record_type', record_type); - if (!state.valuesets.modalities.find(x => x.id === modality)) - throw new Error(`Expected modality ${modality} to exist in valueset modalities`) - formData.append('modality', modality); - console.log(`Record type: ${record_type}, Modality: ${modality}`); - }; - - let image_type = document.getElementById('image-type').value; - switch (image_type) { - // Skeletal X-ray of ankle and foot, Radiographic imaging - case 'FA': set_record_type_and_modality('1597004', 'RG'); break; - // Skeletal X-ray of wrist and hand, Radiographic imaging - case 'H': set_record_type_and_modality('39714003', 'RG'); break; - // Cephalogram, Radiographic imaging - case 'L': set_record_type_and_modality('201456002', 'RG'); break; - // Pelvis X-ray, Radiographic imaging - case 'P': set_record_type_and_modality('268425006', 'RG'); break; - // Dental model, 3D Manufacturing Modeling System - case 'SM': set_record_type_and_modality('302189007', 'M3D'); break; - case 'FM': - case 'F': - throw new Error('Image type has no match in record type codings'); - default: - throw new Error(`Unexpected image type ${image_type}. This is a bug.`); - + const recordType = document.getElementById('record-type').value; + if (!recordType) { + throw new Error('Record type is required.'); + } + 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('image_type', image_type); + formData.append('record_type', recordType); if (state.requiresServerTransform && state.transformOps.length > 0) { formData.append('image_transform_ops', JSON.stringify(state.transformOps)); @@ -1157,18 +1138,12 @@

Scanner Device Info

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

Scanner Device Info

}); // Watch form fields for changes - ['image-type'].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/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..a1eaae4 100644 --- a/bfd9000_web/docs/data_model.md +++ b/bfd9000_web/docs/data_model.md @@ -103,7 +103,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`: From 2fb9966754005351fe7a947805f6ce375ccc77d0 Mon Sep 17 00:00:00 2001 From: Toni Magni Date: Tue, 3 Mar 2026 18:34:12 -0500 Subject: [PATCH 05/10] feat: implement FHIR ValueSet import functionality and update initialization command --- bfd9000_web/archive/constants.py | 9 +++++ .../commands/import_record_types.py | 17 --------- .../management/commands/import_valuesets.py | 34 ++++++++++++++++++ .../archive/management/commands/initialize.py | 9 ++++- .../{record_types.py => valuesets.py} | 35 ++++++++----------- 5 files changed, 66 insertions(+), 38 deletions(-) delete mode 100644 bfd9000_web/archive/management/commands/import_record_types.py create mode 100644 bfd9000_web/archive/management/commands/import_valuesets.py rename bfd9000_web/archive/management/importers/{record_types.py => valuesets.py} (81%) diff --git a/bfd9000_web/archive/constants.py b/bfd9000_web/archive/constants.py index 6296c95..60b438c 100644 --- a/bfd9000_web/archive/constants.py +++ b/bfd9000_web/archive/constants.py @@ -11,6 +11,9 @@ 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', @@ -27,6 +30,12 @@ '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_record_types.py b/bfd9000_web/archive/management/commands/import_record_types.py deleted file mode 100644 index 7da5a5b..0000000 --- a/bfd9000_web/archive/management/commands/import_record_types.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from django.core.management.base import BaseCommand - -from archive.management.importers.record_types import import_record_types - - -class Command(BaseCommand): - help = "Import CWRU record type codes from FHIR ValueSet expansion" - - def handle(self, *args: Any, **options: Any) -> None: - count = import_record_types() - self.stdout.write(self.style.SUCCESS( - f"Imported {count} record type codes into valueset 'record_types'." - )) 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 665ab92..6ba067e 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.""" @@ -76,7 +78,12 @@ def handle(self, *args, **options) -> None: call_command("migrate", verbosity=verbosity) self.stdout.write(self.style.NOTICE("Importing record types...")) - call_command("import_record_types", verbosity=verbosity) + call_command( + "import_valuesets", + slug="record_types", + expand_url=VALUESET_EXPAND_URLS["record_types"], + verbosity=verbosity, + ) if not options["skip_superuser"]: self._run_createsuperuser(options, verbosity) diff --git a/bfd9000_web/archive/management/importers/record_types.py b/bfd9000_web/archive/management/importers/valuesets.py similarity index 81% rename from bfd9000_web/archive/management/importers/record_types.py rename to bfd9000_web/archive/management/importers/valuesets.py index 953da2d..6f47388 100644 --- a/bfd9000_web/archive/management/importers/record_types.py +++ b/bfd9000_web/archive/management/importers/valuesets.py @@ -1,47 +1,44 @@ from __future__ import annotations import json -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Dict, List from urllib.request import urlopen from archive.models import Coding, ValueSet, ValueSetConcept - -EXPAND_URL = "http://terminology.open-ortho.org/fhir/cwru-ortho-record-types/$expand" -VALUESET_SLUG = "record_types" - - -def import_record_types(expand_url: str = EXPAND_URL) -> int: +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) + 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") + raw = response.read().decode("ascii", errors="ignore") data: Dict[str, Any] = json.loads(raw) return data - -def _upsert_valueset(payload: Dict[str, Any]) -> ValueSet: +def _upsert_valueset(payload: Dict[str, Any], slug: str) -> ValueSet: compose = payload.get("compose") or {} - include: List[Dict[str, Any]] = list(compose.get("include") 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[Dict[str, Any]] = list(expansion.get("contains") 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=VALUESET_SLUG, + slug=slug, defaults={ "url": payload.get("url", ""), - "name": payload.get("name", VALUESET_SLUG), + "name": payload.get("name", slug), "title": payload.get("title", ""), "description": payload.get("description", ""), "version": payload.get("version", ""), @@ -54,7 +51,7 @@ def _upsert_valueset(payload: Dict[str, Any]) -> ValueSet: if not created: updates: Dict[str, str] = { "url": payload.get("url", ""), - "name": payload.get("name", VALUESET_SLUG), + "name": payload.get("name", slug), "title": payload.get("title", ""), "description": payload.get("description", ""), "version": payload.get("version", ""), @@ -72,10 +69,9 @@ def _upsert_valueset(payload: Dict[str, Any]) -> ValueSet: return valueset - def _upsert_codings(valueset: ValueSet, payload: Dict[str, Any]) -> List[Coding]: expansion = payload.get("expansion") or {} - contains: Iterable[Dict[str, Any]] = expansion.get("contains") or [] + contains = expansion.get("contains") or [] codings: List[Coding] = [] for concept in contains: @@ -106,7 +102,6 @@ def _upsert_codings(valueset: ValueSet, payload: Dict[str, Any]) -> List[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) From 201db35ada11719907f25089873485f9d12e1e7b Mon Sep 17 00:00:00 2001 From: Toni Magni Date: Tue, 3 Mar 2026 18:37:34 -0500 Subject: [PATCH 06/10] fix: update import command to load all valuesets and enhance documentation on valuesets --- .../archive/management/commands/initialize.py | 5 ++--- bfd9000_web/docs/data_model.md | 13 ++++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/bfd9000_web/archive/management/commands/initialize.py b/bfd9000_web/archive/management/commands/initialize.py index 6ba067e..5602f0e 100644 --- a/bfd9000_web/archive/management/commands/initialize.py +++ b/bfd9000_web/archive/management/commands/initialize.py @@ -77,11 +77,10 @@ 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 record types...")) + self.stdout.write(self.style.NOTICE("Importing all valuesets...")) call_command( "import_valuesets", - slug="record_types", - expand_url=VALUESET_EXPAND_URLS["record_types"], + "--all", verbosity=verbosity, ) diff --git a/bfd9000_web/docs/data_model.md b/bfd9000_web/docs/data_model.md index a1eaae4..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: @@ -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`. From 0e0d14d4cdf0995757a1482b7e4e3ba93f4d7157 Mon Sep 17 00:00:00 2001 From: Toni Magni Date: Tue, 3 Mar 2026 21:21:04 -0500 Subject: [PATCH 07/10] fix: update BFD9020_BASE_URL to use localhost and enhance docker-compose configuration --- bfd9000_web/docker-compose.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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: From 1b36382feaa0e2b5bddef2884b61b3ebfa8f3633 Mon Sep 17 00:00:00 2001 From: Toni Magni Date: Tue, 3 Mar 2026 21:25:52 -0500 Subject: [PATCH 08/10] fix: update modality column to display code with tooltip for full term --- bfd9000_web/archive/templates/archive/records.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bfd9000_web/archive/templates/archive/records.html b/bfd9000_web/archive/templates/archive/records.html index 49aae3d..e9da3a2 100644 --- a/bfd9000_web/archive/templates/archive/records.html +++ b/bfd9000_web/archive/templates/archive/records.html @@ -189,9 +189,14 @@ const fileSize = document.createElement("td"); fileSize.textContent = formatFileSize(record.file_size); - // Modality column (plain text) + // Modality column (code with full term tooltip) const modality = document.createElement("td"); - modality.textContent = record.series_modality?.display || "-"; + const modalityCode = record.series_modality?.code || "-"; + const modalityDisplay = record.series_modality?.display; + modality.textContent = modalityCode; + if (modalityDisplay) { + modality.title = modalityDisplay; + } row.replaceChildren( recordId, From 09c5f1bc166764fe0346dd0f90455bd13630e452 Mon Sep 17 00:00:00 2001 From: Toni Magni Date: Tue, 3 Mar 2026 22:07:35 -0500 Subject: [PATCH 09/10] Fix: robust valueset import error handling, FHIR UTF-8 decoding, safe JS probability access, and AI class_code cascade. - initialize.py: Wrap import_valuesets in try/except and warn on failure as directed in PR review (no CLI flag). - importers/valuesets.py: Decode FHIR JSON response as UTF-8 per spec, not ascii. - scan.html: Use null-safe access for probability, simplify AI classification field to class_code||code, show user warning if AI fails classification. --- .../archive/management/commands/initialize.py | 14 +++++++++----- .../archive/management/importers/valuesets.py | 2 +- bfd9000_web/archive/templates/archive/scan.html | 17 +++++++++++++---- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/bfd9000_web/archive/management/commands/initialize.py b/bfd9000_web/archive/management/commands/initialize.py index 5602f0e..703b7e6 100644 --- a/bfd9000_web/archive/management/commands/initialize.py +++ b/bfd9000_web/archive/management/commands/initialize.py @@ -78,11 +78,15 @@ def handle(self, *args, **options) -> None: call_command("migrate", verbosity=verbosity) self.stdout.write(self.style.NOTICE("Importing all valuesets...")) - call_command( - "import_valuesets", - "--all", - verbosity=verbosity, - ) + 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 index 6f47388..ebdb883 100644 --- a/bfd9000_web/archive/management/importers/valuesets.py +++ b/bfd9000_web/archive/management/importers/valuesets.py @@ -19,7 +19,7 @@ def import_valueset(expand_url: str, slug: str) -> int: def _fetch_valueset(url: str) -> Dict[str, Any]: with urlopen(url) as response: - raw = response.read().decode("ascii", errors="ignore") + raw = response.read().decode("utf-8") data: Dict[str, Any] = json.loads(raw) return data diff --git a/bfd9000_web/archive/templates/archive/scan.html b/bfd9000_web/archive/templates/archive/scan.html index 2027e47..b00ddbc 100644 --- a/bfd9000_web/archive/templates/archive/scan.html +++ b/bfd9000_web/archive/templates/archive/scan.html @@ -143,7 +143,7 @@

Record Metadata

- @@ -659,10 +659,15 @@

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} (${opt.id})`; + const code = String(opt.id || '').padEnd(maxCodeLength, ' '); + option.textContent = `[${code}] - ${opt.display}`; select.appendChild(option); }); } @@ -1005,8 +1010,12 @@

Scanner Device Info

const result = await queryAI(state.aiFile); const recordTypeSelect = document.getElementById('record-type'); - const aiCode = result.code || result.record_type || result.prediction || result.class_code || result.class_prediction; + const aiCode = result.class_code || result.code; if (aiCode) { + // If no valid code, classification failed + } else { + showAIStatus('AI classification failed: no valid code returned. Please classify manually.', true); + } const option = state.valuesets.record_types.find(x => x.id === aiCode); if (option) { recordTypeSelect.value = aiCode; @@ -1026,7 +1035,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); From fe8e9223df4dc4bfff37e7a1ef39fd7f745f4d7a Mon Sep 17 00:00:00 2001 From: Toni Magni Date: Tue, 3 Mar 2026 22:23:35 -0500 Subject: [PATCH 10/10] fix: repair broken if/else block in handleAIFill causing SyntaxError --- bfd9000_web/archive/templates/archive/scan.html | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bfd9000_web/archive/templates/archive/scan.html b/bfd9000_web/archive/templates/archive/scan.html index b00ddbc..faea645 100644 --- a/bfd9000_web/archive/templates/archive/scan.html +++ b/bfd9000_web/archive/templates/archive/scan.html @@ -1012,10 +1012,6 @@

Scanner Device Info

const recordTypeSelect = document.getElementById('record-type'); const aiCode = result.class_code || result.code; if (aiCode) { - // If no valid code, classification failed - } else { - showAIStatus('AI classification failed: no valid code returned. Please classify manually.', true); - } const option = state.valuesets.record_types.find(x => x.id === aiCode); if (option) { recordTypeSelect.value = aiCode; @@ -1025,6 +1021,8 @@

Scanner Device Info

if (option) { recordTypeSelect.value = option.id; } + } else { + showAIStatus('AI classification failed: no valid code returned. Please classify manually.', true); } if (result.flip || result.rotation) {