@@ -189,9 +189,9 @@
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 (plain text)
+ const modality = document.createElement("td");
+ modality.textContent = record.series_modality?.display || "-";
row.replaceChildren(
recordId,
@@ -199,7 +199,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 d9c5f4a..2027e47 100644
--- a/bfd9000_web/archive/templates/archive/scan.html
+++ b/bfd9000_web/archive/templates/archive/scan.html
@@ -138,12 +138,12 @@ Record Metadata
min-width: 8rem;
}
-
+
- Image Type *
+ Record Type *
-
+
Loading...
@@ -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
Record Type *
-
+
Loading...
@@ -659,10 +659,15 @@ Scanner Device Info
function populateSelect(selectId, options) {
const select = document.getElementById(selectId);
select.innerHTML = 'Select... ';
+ 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) {