1367 lines
37 KiB
Python
1367 lines
37 KiB
Python
"""
|
|
Radiology app models for hospital management system.
|
|
Provides imaging orders, DICOM management, and radiology workflows.
|
|
"""
|
|
|
|
import uuid
|
|
from django.db import models
|
|
from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator
|
|
from django.utils import timezone
|
|
from django.conf import settings
|
|
from datetime import timedelta, datetime, date
|
|
from decimal import Decimal
|
|
import json
|
|
|
|
|
|
class ImagingStudy(models.Model):
|
|
"""
|
|
Imaging study model for radiology studies and DICOM management.
|
|
"""
|
|
MODALITY_CHOICES=[
|
|
('CR', 'Computed Radiography'),
|
|
('CT', 'Computed Tomography'),
|
|
('MR', 'Magnetic Resonance'),
|
|
('US', 'Ultrasound'),
|
|
('XA', 'X-Ray Angiography'),
|
|
('RF', 'Radiofluoroscopy'),
|
|
('DX', 'Digital Radiography'),
|
|
('MG', 'Mammography'),
|
|
('NM', 'Nuclear Medicine'),
|
|
('PT', 'Positron Emission Tomography'),
|
|
('OT', 'Other'),
|
|
]
|
|
BODY_PART_CHOICES=[
|
|
('HEAD', 'Head'),
|
|
('NECK', 'Neck'),
|
|
('CHEST', 'Chest'),
|
|
('ABDOMEN', 'Abdomen'),
|
|
('PELVIS', 'Pelvis'),
|
|
('SPINE', 'Spine'),
|
|
('EXTREMITY', 'Extremity'),
|
|
('BREAST', 'Breast'),
|
|
('HEART', 'Heart'),
|
|
('BRAIN', 'Brain'),
|
|
('WHOLE_BODY', 'Whole Body'),
|
|
('OTHER', 'Other'),
|
|
]
|
|
STATUS_CHOICES=[
|
|
('SCHEDULED', 'Scheduled'),
|
|
('ARRIVED', 'Arrived'),
|
|
('IN_PROGRESS', 'In Progress'),
|
|
('COMPLETED', 'Completed'),
|
|
('INTERPRETED', 'Interpreted'),
|
|
('FINALIZED', 'Finalized'),
|
|
('CANCELLED', 'Cancelled'),
|
|
]
|
|
PRIORITY_CHOICES = [
|
|
('ROUTINE', 'Routine'),
|
|
('URGENT', 'Urgent'),
|
|
('STAT', 'STAT'),
|
|
('EMERGENCY', 'Emergency'),
|
|
]
|
|
IMAGE_QUALITY_CHOICES = [
|
|
('EXCELLENT', 'Excellent'),
|
|
('GOOD', 'Good'),
|
|
('ADEQUATE', 'Adequate'),
|
|
('POOR', 'Poor'),
|
|
('UNACCEPTABLE', 'Unacceptable'),
|
|
]
|
|
COMPLETION_STATUS_CHOICES = [
|
|
('COMPLETE', 'Complete'),
|
|
('PARTIAL', 'Partial'),
|
|
('INCOMPLETE', 'Incomplete'),
|
|
]
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='imaging_studies',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Study Information
|
|
study_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique study identifier'
|
|
)
|
|
study_instance_uid = models.CharField(
|
|
max_length=64,
|
|
unique=True,
|
|
help_text='DICOM Study Instance UID'
|
|
)
|
|
accession_number = models.CharField(
|
|
max_length=20,
|
|
unique=True,
|
|
help_text='Study accession number'
|
|
)
|
|
|
|
# Patient and Provider
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='imaging_studies',
|
|
help_text='Patient'
|
|
)
|
|
referring_physician = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='referred_studies',
|
|
help_text='Referring physician'
|
|
)
|
|
radiologist = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='interpreted_studies',
|
|
help_text='Interpreting radiologist'
|
|
)
|
|
|
|
# Study Details
|
|
modality = models.CharField(
|
|
max_length=10,
|
|
choices=MODALITY_CHOICES,
|
|
help_text='Study modality'
|
|
)
|
|
study_description = models.CharField(
|
|
max_length=200,
|
|
help_text='Study description'
|
|
)
|
|
body_part = models.CharField(
|
|
max_length=100,
|
|
choices=BODY_PART_CHOICES,
|
|
help_text='Body part examined'
|
|
)
|
|
|
|
# Study Dates and Times
|
|
study_date = models.DateField(
|
|
help_text='Study date'
|
|
)
|
|
study_time = models.TimeField(
|
|
help_text='Study time'
|
|
)
|
|
study_datetime = models.DateTimeField(
|
|
help_text='Study date and time'
|
|
)
|
|
|
|
# Clinical Information
|
|
clinical_indication = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical indication for study'
|
|
)
|
|
clinical_history = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical history'
|
|
)
|
|
diagnosis_code = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='ICD-10 diagnosis code'
|
|
)
|
|
|
|
# Study Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=STATUS_CHOICES,
|
|
default='SCHEDULED',
|
|
help_text='Study status'
|
|
)
|
|
|
|
# Priority
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=PRIORITY_CHOICES,
|
|
default='ROUTINE',
|
|
help_text='Study priority'
|
|
)
|
|
|
|
# Technical Parameters
|
|
kvp = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Peak kilovoltage (kVp)'
|
|
)
|
|
mas = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Milliampere-seconds (mAs)'
|
|
)
|
|
exposure_time = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Exposure time in milliseconds'
|
|
)
|
|
slice_thickness = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Slice thickness in mm'
|
|
)
|
|
|
|
# Equipment Information
|
|
station_name = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Acquisition station name'
|
|
)
|
|
manufacturer = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Equipment manufacturer'
|
|
)
|
|
model_name = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Equipment model name'
|
|
)
|
|
|
|
# Study Metrics
|
|
number_of_series = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of series in study'
|
|
)
|
|
number_of_instances = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of instances in study'
|
|
)
|
|
study_size = models.BigIntegerField(
|
|
default=0,
|
|
help_text='Study size in bytes'
|
|
)
|
|
|
|
# Quality and Completion
|
|
image_quality = models.CharField(
|
|
max_length=20,
|
|
choices=IMAGE_QUALITY_CHOICES,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Image quality assessment'
|
|
)
|
|
completion_status = models.CharField(
|
|
max_length=20,
|
|
choices=COMPLETION_STATUS_CHOICES,
|
|
default='COMPLETE',
|
|
help_text='Study completion status'
|
|
)
|
|
|
|
# Related Information
|
|
encounter = models.ForeignKey(
|
|
'emr.Encounter',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='imaging_studies',
|
|
help_text='Related encounter'
|
|
)
|
|
imaging_order = models.ForeignKey(
|
|
'ImagingOrder',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='studies',
|
|
help_text='Related imaging order'
|
|
)
|
|
|
|
# PACS Information
|
|
pacs_location = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='PACS storage location'
|
|
)
|
|
archived = models.BooleanField(
|
|
default=False,
|
|
help_text='Study is archived'
|
|
)
|
|
archive_location = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Archive storage location'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_studies',
|
|
help_text='User who created the study'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'radiology_imaging_study'
|
|
verbose_name = 'Imaging Study'
|
|
verbose_name_plural = 'Imaging Studies'
|
|
ordering = ['-study_datetime']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'study_date']),
|
|
models.Index(fields=['modality', 'study_date']),
|
|
models.Index(fields=['accession_number']),
|
|
models.Index(fields=['study_instance_uid']),
|
|
models.Index(fields=['priority']),
|
|
models.Index(fields=['radiologist']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.accession_number} - {self.study_description}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Generate accession number if not provided.
|
|
"""
|
|
if not self.accession_number:
|
|
# Generate accession number (simple implementation)
|
|
today = timezone.now().date()
|
|
last_study = ImagingStudy.objects.filter(
|
|
tenant=self.tenant,
|
|
created_at__date=today
|
|
).order_by('-id').first()
|
|
|
|
if last_study:
|
|
last_number = int(last_study.accession_number.split('-')[-1])
|
|
self.accession_number = f"RAD-{today.strftime('%Y%m%d')}-{last_number + 1:04d}"
|
|
else:
|
|
self.accession_number = f"RAD-{today.strftime('%Y%m%d')}-0001"
|
|
|
|
# Set study_datetime from date and time
|
|
if self.study_date and self.study_time:
|
|
self.study_datetime = timezone.make_aware(
|
|
datetime.combine(self.study_date, self.study_time)
|
|
)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_stat(self):
|
|
"""
|
|
Check if study is STAT priority.
|
|
"""
|
|
return self.priority in ['STAT', 'EMERGENCY']
|
|
|
|
@property
|
|
def is_complete(self):
|
|
"""
|
|
Check if study is complete.
|
|
"""
|
|
return self.status in ['COMPLETED', 'INTERPRETED', 'FINALIZED']
|
|
|
|
|
|
class ImagingSeries(models.Model):
|
|
"""
|
|
Imaging series model for DICOM series management.
|
|
"""
|
|
|
|
# Study relationship
|
|
study = models.ForeignKey(
|
|
ImagingStudy,
|
|
on_delete=models.CASCADE,
|
|
related_name='series',
|
|
help_text='Parent imaging study'
|
|
)
|
|
|
|
# Series Information
|
|
series_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique series identifier'
|
|
)
|
|
series_instance_uid = models.CharField(
|
|
max_length=64,
|
|
unique=True,
|
|
help_text='DICOM Series Instance UID'
|
|
)
|
|
series_number = models.PositiveIntegerField(
|
|
help_text='Series number within study'
|
|
)
|
|
|
|
# Series Details
|
|
modality = models.CharField(
|
|
max_length=10,
|
|
choices=[
|
|
('CR', 'Computed Radiography'),
|
|
('CT', 'Computed Tomography'),
|
|
('MR', 'Magnetic Resonance'),
|
|
('US', 'Ultrasound'),
|
|
('XA', 'X-Ray Angiography'),
|
|
('RF', 'Radiofluoroscopy'),
|
|
('DX', 'Digital Radiography'),
|
|
('MG', 'Mammography'),
|
|
('NM', 'Nuclear Medicine'),
|
|
('PT', 'Positron Emission Tomography'),
|
|
('OT', 'Other'),
|
|
],
|
|
help_text='Series modality'
|
|
)
|
|
series_description = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Series description'
|
|
)
|
|
protocol_name = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Protocol name'
|
|
)
|
|
|
|
# Series Dates and Times
|
|
series_date = models.DateField(
|
|
help_text='Series date'
|
|
)
|
|
series_time = models.TimeField(
|
|
help_text='Series time'
|
|
)
|
|
series_datetime = models.DateTimeField(
|
|
help_text='Series date and time'
|
|
)
|
|
|
|
# Technical Parameters
|
|
slice_thickness = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Slice thickness in mm'
|
|
)
|
|
spacing_between_slices = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Spacing between slices in mm'
|
|
)
|
|
pixel_spacing = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Pixel spacing'
|
|
)
|
|
image_orientation = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Image orientation'
|
|
)
|
|
|
|
# Series Metrics
|
|
number_of_instances = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of instances in series'
|
|
)
|
|
series_size = models.BigIntegerField(
|
|
default=0,
|
|
help_text='Series size in bytes'
|
|
)
|
|
|
|
# Body Part and Position
|
|
body_part = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Body part examined'
|
|
)
|
|
patient_position = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('HFP', 'Head First-Prone'),
|
|
('HFS', 'Head First-Supine'),
|
|
('HFDR', 'Head First-Decubitus Right'),
|
|
('HFDL', 'Head First-Decubitus Left'),
|
|
('FFP', 'Feet First-Prone'),
|
|
('FFS', 'Feet First-Supine'),
|
|
('FFDR', 'Feet First-Decubitus Right'),
|
|
('FFDL', 'Feet First-Decubitus Left'),
|
|
],
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient position'
|
|
)
|
|
|
|
# Contrast Information
|
|
contrast_agent = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Contrast agent used'
|
|
)
|
|
contrast_route = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('IV', 'Intravenous'),
|
|
('ORAL', 'Oral'),
|
|
('RECTAL', 'Rectal'),
|
|
('INTRATHECAL', 'Intrathecal'),
|
|
('INTRA_ARTICULAR', 'Intra-articular'),
|
|
('OTHER', 'Other'),
|
|
],
|
|
blank=True,
|
|
null=True,
|
|
help_text='Contrast administration route'
|
|
)
|
|
|
|
# Quality
|
|
image_quality = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('EXCELLENT', 'Excellent'),
|
|
('GOOD', 'Good'),
|
|
('ADEQUATE', 'Adequate'),
|
|
('POOR', 'Poor'),
|
|
('UNACCEPTABLE', 'Unacceptable'),
|
|
],
|
|
blank=True,
|
|
null=True,
|
|
help_text='Image quality assessment'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'radiology_imaging_series'
|
|
verbose_name = 'Imaging Series'
|
|
verbose_name_plural = 'Imaging Series'
|
|
ordering = ['study', 'series_number']
|
|
indexes = [
|
|
models.Index(fields=['study', 'series_number']),
|
|
models.Index(fields=['series_instance_uid']),
|
|
models.Index(fields=['modality']),
|
|
models.Index(fields=['series_datetime']),
|
|
]
|
|
unique_together = ['study', 'series_number']
|
|
|
|
def __str__(self):
|
|
return f"Series {self.series_number}: {self.series_description or 'No description'}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Set series_datetime from date and time.
|
|
"""
|
|
if self.series_date and self.series_time:
|
|
self.series_datetime = timezone.make_aware(
|
|
datetime.combine(self.series_date, self.series_time)
|
|
)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def patient(self):
|
|
"""
|
|
Get patient from study.
|
|
"""
|
|
return self.study.patient
|
|
|
|
|
|
class DICOMImage(models.Model):
|
|
"""
|
|
DICOM image model for individual image instances.
|
|
"""
|
|
|
|
# Series relationship
|
|
series = models.ForeignKey(
|
|
ImagingSeries,
|
|
on_delete=models.CASCADE,
|
|
related_name='images',
|
|
help_text='Parent imaging series'
|
|
)
|
|
|
|
# Image Information
|
|
image_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique image identifier'
|
|
)
|
|
sop_instance_uid = models.CharField(
|
|
max_length=64,
|
|
unique=True,
|
|
help_text='DICOM SOP Instance UID'
|
|
)
|
|
instance_number = models.PositiveIntegerField(
|
|
help_text='Instance number within series'
|
|
)
|
|
|
|
# Image Details
|
|
image_type = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='DICOM image type'
|
|
)
|
|
sop_class_uid = models.CharField(
|
|
max_length=64,
|
|
help_text='DICOM SOP Class UID'
|
|
)
|
|
|
|
# Image Dimensions
|
|
rows = models.PositiveIntegerField(
|
|
help_text='Number of rows in image'
|
|
)
|
|
columns = models.PositiveIntegerField(
|
|
help_text='Number of columns in image'
|
|
)
|
|
bits_allocated = models.PositiveIntegerField(
|
|
help_text='Number of bits allocated for each pixel'
|
|
)
|
|
bits_stored = models.PositiveIntegerField(
|
|
help_text='Number of bits stored for each pixel'
|
|
)
|
|
|
|
# Image Position and Orientation
|
|
image_position = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Image position (patient)'
|
|
)
|
|
image_orientation = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Image orientation (patient)'
|
|
)
|
|
slice_location = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Slice location'
|
|
)
|
|
|
|
# Window/Level Settings
|
|
window_center = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Window center'
|
|
)
|
|
window_width = models.FloatField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Window width'
|
|
)
|
|
|
|
# File Information
|
|
file_path = models.CharField(
|
|
max_length=500,
|
|
help_text='File path on storage system'
|
|
)
|
|
file_size = models.BigIntegerField(
|
|
help_text='File size in bytes'
|
|
)
|
|
transfer_syntax_uid = models.CharField(
|
|
max_length=64,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Transfer syntax UID'
|
|
)
|
|
|
|
# Content Information
|
|
content_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Content date'
|
|
)
|
|
content_time = models.TimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Content time'
|
|
)
|
|
acquisition_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Acquisition date and time'
|
|
)
|
|
|
|
# Quality and Status
|
|
image_quality = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('EXCELLENT', 'Excellent'),
|
|
('GOOD', 'Good'),
|
|
('ADEQUATE', 'Adequate'),
|
|
('POOR', 'Poor'),
|
|
('UNACCEPTABLE', 'Unacceptable'),
|
|
],
|
|
blank=True,
|
|
null=True,
|
|
help_text='Image quality assessment'
|
|
)
|
|
|
|
# Processing Status
|
|
processed = models.BooleanField(
|
|
default=False,
|
|
help_text='Image has been processed'
|
|
)
|
|
archived = models.BooleanField(
|
|
default=False,
|
|
help_text='Image is archived'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'radiology_dicom_image'
|
|
verbose_name = 'DICOM Image'
|
|
verbose_name_plural = 'DICOM Images'
|
|
ordering = ['series', 'instance_number']
|
|
indexes = [
|
|
models.Index(fields=['series', 'instance_number']),
|
|
models.Index(fields=['sop_instance_uid']),
|
|
models.Index(fields=['sop_class_uid']),
|
|
models.Index(fields=['processed']),
|
|
models.Index(fields=['archived']),
|
|
]
|
|
unique_together = ['series', 'instance_number']
|
|
|
|
def __str__(self):
|
|
return f"Image {self.instance_number} ({self.sop_instance_uid})"
|
|
|
|
@property
|
|
def study(self):
|
|
"""
|
|
Get study from series.
|
|
"""
|
|
return self.series.study
|
|
|
|
@property
|
|
def patient(self):
|
|
"""
|
|
Get patient from study.
|
|
"""
|
|
return self.series.study.patient
|
|
|
|
@property
|
|
def file_size_mb(self):
|
|
"""
|
|
Get file size in MB.
|
|
"""
|
|
return round(self.file_size / (1024 * 1024), 2)
|
|
|
|
|
|
class RadiologyReport(models.Model):
|
|
"""
|
|
Radiology report model for study interpretation and reporting.
|
|
"""
|
|
|
|
# Study relationship
|
|
study = models.OneToOneField(
|
|
ImagingStudy,
|
|
on_delete=models.CASCADE,
|
|
related_name='report',
|
|
help_text='Related imaging study'
|
|
)
|
|
|
|
# Report Information
|
|
report_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique report identifier'
|
|
)
|
|
|
|
# Radiologist Information
|
|
radiologist = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='radiology_reports',
|
|
help_text='Interpreting radiologist'
|
|
)
|
|
dictated_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='dictated_reports',
|
|
help_text='Radiologist who dictated the report'
|
|
)
|
|
transcribed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='transcribed_reports',
|
|
help_text='Person who transcribed the report'
|
|
)
|
|
|
|
# Report Content
|
|
clinical_history = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical history and indication'
|
|
)
|
|
technique = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Imaging technique and protocol'
|
|
)
|
|
findings = models.TextField(
|
|
help_text='Imaging findings'
|
|
)
|
|
impression = models.TextField(
|
|
help_text='Radiologist impression and conclusion'
|
|
)
|
|
recommendations = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Recommendations for follow-up'
|
|
)
|
|
|
|
# Report Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('DRAFT', 'Draft'),
|
|
('PRELIMINARY', 'Preliminary'),
|
|
('FINAL', 'Final'),
|
|
('AMENDED', 'Amended'),
|
|
('CORRECTED', 'Corrected'),
|
|
],
|
|
default='DRAFT',
|
|
help_text='Report status'
|
|
)
|
|
|
|
# Critical Findings
|
|
critical_finding = models.BooleanField(
|
|
default=False,
|
|
help_text='Report contains critical findings'
|
|
)
|
|
critical_communicated = models.BooleanField(
|
|
default=False,
|
|
help_text='Critical findings have been communicated'
|
|
)
|
|
critical_communicated_to = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Person critical findings were communicated to'
|
|
)
|
|
critical_communicated_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time critical findings were communicated'
|
|
)
|
|
|
|
# Report Dates
|
|
dictated_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time report was dictated'
|
|
)
|
|
transcribed_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time report was transcribed'
|
|
)
|
|
verified_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time report was verified'
|
|
)
|
|
finalized_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time report was finalized'
|
|
)
|
|
|
|
# Template and Structured Reporting
|
|
template_used = models.ForeignKey(
|
|
'ReportTemplate',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='reports',
|
|
help_text='Report template used'
|
|
)
|
|
structured_data = models.JSONField(
|
|
default=dict,
|
|
help_text='Structured reporting data'
|
|
)
|
|
|
|
# Quality and Metrics
|
|
report_length = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Report length in characters'
|
|
)
|
|
turnaround_time = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Turnaround time in minutes'
|
|
)
|
|
|
|
# Addenda
|
|
addendum = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Report addendum'
|
|
)
|
|
addendum_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time addendum was added'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'radiology_radiology_report'
|
|
verbose_name = 'Radiology Report'
|
|
verbose_name_plural = 'Radiology Reports'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['study']),
|
|
models.Index(fields=['radiologist']),
|
|
models.Index(fields=['status']),
|
|
models.Index(fields=['critical_finding']),
|
|
models.Index(fields=['finalized_datetime']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Report for {self.study.accession_number}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Calculate report length and turnaround time.
|
|
"""
|
|
# Calculate report length
|
|
content = f"{self.findings} {self.impression}"
|
|
self.report_length = len(content)
|
|
|
|
# Calculate turnaround time
|
|
if self.finalized_datetime and self.study.study_datetime:
|
|
delta = self.finalized_datetime - self.study.study_datetime
|
|
self.turnaround_time = int(delta.total_seconds() / 60)
|
|
|
|
# Set finalized datetime if status is final
|
|
if self.status == 'FINAL' and not self.finalized_datetime:
|
|
self.finalized_datetime = timezone.now()
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def patient(self):
|
|
"""
|
|
Get patient from study.
|
|
"""
|
|
return self.study.patient
|
|
|
|
@property
|
|
def is_final(self):
|
|
"""
|
|
Check if report is final.
|
|
"""
|
|
return self.status == 'FINAL'
|
|
|
|
@property
|
|
def is_critical(self):
|
|
"""
|
|
Check if report has critical findings.
|
|
"""
|
|
return self.critical_finding
|
|
|
|
|
|
class ReportTemplate(models.Model):
|
|
"""
|
|
Report template model for standardized reporting.
|
|
"""
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='report_templates',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Template Information
|
|
template_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique template identifier'
|
|
)
|
|
name = models.CharField(
|
|
max_length=100,
|
|
help_text='Template name'
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Template description'
|
|
)
|
|
|
|
# Template Scope
|
|
modality = models.CharField(
|
|
max_length=10,
|
|
choices=[
|
|
('ALL', 'All Modalities'),
|
|
('CR', 'Computed Radiography'),
|
|
('CT', 'Computed Tomography'),
|
|
('MR', 'Magnetic Resonance'),
|
|
('US', 'Ultrasound'),
|
|
('XA', 'X-Ray Angiography'),
|
|
('RF', 'Radiofluoroscopy'),
|
|
('DX', 'Digital Radiography'),
|
|
('MG', 'Mammography'),
|
|
('NM', 'Nuclear Medicine'),
|
|
('PT', 'Positron Emission Tomography'),
|
|
],
|
|
default='ALL',
|
|
help_text='Applicable modality'
|
|
)
|
|
body_part = models.CharField(
|
|
max_length=100,
|
|
choices=[
|
|
('ALL', 'All Body Parts'),
|
|
('HEAD', 'Head'),
|
|
('NECK', 'Neck'),
|
|
('CHEST', 'Chest'),
|
|
('ABDOMEN', 'Abdomen'),
|
|
('PELVIS', 'Pelvis'),
|
|
('SPINE', 'Spine'),
|
|
('EXTREMITY', 'Extremity'),
|
|
('BREAST', 'Breast'),
|
|
('HEART', 'Heart'),
|
|
('BRAIN', 'Brain'),
|
|
],
|
|
default='ALL',
|
|
help_text='Applicable body part'
|
|
)
|
|
|
|
# Template Content
|
|
clinical_history_template = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical history template'
|
|
)
|
|
technique_template = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Technique template'
|
|
)
|
|
findings_template = models.TextField(
|
|
help_text='Findings template'
|
|
)
|
|
impression_template = models.TextField(
|
|
help_text='Impression template'
|
|
)
|
|
recommendations_template = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Recommendations template'
|
|
)
|
|
|
|
# Structured Reporting
|
|
structured_fields = models.JSONField(
|
|
default=dict,
|
|
help_text='Structured reporting field definitions'
|
|
)
|
|
|
|
# Template Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Template is active'
|
|
)
|
|
is_default = models.BooleanField(
|
|
default=False,
|
|
help_text='Default template for modality/body part'
|
|
)
|
|
|
|
# Usage Statistics
|
|
usage_count = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of times template has been used'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_report_templates',
|
|
help_text='User who created the template'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'radiology_report_template'
|
|
verbose_name = 'Report Template'
|
|
verbose_name_plural = 'Report Templates'
|
|
ordering = ['modality', 'body_part', 'name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
models.Index(fields=['modality', 'body_part']),
|
|
models.Index(fields=['is_default']),
|
|
]
|
|
unique_together = ['tenant', 'name']
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.modality}/{self.body_part})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Ensure only one default template per modality/body part.
|
|
"""
|
|
if self.is_default:
|
|
# Remove default flag from other templates
|
|
ReportTemplate.objects.filter(
|
|
tenant=self.tenant,
|
|
modality=self.modality,
|
|
body_part=self.body_part,
|
|
is_default=True
|
|
).exclude(pk=self.pk).update(is_default=False)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class ImagingOrder(models.Model):
|
|
"""
|
|
Imaging order model for radiology order management.
|
|
"""
|
|
MODALITY_CHOICES = [
|
|
('CR', 'Computed Radiography'),
|
|
('CT', 'Computed Tomography'),
|
|
('MR', 'Magnetic Resonance'),
|
|
('US', 'Ultrasound'),
|
|
('XA', 'X-Ray Angiography'),
|
|
('RF', 'Radiofluoroscopy'),
|
|
('DX', 'Digital Radiography'),
|
|
('MG', 'Mammography'),
|
|
('NM', 'Nuclear Medicine'),
|
|
('PT', 'Positron Emission Tomography'),
|
|
]
|
|
PRIORITY_CHOICES = [
|
|
('ROUTINE', 'Routine'),
|
|
('URGENT', 'Urgent'),
|
|
('STAT', 'STAT'),
|
|
('EMERGENCY', 'Emergency'),
|
|
]
|
|
BODY_PART_CHOICES = [
|
|
('HEAD', 'Head'),
|
|
('NECK', 'Neck'),
|
|
('CHEST', 'Chest'),
|
|
('ABDOMEN', 'Abdomen'),
|
|
('PELVIS', 'Pelvis'),
|
|
('SPINE', 'Spine'),
|
|
('EXTREMITY', 'Extremity'),
|
|
('BREAST', 'Breast'),
|
|
('HEART', 'Heart'),
|
|
('BRAIN', 'Brain'),
|
|
('WHOLE_BODY', 'Whole Body'),
|
|
('OTHER', 'Other'),
|
|
]
|
|
CONTRAST_ROUTE_CHOICES = [
|
|
('IV', 'Intravenous'),
|
|
('ORAL', 'Oral'),
|
|
('RECTAL', 'Rectal'),
|
|
('INTRATHECAL', 'Intrathecal'),
|
|
('INTRA_ARTICULAR', 'Intra-articular'),
|
|
('OTHER', 'Other'),
|
|
]
|
|
STATUS_CHOICES = [
|
|
('PENDING', 'Pending'),
|
|
('SCHEDULED', 'Scheduled'),
|
|
('IN_PROGRESS', 'In Progress'),
|
|
('COMPLETED', 'Completed'),
|
|
('CANCELLED', 'Cancelled'),
|
|
('ON_HOLD', 'On Hold'),
|
|
]
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='imaging_orders',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Order Information
|
|
order_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique order identifier'
|
|
)
|
|
order_number = models.CharField(
|
|
max_length=20,
|
|
unique=True,
|
|
help_text='Imaging order number'
|
|
)
|
|
|
|
# Patient and Provider
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='imaging_orders',
|
|
help_text='Patient'
|
|
)
|
|
ordering_provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='ordered_imaging_studies',
|
|
help_text='Ordering provider'
|
|
)
|
|
|
|
# Order Details
|
|
order_datetime = models.DateTimeField(
|
|
default=timezone.now,
|
|
help_text='Date and time order was placed'
|
|
)
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=PRIORITY_CHOICES,
|
|
default='ROUTINE',
|
|
help_text='Order priority'
|
|
)
|
|
|
|
# Imaging Details
|
|
modality = models.CharField(
|
|
max_length=10,
|
|
choices=MODALITY_CHOICES,
|
|
help_text='Requested modality'
|
|
)
|
|
study_description = models.CharField(
|
|
max_length=200,
|
|
help_text='Study description'
|
|
)
|
|
body_part = models.CharField(
|
|
max_length=100,
|
|
choices=BODY_PART_CHOICES,
|
|
help_text='Body part to be examined'
|
|
)
|
|
|
|
# Clinical Information
|
|
clinical_indication = models.TextField(
|
|
help_text='Clinical indication for imaging'
|
|
)
|
|
clinical_history = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Relevant clinical history'
|
|
)
|
|
diagnosis_code = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='ICD-10 diagnosis code'
|
|
)
|
|
|
|
# Contrast Information
|
|
contrast_required = models.BooleanField(
|
|
default=False,
|
|
help_text='Contrast agent required'
|
|
)
|
|
contrast_type = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Type of contrast agent'
|
|
)
|
|
contrast_route = models.CharField(
|
|
max_length=20,
|
|
choices=CONTRAST_ROUTE_CHOICES,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Contrast administration route'
|
|
)
|
|
|
|
# Scheduling Information
|
|
requested_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Requested study date and time'
|
|
)
|
|
scheduled_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Scheduled study date and time'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=STATUS_CHOICES,
|
|
default='PENDING',
|
|
help_text='Order status'
|
|
)
|
|
|
|
# Related Information
|
|
encounter = models.ForeignKey(
|
|
'emr.Encounter',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='imaging_orders',
|
|
help_text='Related encounter'
|
|
)
|
|
|
|
# Special Instructions
|
|
special_instructions = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Special instructions for imaging'
|
|
)
|
|
patient_preparation = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient preparation instructions'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'radiology_imaging_order'
|
|
verbose_name = 'Imaging Order'
|
|
verbose_name_plural = 'Imaging Orders'
|
|
ordering = ['-order_datetime']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'status']),
|
|
models.Index(fields=['ordering_provider']),
|
|
models.Index(fields=['order_datetime']),
|
|
models.Index(fields=['order_number']),
|
|
models.Index(fields=['priority']),
|
|
models.Index(fields=['modality']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.order_number} - {self.study_description}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Generate order number if not provided.
|
|
"""
|
|
if not self.order_number:
|
|
# Generate order number (simple implementation)
|
|
last_order = ImagingOrder.objects.filter(tenant=self.tenant).order_by('-id').first()
|
|
if last_order:
|
|
last_number = int(last_order.order_number.split('-')[-1])
|
|
self.order_number = f"IMG-{self.tenant.id}-{last_number + 1:06d}"
|
|
else:
|
|
self.order_number = f"IMG-{self.tenant.id}-000001"
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_stat(self):
|
|
"""
|
|
Check if order is STAT priority.
|
|
"""
|
|
return self.priority in ['STAT', 'EMERGENCY']
|
|
|