1331 lines
35 KiB
Python
1331 lines
35 KiB
Python
"""
|
|
Laboratory app models for hospital management system.
|
|
Provides lab test ordering, specimen management, and result processing.
|
|
"""
|
|
|
|
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 LabTest(models.Model):
|
|
"""
|
|
Lab test model for test catalog and configuration.
|
|
"""
|
|
TEST_CATEGORY_CHOICES = [
|
|
('CHEMISTRY', 'Chemistry'),
|
|
('HEMATOLOGY', 'Hematology'),
|
|
('MICROBIOLOGY', 'Microbiology'),
|
|
('IMMUNOLOGY', 'Immunology'),
|
|
('MOLECULAR', 'Molecular'),
|
|
('PATHOLOGY', 'Pathology'),
|
|
('TOXICOLOGY', 'Toxicology'),
|
|
('ENDOCRINOLOGY', 'Endocrinology'),
|
|
('CARDIOLOGY', 'Cardiology'),
|
|
('ONCOLOGY', 'Oncology'),
|
|
('GENETICS', 'Genetics'),
|
|
('COAGULATION', 'Coagulation'),
|
|
('URINALYSIS', 'Urinalysis'),
|
|
('OTHER', 'Other'),
|
|
]
|
|
|
|
TEST_TYPE_CHOICES = [
|
|
('QUANTITATIVE', 'Quantitative'),
|
|
('QUALITATIVE', 'Qualitative'),
|
|
('SEMI_QUANTITATIVE', 'Semi-Quantitative'),
|
|
('CULTURE', 'Culture'),
|
|
('MICROSCOPY', 'Microscopy'),
|
|
('MOLECULAR', 'Molecular'),
|
|
('IMMUNOASSAY', 'Immunoassay'),
|
|
('OTHER', 'Other'),
|
|
]
|
|
|
|
SPECIMEN_TYPE_CHOICES = [
|
|
('BLOOD', 'Blood'),
|
|
('SERUM', 'Serum'),
|
|
('PLASMA', 'Plasma'),
|
|
('URINE', 'Urine'),
|
|
('STOOL', 'Stool'),
|
|
('CSF', 'Cerebrospinal Fluid'),
|
|
('SPUTUM', 'Sputum'),
|
|
('SWAB', 'Swab'),
|
|
('TISSUE', 'Tissue'),
|
|
('FLUID', 'Body Fluid'),
|
|
('SALIVA', 'Saliva'),
|
|
('HAIR', 'Hair'),
|
|
('NAIL', 'Nail'),
|
|
('OTHER', 'Other'),
|
|
]
|
|
|
|
STORAGE_TEMPERATURE_CHOICES = [
|
|
('ROOM_TEMP', 'Room Temperature'),
|
|
('REFRIGERATED', 'Refrigerated (2-8°C)'),
|
|
('FROZEN', 'Frozen (-20°C)'),
|
|
('DEEP_FROZEN', 'Deep Frozen (-80°C)'),
|
|
('ICE', 'On Ice'),
|
|
('AMBIENT', 'Ambient'),
|
|
]
|
|
|
|
QC_FREQUENCY_CHOICES = [
|
|
('DAILY', 'Daily'),
|
|
('WEEKLY', 'Weekly'),
|
|
('MONTHLY', 'Monthly'),
|
|
('PER_BATCH', 'Per Batch'),
|
|
('CONTINUOUS', 'Continuous'),
|
|
]
|
|
|
|
DEPARTMENT_CHOICES = [
|
|
('CHEMISTRY', 'Chemistry'),
|
|
('HEMATOLOGY', 'Hematology'),
|
|
('MICROBIOLOGY', 'Microbiology'),
|
|
('IMMUNOLOGY', 'Immunology'),
|
|
('MOLECULAR', 'Molecular'),
|
|
('PATHOLOGY', 'Pathology'),
|
|
('BLOOD_BANK', 'Blood Bank'),
|
|
('CYTOLOGY', 'Cytology'),
|
|
('HISTOLOGY', 'Histology'),
|
|
('TOXICOLOGY', 'Toxicology'),
|
|
]
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='lab_tests',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Test Information
|
|
test_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique test identifier'
|
|
)
|
|
|
|
# Test Identification
|
|
test_code = models.CharField(
|
|
max_length=20,
|
|
help_text='Laboratory test code'
|
|
)
|
|
test_name = models.CharField(
|
|
max_length=200,
|
|
help_text='Test name'
|
|
)
|
|
test_description = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Detailed test description'
|
|
)
|
|
|
|
# Medical Coding
|
|
loinc_code = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='LOINC code'
|
|
)
|
|
cpt_code = models.CharField(
|
|
max_length=10,
|
|
blank=True,
|
|
null=True,
|
|
help_text='CPT code for billing'
|
|
)
|
|
snomed_code = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='SNOMED CT code'
|
|
)
|
|
|
|
# Test Classification
|
|
test_category = models.CharField(
|
|
max_length=50,
|
|
choices=TEST_CATEGORY_CHOICES,
|
|
help_text='Test category'
|
|
)
|
|
test_type = models.CharField(
|
|
max_length=30,
|
|
choices= TEST_TYPE_CHOICES,
|
|
help_text='Type of test'
|
|
)
|
|
|
|
# Specimen Requirements
|
|
specimen_type = models.CharField(
|
|
max_length=30,
|
|
choices=SPECIMEN_TYPE_CHOICES,
|
|
help_text='Required specimen type'
|
|
)
|
|
specimen_volume = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Required specimen volume'
|
|
)
|
|
collection_container = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Collection container type'
|
|
)
|
|
collection_instructions = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Specimen collection instructions'
|
|
)
|
|
|
|
# Processing Requirements
|
|
processing_time = models.PositiveIntegerField(
|
|
help_text='Processing time in minutes'
|
|
)
|
|
turnaround_time = models.PositiveIntegerField(
|
|
help_text='Expected turnaround time in hours'
|
|
)
|
|
stat_available = models.BooleanField(
|
|
default=False,
|
|
help_text='STAT processing available'
|
|
)
|
|
stat_turnaround_time = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='STAT turnaround time in hours'
|
|
)
|
|
|
|
# Storage and Transport
|
|
storage_temperature = models.CharField(
|
|
max_length=30,
|
|
choices=STORAGE_TEMPERATURE_CHOICES,
|
|
default='ROOM_TEMP',
|
|
help_text='Storage temperature requirement'
|
|
)
|
|
transport_requirements = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Transport requirements'
|
|
)
|
|
stability_time = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Specimen stability time in hours'
|
|
)
|
|
|
|
# Clinical Information
|
|
clinical_significance = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical significance of test'
|
|
)
|
|
indications = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical indications'
|
|
)
|
|
contraindications = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Contraindications'
|
|
)
|
|
|
|
# Patient Preparation
|
|
patient_preparation = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient preparation instructions'
|
|
)
|
|
fasting_required = models.BooleanField(
|
|
default=False,
|
|
help_text='Fasting required'
|
|
)
|
|
fasting_hours = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Required fasting hours'
|
|
)
|
|
|
|
# Methodology
|
|
methodology = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Test methodology'
|
|
)
|
|
analyzer = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Analyzer/instrument used'
|
|
)
|
|
|
|
# Quality Control
|
|
qc_frequency = models.CharField(
|
|
max_length=20,
|
|
choices=QC_FREQUENCY_CHOICES,
|
|
default='DAILY',
|
|
help_text='Quality control frequency'
|
|
)
|
|
|
|
# Pricing
|
|
cost = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Test cost'
|
|
)
|
|
|
|
# Availability
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Test is active and available'
|
|
)
|
|
is_orderable = models.BooleanField(
|
|
default=True,
|
|
help_text='Test can be ordered'
|
|
)
|
|
|
|
# Department
|
|
department = models.CharField(
|
|
max_length=50,
|
|
choices=DEPARTMENT_CHOICES,
|
|
help_text='Laboratory department'
|
|
)
|
|
|
|
# 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_lab_tests',
|
|
help_text='User who created the test'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'laboratory_lab_test'
|
|
verbose_name = 'Lab Test'
|
|
verbose_name_plural = 'Lab Tests'
|
|
ordering = ['test_name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
models.Index(fields=['test_code']),
|
|
models.Index(fields=['test_category']),
|
|
models.Index(fields=['department']),
|
|
models.Index(fields=['loinc_code']),
|
|
models.Index(fields=['cpt_code']),
|
|
]
|
|
unique_together = ['tenant', 'test_code']
|
|
|
|
def __str__(self):
|
|
return f"{self.test_code} - {self.test_name}"
|
|
|
|
@property
|
|
def display_name(self):
|
|
"""
|
|
Get display name for test.
|
|
"""
|
|
return str(self)
|
|
|
|
|
|
class LabOrder(models.Model):
|
|
"""
|
|
Lab order model for test ordering and management.
|
|
"""
|
|
PRIORITY_CHOICES = [
|
|
('ROUTINE', 'Routine'),
|
|
('URGENT', 'Urgent'),
|
|
('STAT', 'STAT'),
|
|
('ASAP', 'ASAP'),
|
|
('TIMED', 'Timed'),
|
|
]
|
|
STATUS_CHOICES = [
|
|
('PENDING', 'Pending'),
|
|
('SCHEDULED', 'Scheduled'),
|
|
('COLLECTED', 'Collected'),
|
|
('IN_PROGRESS', 'In Progress'),
|
|
('COMPLETED', 'Completed'),
|
|
('CANCELLED', 'Cancelled'),
|
|
('ON_HOLD', 'On Hold'),
|
|
]
|
|
FASTING_STATUS_CHOICES = [
|
|
('FASTING', 'Fasting'),
|
|
('NON_FASTING', 'Non-Fasting'),
|
|
('UNKNOWN', 'Unknown'),
|
|
]
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='lab_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='Lab order number'
|
|
)
|
|
|
|
# Patient and Provider
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='lab_orders',
|
|
help_text='Patient'
|
|
)
|
|
ordering_provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='ordered_lab_tests',
|
|
help_text='Ordering provider'
|
|
)
|
|
|
|
# Tests
|
|
tests = models.ManyToManyField(
|
|
LabTest,
|
|
related_name='orders',
|
|
help_text='Ordered tests'
|
|
)
|
|
|
|
# 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'
|
|
)
|
|
|
|
# Clinical Information
|
|
clinical_indication = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical indication for tests'
|
|
)
|
|
diagnosis_code = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='ICD-10 diagnosis code'
|
|
)
|
|
clinical_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical notes'
|
|
)
|
|
|
|
# Collection Information
|
|
collection_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Requested collection date and time'
|
|
)
|
|
collection_location = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Collection location'
|
|
)
|
|
fasting_status = models.CharField(
|
|
max_length=20,
|
|
choices=FASTING_STATUS_CHOICES,
|
|
default='UNKNOWN',
|
|
help_text='Patient fasting status'
|
|
)
|
|
|
|
# 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='lab_orders',
|
|
help_text='Related encounter'
|
|
)
|
|
|
|
# Special Instructions
|
|
special_instructions = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Special instructions for collection or processing'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'laboratory_lab_order'
|
|
verbose_name = 'Lab Order'
|
|
verbose_name_plural = 'Lab 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']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.order_number} - {self.patient.get_full_name()}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Generate order number if not provided.
|
|
"""
|
|
if not self.order_number:
|
|
# Generate order number (simple implementation)
|
|
last_order = LabOrder.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"LAB-{self.tenant.id}-{last_number + 1:06d}"
|
|
else:
|
|
self.order_number = f"LAB-{self.tenant.id}-000001"
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def test_count(self):
|
|
"""
|
|
Get number of tests in order.
|
|
"""
|
|
return self.tests.count()
|
|
|
|
@property
|
|
def is_stat(self):
|
|
"""
|
|
Check if order is STAT priority.
|
|
"""
|
|
return self.priority == 'STAT'
|
|
|
|
|
|
class Specimen(models.Model):
|
|
"""
|
|
Specimen model for specimen tracking and management.
|
|
"""
|
|
SPECIMEN_TYPE_CHOICES = [
|
|
('BLOOD', 'Blood'),
|
|
('SERUM', 'Serum'),
|
|
('PLASMA', 'Plasma'),
|
|
('URINE', 'Urine'),
|
|
('STOOL', 'Stool'),
|
|
('CSF', 'Cerebrospinal Fluid'),
|
|
('SPUTUM', 'Sputum'),
|
|
('SWAB', 'Swab'),
|
|
('TISSUE', 'Tissue'),
|
|
('FLUID', 'Body Fluid'),
|
|
('SALIVA', 'Saliva'),
|
|
('HAIR', 'Hair'),
|
|
('NAIL', 'Nail'),
|
|
('OTHER', 'Other'),
|
|
]
|
|
QUALITY_CHOICES = [
|
|
('ACCEPTABLE', 'Acceptable'),
|
|
('SUBOPTIMAL', 'Suboptimal'),
|
|
('REJECTED', 'Rejected'),
|
|
]
|
|
REJECTION_REASON_CHOICES = [
|
|
('HEMOLYZED', 'Hemolyzed'),
|
|
('CLOTTED', 'Clotted'),
|
|
('INSUFFICIENT_VOLUME', 'Insufficient Volume'),
|
|
('CONTAMINATED', 'Contaminated'),
|
|
('MISLABELED', 'Mislabeled'),
|
|
('EXPIRED', 'Expired'),
|
|
('IMPROPER_STORAGE', 'Improper Storage'),
|
|
('DAMAGED_CONTAINER', 'Damaged Container'),
|
|
('OTHER', 'Other'),
|
|
]
|
|
STORAGE_TEMPERATURE_CHOICES = [
|
|
('ROOM_TEMP', 'Room Temperature'),
|
|
('REFRIGERATED', 'Refrigerated (2-8°C)'),
|
|
('FROZEN', 'Frozen (-20°C)'),
|
|
('DEEP_FROZEN', 'Deep Frozen (-80°C)'),
|
|
]
|
|
STATUS_CHOICES = [
|
|
('COLLECTED', 'Collected'),
|
|
('IN_TRANSIT', 'In Transit'),
|
|
('RECEIVED', 'Received'),
|
|
('PROCESSING', 'Processing'),
|
|
('COMPLETED', 'Completed'),
|
|
('REJECTED', 'Rejected'),
|
|
('DISPOSED', 'Disposed'),
|
|
]
|
|
|
|
# Order relationship
|
|
order = models.ForeignKey(
|
|
LabOrder,
|
|
on_delete=models.CASCADE,
|
|
related_name='specimens',
|
|
help_text='Related lab order'
|
|
)
|
|
|
|
# Specimen Information
|
|
specimen_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique specimen identifier'
|
|
)
|
|
specimen_number = models.CharField(
|
|
max_length=20,
|
|
unique=True,
|
|
help_text='Specimen accession number'
|
|
)
|
|
|
|
# Specimen Details
|
|
specimen_type = models.CharField(
|
|
max_length=30,
|
|
choices=SPECIMEN_TYPE_CHOICES,
|
|
help_text='Specimen type'
|
|
)
|
|
container_type = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Container type'
|
|
)
|
|
volume = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Specimen volume'
|
|
)
|
|
|
|
# Collection Information
|
|
collected_datetime = models.DateTimeField(
|
|
help_text='Date and time collected'
|
|
)
|
|
collected_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='collected_specimens',
|
|
help_text='Person who collected specimen'
|
|
)
|
|
collection_site = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Collection site/location'
|
|
)
|
|
collection_method = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Collection method'
|
|
)
|
|
|
|
# Specimen Quality
|
|
quality = models.CharField(
|
|
max_length=20,
|
|
choices=QUALITY_CHOICES,
|
|
default='ACCEPTABLE',
|
|
help_text='Specimen quality'
|
|
)
|
|
rejection_reason = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
choices=REJECTION_REASON_CHOICES,
|
|
help_text='Reason for rejection'
|
|
)
|
|
quality_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Quality assessment notes'
|
|
)
|
|
|
|
# Processing Information
|
|
received_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time received in lab'
|
|
)
|
|
received_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='received_specimens',
|
|
help_text='Lab staff who received specimen'
|
|
)
|
|
|
|
# Storage Information
|
|
storage_location = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Storage location'
|
|
)
|
|
storage_temperature = models.CharField(
|
|
max_length=30,
|
|
choices=STORAGE_TEMPERATURE_CHOICES,
|
|
default='ROOM_TEMP',
|
|
help_text='Storage temperature'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=STATUS_CHOICES,
|
|
default='COLLECTED',
|
|
help_text='Specimen status'
|
|
)
|
|
|
|
# Chain of Custody
|
|
chain_of_custody = models.JSONField(
|
|
default=list,
|
|
help_text='Chain of custody tracking'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'laboratory_specimen'
|
|
verbose_name = 'Specimen'
|
|
verbose_name_plural = 'Specimens'
|
|
ordering = ['-collected_datetime']
|
|
indexes = [
|
|
models.Index(fields=['order']),
|
|
models.Index(fields=['specimen_number']),
|
|
models.Index(fields=['specimen_type']),
|
|
models.Index(fields=['status']),
|
|
models.Index(fields=['collected_datetime']),
|
|
models.Index(fields=['quality']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.specimen_number} - {self.specimen_type}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Generate specimen number if not provided.
|
|
"""
|
|
if not self.specimen_number:
|
|
# Generate specimen number (simple implementation)
|
|
today = timezone.now().date()
|
|
last_specimen = Specimen.objects.filter(
|
|
created_at__date=today
|
|
).order_by('-id').first()
|
|
|
|
if last_specimen:
|
|
last_number = int(last_specimen.specimen_number.split('-')[-1])
|
|
self.specimen_number = f"SPEC-{today.strftime('%Y%m%d')}-{last_number + 1:04d}"
|
|
else:
|
|
self.specimen_number = f"SPEC-{today.strftime('%Y%m%d')}-0001"
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def patient(self):
|
|
"""
|
|
Get patient from order.
|
|
"""
|
|
return self.order.patient
|
|
|
|
@property
|
|
def is_rejected(self):
|
|
"""
|
|
Check if specimen is rejected.
|
|
"""
|
|
return self.quality == 'REJECTED'
|
|
|
|
|
|
class LabResult(models.Model):
|
|
"""
|
|
Lab result model for test results and reporting.
|
|
"""
|
|
RESULT_TYPE_CHOICES = [
|
|
('NUMERIC', 'Numeric'),
|
|
('TEXT', 'Text'),
|
|
('CODED', 'Coded'),
|
|
('NARRATIVE', 'Narrative'),
|
|
]
|
|
ABNORMAL_FLAG_CHOICES = [
|
|
('N', 'Normal'),
|
|
('H', 'High'),
|
|
('L', 'Low'),
|
|
('HH', 'Critical High'),
|
|
('LL', 'Critical Low'),
|
|
('A', 'Abnormal'),
|
|
]
|
|
STATUS_CHOICES = [
|
|
('PENDING', 'Pending'),
|
|
('IN_PROGRESS', 'In Progress'),
|
|
('COMPLETED', 'Completed'),
|
|
('VERIFIED', 'Verified'),
|
|
('AMENDED', 'Amended'),
|
|
('CANCELLED', 'Cancelled'),
|
|
]
|
|
# Order and Test relationship
|
|
order = models.ForeignKey(
|
|
LabOrder,
|
|
on_delete=models.CASCADE,
|
|
related_name='results',
|
|
help_text='Related lab order'
|
|
)
|
|
test = models.ForeignKey(
|
|
LabTest,
|
|
on_delete=models.CASCADE,
|
|
related_name='results',
|
|
help_text='Lab test'
|
|
)
|
|
specimen = models.ForeignKey(
|
|
Specimen,
|
|
on_delete=models.CASCADE,
|
|
related_name='results',
|
|
help_text='Specimen used for test'
|
|
)
|
|
|
|
# Result Information
|
|
result_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique result identifier'
|
|
)
|
|
|
|
# Result Values
|
|
result_value = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Test result value'
|
|
)
|
|
result_unit = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Result unit of measure'
|
|
)
|
|
result_type = models.CharField(
|
|
max_length=20,
|
|
choices=RESULT_TYPE_CHOICES,
|
|
default='NUMERIC',
|
|
help_text='Type of result'
|
|
)
|
|
|
|
# Reference Range
|
|
reference_range = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Reference range'
|
|
)
|
|
abnormal_flag = models.CharField(
|
|
max_length=10,
|
|
choices=ABNORMAL_FLAG_CHOICES,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Abnormal flag'
|
|
)
|
|
|
|
# Critical Values
|
|
is_critical = models.BooleanField(
|
|
default=False,
|
|
help_text='Result is critical value'
|
|
)
|
|
critical_called = models.BooleanField(
|
|
default=False,
|
|
help_text='Critical value was called to provider'
|
|
)
|
|
critical_called_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time critical value was called'
|
|
)
|
|
critical_called_to = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Person critical value was called to'
|
|
)
|
|
|
|
# Processing Information
|
|
analyzed_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time analyzed'
|
|
)
|
|
analyzed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='analyzed_results',
|
|
help_text='Lab technician who analyzed'
|
|
)
|
|
analyzer = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Analyzer/instrument used'
|
|
)
|
|
|
|
# Verification
|
|
verified = models.BooleanField(
|
|
default=False,
|
|
help_text='Result has been verified'
|
|
)
|
|
verified_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='verified_results',
|
|
help_text='Lab professional who verified result'
|
|
)
|
|
verified_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time of verification'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=STATUS_CHOICES,
|
|
default='PENDING',
|
|
help_text='Result status'
|
|
)
|
|
|
|
# Comments
|
|
technician_comments = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Technician comments'
|
|
)
|
|
pathologist_comments = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Pathologist comments'
|
|
)
|
|
|
|
# Quality Control
|
|
qc_passed = models.BooleanField(
|
|
default=True,
|
|
help_text='Quality control passed'
|
|
)
|
|
qc_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Quality control notes'
|
|
)
|
|
|
|
# Reporting
|
|
reported_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time result was reported'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'laboratory_lab_result'
|
|
verbose_name = 'Lab Result'
|
|
verbose_name_plural = 'Lab Results'
|
|
ordering = ['-analyzed_datetime']
|
|
indexes = [
|
|
models.Index(fields=['order']),
|
|
models.Index(fields=['test']),
|
|
models.Index(fields=['specimen']),
|
|
models.Index(fields=['status']),
|
|
models.Index(fields=['is_critical']),
|
|
models.Index(fields=['verified']),
|
|
models.Index(fields=['analyzed_datetime']),
|
|
]
|
|
unique_together = ['order', 'test', 'specimen']
|
|
|
|
def __str__(self):
|
|
return f"{self.test.test_name} - {self.result_value} {self.result_unit or ''}"
|
|
|
|
@property
|
|
def patient(self):
|
|
"""
|
|
Get patient from order.
|
|
"""
|
|
return self.order.patient
|
|
|
|
@property
|
|
def is_abnormal(self):
|
|
"""
|
|
Check if result is abnormal.
|
|
"""
|
|
return self.abnormal_flag and self.abnormal_flag != 'N'
|
|
|
|
@property
|
|
def is_critical_high(self):
|
|
"""
|
|
Check if result is critically high.
|
|
"""
|
|
return self.abnormal_flag == 'HH'
|
|
|
|
@property
|
|
def is_critical_low(self):
|
|
"""
|
|
Check if result is critically low.
|
|
"""
|
|
return self.abnormal_flag == 'LL'
|
|
|
|
|
|
class QualityControl(models.Model):
|
|
"""
|
|
Quality control model for lab quality management.
|
|
"""
|
|
CONTROL_LEVEL_CHOICES = [
|
|
('NORMAL', 'Normal'),
|
|
('LOW', 'Low'),
|
|
('HIGH', 'High'),
|
|
('CRITICAL', 'Critical'),
|
|
]
|
|
STATUS_CHOICES = [
|
|
('PASSED', 'Passed'),
|
|
('FAILED', 'Failed'),
|
|
('WARNING', 'Warning'),
|
|
('PENDING', 'Pending'),
|
|
]
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='lab_quality_controls',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Test relationship
|
|
test = models.ForeignKey(
|
|
LabTest,
|
|
on_delete=models.CASCADE,
|
|
related_name='quality_controls',
|
|
help_text='Lab test'
|
|
)
|
|
result = models.ForeignKey(LabResult, on_delete=models.CASCADE, related_name='quality_controls')
|
|
# QC Information
|
|
qc_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique QC identifier'
|
|
)
|
|
|
|
# QC Material
|
|
control_material = models.CharField(
|
|
max_length=100,
|
|
help_text='Control material name'
|
|
)
|
|
control_lot = models.CharField(
|
|
max_length=50,
|
|
help_text='Control lot number'
|
|
)
|
|
control_level = models.CharField(
|
|
max_length=20,
|
|
choices=CONTROL_LEVEL_CHOICES,
|
|
help_text='Control level'
|
|
)
|
|
|
|
# Expected Values
|
|
target_value = models.DecimalField(
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
help_text='Target value'
|
|
)
|
|
acceptable_range_low = models.DecimalField(
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
help_text='Acceptable range low'
|
|
)
|
|
acceptable_range_high = models.DecimalField(
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
help_text='Acceptable range high'
|
|
)
|
|
|
|
# QC Run Information
|
|
run_datetime = models.DateTimeField(
|
|
help_text='Date and time of QC run'
|
|
)
|
|
observed_value = models.DecimalField(
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
help_text='Observed value'
|
|
)
|
|
|
|
# QC Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=STATUS_CHOICES,
|
|
help_text='QC status'
|
|
)
|
|
|
|
# Staff
|
|
performed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='performed_qc',
|
|
help_text='Staff who performed QC'
|
|
)
|
|
reviewed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='reviewed_qc',
|
|
help_text='Staff who reviewed QC'
|
|
)
|
|
|
|
# Analyzer
|
|
analyzer = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Analyzer/instrument used'
|
|
)
|
|
|
|
# Comments
|
|
comments = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='QC comments'
|
|
)
|
|
corrective_action = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Corrective action taken'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'laboratory_quality_control'
|
|
verbose_name = 'Quality Control'
|
|
verbose_name_plural = 'Quality Controls'
|
|
ordering = ['-run_datetime']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'test']),
|
|
models.Index(fields=['test', 'run_datetime']),
|
|
models.Index(fields=['status']),
|
|
models.Index(fields=['control_level']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.test.test_name} - {self.control_level} - {self.run_datetime.date()}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Determine QC status based on observed value.
|
|
"""
|
|
if self.observed_value:
|
|
if (self.acceptable_range_low <= self.observed_value <= self.acceptable_range_high):
|
|
self.status = 'PASSED'
|
|
else:
|
|
self.status = 'FAILED'
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_within_range(self):
|
|
"""
|
|
Check if observed value is within acceptable range.
|
|
"""
|
|
return (self.acceptable_range_low <= self.observed_value <= self.acceptable_range_high)
|
|
|
|
@property
|
|
def deviation_percentage(self):
|
|
"""
|
|
Calculate deviation percentage from target.
|
|
"""
|
|
if self.target_value and self.observed_value:
|
|
return ((self.observed_value - self.target_value) / self.target_value) * 100
|
|
return None
|
|
|
|
|
|
class ReferenceRange(models.Model):
|
|
"""
|
|
Reference range model for test normal values.
|
|
"""
|
|
GENDER_CHOICES = [
|
|
('M', 'Male'),
|
|
('F', 'Female'),
|
|
('ALL', 'All'),
|
|
]
|
|
# Test relationship
|
|
test = models.ForeignKey(
|
|
LabTest,
|
|
on_delete=models.CASCADE,
|
|
related_name='reference_ranges',
|
|
help_text='Lab test'
|
|
)
|
|
|
|
# Range Information
|
|
range_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique range identifier'
|
|
)
|
|
|
|
# Demographics
|
|
gender = models.CharField(
|
|
max_length=10,
|
|
choices=GENDER_CHOICES,
|
|
default='ALL',
|
|
help_text='Gender'
|
|
)
|
|
age_min = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Minimum age in years'
|
|
)
|
|
age_max = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Maximum age in years'
|
|
)
|
|
|
|
# Range Values
|
|
range_low = models.DecimalField(
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Range low value'
|
|
)
|
|
range_high = models.DecimalField(
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Range high value'
|
|
)
|
|
range_text = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Text description of range'
|
|
)
|
|
|
|
# Critical Values
|
|
critical_low = models.DecimalField(
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Critical low value'
|
|
)
|
|
critical_high = models.DecimalField(
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Critical high value'
|
|
)
|
|
|
|
# Units
|
|
unit = models.CharField(
|
|
max_length=20,
|
|
help_text='Unit of measure'
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Reference range is active'
|
|
)
|
|
|
|
# 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_reference_ranges',
|
|
help_text='User who created the reference range'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'laboratory_reference_range'
|
|
verbose_name = 'Reference Range'
|
|
verbose_name_plural = 'Reference Ranges'
|
|
ordering = ['test', 'gender', 'age_min']
|
|
indexes = [
|
|
models.Index(fields=['test', 'is_active']),
|
|
models.Index(fields=['gender']),
|
|
models.Index(fields=['age_min', 'age_max']),
|
|
]
|
|
|
|
def __str__(self):
|
|
age_range = ""
|
|
if self.age_min is not None or self.age_max is not None:
|
|
age_range = f" ({self.age_min or 0}-{self.age_max or '∞'} years)"
|
|
|
|
return f"{self.test.test_name} - {self.gender}{age_range}"
|
|
|
|
def is_applicable(self, patient_age, patient_gender):
|
|
"""
|
|
Check if reference range is applicable for patient.
|
|
"""
|
|
# Check gender
|
|
if self.gender != 'ALL' and self.gender != patient_gender:
|
|
return False
|
|
|
|
# Check age
|
|
if self.age_min is not None and patient_age < self.age_min:
|
|
return False
|
|
if self.age_max is not None and patient_age > self.age_max:
|
|
return False
|
|
|
|
return True
|
|
|
|
@property
|
|
def display_range(self):
|
|
"""
|
|
Get display string for range.
|
|
"""
|
|
if self.range_text:
|
|
return self.range_text
|
|
elif self.range_low is not None and self.range_high is not None:
|
|
return f"{self.range_low} - {self.range_high} {self.unit}"
|
|
elif self.range_low is not None:
|
|
return f"> {self.range_low} {self.unit}"
|
|
elif self.range_high is not None:
|
|
return f"< {self.range_high} {self.unit}"
|
|
else:
|
|
return "Not defined"
|
|
|