1389 lines
39 KiB
Python
1389 lines
39 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 django.contrib.contenttypes.fields import GenericRelation
|
|
from datetime import timedelta, datetime, date
|
|
from decimal import Decimal
|
|
import json
|
|
|
|
|
|
class LabTest(models.Model):
|
|
"""
|
|
Lab test model for test catalog and configuration.
|
|
"""
|
|
|
|
class TestCategory(models.TextChoices):
|
|
CHEMISTRY = 'CHEMISTRY', 'Chemistry'
|
|
HEMATOLOGY = 'HEMATOLOGY', 'Hematology'
|
|
MICROBIOLOGY = 'MICROBIOLOGY', 'Microbiology'
|
|
IMMUNOLOGY = 'IMMUNOLOGY', 'Immunology'
|
|
MOLECULAR = 'MOLECULAR', 'Molecular'
|
|
PATHOLOGY = 'PATHOLOGY', 'Pathology'
|
|
TOXICOLOGY = 'TOXICOLOGY', 'Toxicology'
|
|
ENDOCRINOLOGY = 'ENDOCRINOLOGY', 'Endocrinology'
|
|
CARDIOLOGY = 'CARDIOLOGY', 'Cardiology'
|
|
ONCOLOGY = 'ONCOLOGY', 'Oncology'
|
|
GENETICS = 'GENETICS', 'Genetics'
|
|
COAGULATION = 'COAGULATION', 'Coagulation'
|
|
URINALYSIS = 'URINALYSIS', 'Urinalysis'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class TestType(models.TextChoices):
|
|
QUANTITATIVE = 'QUANTITATIVE', 'Quantitative'
|
|
QUALITATIVE = 'QUALITATIVE', 'Qualitative'
|
|
SEMI_QUANTITATIVE = 'SEMI_QUANTITATIVE', 'Semi-Quantitative'
|
|
CULTURE = 'CULTURE', 'Culture'
|
|
MICROSCOPY = 'MICROSCOPY', 'Microscopy'
|
|
MOLECULAR = 'MOLECULAR', 'Molecular'
|
|
IMMUNOASSAY = 'IMMUNOASSAY', 'Immunoassay'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class SpecimenType(models.TextChoices):
|
|
BLOOD = 'BLOOD', 'Blood'
|
|
SERUM = 'SERUM', 'Serum'
|
|
PLASMA = 'PLASMA', 'Plasma'
|
|
URINE = 'URINE', 'Urine'
|
|
STOOL = 'STOOL', 'Stool'
|
|
CSF = 'CSF', 'Cerebrospinal Fluid'
|
|
SPUTUM = 'SPUTUM', 'Sputum'
|
|
SWAB = 'SWAB', 'Swab'
|
|
TISSUE = 'TISSUE', 'Tissue'
|
|
FLUID = 'FLUID', 'Body Fluid'
|
|
SALIVA = 'SALIVA', 'Saliva'
|
|
HAIR = 'HAIR', 'Hair'
|
|
NAIL = 'NAIL', 'Nail'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class StorageTemperature(models.TextChoices):
|
|
ROOM_TEMP = 'ROOM_TEMP', 'Room Temperature'
|
|
REFRIGERATED = 'REFRIGERATED', 'Refrigerated (2-8°C)'
|
|
FROZEN = 'FROZEN', 'Frozen (-20°C)'
|
|
DEEP_FROZEN = 'DEEP_FROZEN', 'Deep Frozen (-80°C)'
|
|
ICE = 'ICE', 'On Ice'
|
|
AMBIENT = 'AMBIENT', 'Ambient'
|
|
|
|
class QCFrequency(models.TextChoices):
|
|
DAILY = 'DAILY', 'Daily'
|
|
WEEKLY = 'WEEKLY', 'Weekly'
|
|
MONTHLY = 'MONTHLY', 'Monthly'
|
|
PER_BATCH = 'PER_BATCH', 'Per Batch'
|
|
CONTINUOUS = 'CONTINUOUS', 'Continuous'
|
|
|
|
class Department(models.TextChoices):
|
|
CHEMISTRY = 'CHEMISTRY', 'Chemistry'
|
|
HEMATOLOGY = 'HEMATOLOGY', 'Hematology'
|
|
MICROBIOLOGY = 'MICROBIOLOGY', 'Microbiology'
|
|
IMMUNOLOGY = 'IMMUNOLOGY', 'Immunology'
|
|
MOLECULAR = 'MOLECULAR', 'Molecular'
|
|
PATHOLOGY = 'PATHOLOGY', 'Pathology'
|
|
BLOOD_BANK = 'BLOOD_BANK', 'Blood Bank'
|
|
CYTOLOGY = 'CYTOLOGY', 'Cytology'
|
|
HISTOLOGY = 'HISTOLOGY', 'Histology'
|
|
TOXICOLOGY = '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=TestCategory.choices,
|
|
help_text='Test category'
|
|
)
|
|
test_type = models.CharField(
|
|
max_length=30,
|
|
choices= TestType.choices,
|
|
help_text='Type of test'
|
|
)
|
|
|
|
# Specimen Requirements
|
|
specimen_type = models.CharField(
|
|
max_length=30,
|
|
choices=SpecimenType,
|
|
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=StorageTemperature.choices,
|
|
default=StorageTemperature.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=QCFrequency.choices,
|
|
default=QCFrequency.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.
|
|
"""
|
|
|
|
class LabPriority(models.TextChoices):
|
|
ROUTINE = 'ROUTINE', 'Routine'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
STAT = 'STAT', 'STAT'
|
|
ASAP = 'ASAP', 'ASAP'
|
|
TIMED = 'TIMED', 'Timed'
|
|
|
|
class LabStatus(models.TextChoices):
|
|
PENDING = 'PENDING', 'Pending'
|
|
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
|
COLLECTED = 'COLLECTED', 'Collected'
|
|
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
ON_HOLD = 'ON_HOLD', 'On Hold'
|
|
|
|
class FastingStatus(models.TextChoices):
|
|
FASTING = 'FASTING', 'Fasting'
|
|
NON_FASTING = 'NON_FASTING', 'Non-Fasting'
|
|
UNKNOWN = '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'
|
|
)
|
|
|
|
# Collection scheduling
|
|
scheduled_collection = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Scheduled collection date and time'
|
|
)
|
|
|
|
# Order Details
|
|
order_datetime = models.DateTimeField(
|
|
default=timezone.now,
|
|
help_text='Date and time order was placed'
|
|
)
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=LabPriority.choices,
|
|
default=LabPriority.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=FastingStatus.choices,
|
|
default=FastingStatus.UNKNOWN,
|
|
help_text='Patient fasting status'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=LabStatus.choices,
|
|
default=LabStatus.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'
|
|
)
|
|
|
|
# Insurance Approval Integration
|
|
approval_requests = GenericRelation(
|
|
'insurance_approvals.InsuranceApprovalRequest',
|
|
content_type_field='content_type',
|
|
object_id_field='object_id',
|
|
related_query_name='lab_order'
|
|
)
|
|
|
|
# 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'
|
|
|
|
def has_valid_approval(self):
|
|
"""
|
|
Check if order has a valid insurance approval.
|
|
"""
|
|
from django.utils import timezone
|
|
return self.approval_requests.filter(
|
|
status__in=['APPROVED', 'PARTIALLY_APPROVED'],
|
|
expiration_date__gte=timezone.now().date()
|
|
).exists()
|
|
|
|
def get_active_approval(self):
|
|
"""
|
|
Get the active insurance approval for this order.
|
|
"""
|
|
from django.utils import timezone
|
|
return self.approval_requests.filter(
|
|
status__in=['APPROVED', 'PARTIALLY_APPROVED'],
|
|
expiration_date__gte=timezone.now().date()
|
|
).first()
|
|
|
|
def requires_approval(self):
|
|
"""
|
|
Check if order requires insurance approval.
|
|
Returns True if patient has insurance and no valid approval exists.
|
|
"""
|
|
if not self.patient.insurance_info.exists():
|
|
return False
|
|
return not self.has_valid_approval()
|
|
|
|
@property
|
|
def approval_status(self):
|
|
"""
|
|
Get current approval status for display.
|
|
"""
|
|
if not self.patient.insurance_info.exists():
|
|
return 'NO_INSURANCE'
|
|
|
|
latest_approval = self.approval_requests.order_by('-created_at').first()
|
|
if not latest_approval:
|
|
return 'APPROVAL_REQUIRED'
|
|
|
|
if self.has_valid_approval():
|
|
return 'APPROVED'
|
|
|
|
return latest_approval.status
|
|
|
|
|
|
class Specimen(models.Model):
|
|
"""
|
|
Specimen model for specimen tracking and management.
|
|
"""
|
|
|
|
class SpecimenType(models.TextChoices):
|
|
BLOOD = 'BLOOD', 'Blood'
|
|
SERUM = 'SERUM', 'Serum'
|
|
PLASMA = 'PLASMA', 'Plasma'
|
|
URINE = 'URINE', 'Urine'
|
|
STOOL = 'STOOL', 'Stool'
|
|
CSF = 'CSF', 'Cerebrospinal Fluid'
|
|
SPUTUM = 'SPUTUM', 'Sputum'
|
|
SWAB = 'SWAB', 'Swab'
|
|
TISSUE = 'TISSUE', 'Tissue'
|
|
FLUID = 'FLUID', 'Body Fluid'
|
|
SALIVA = 'SALIVA', 'Saliva'
|
|
HAIR = 'HAIR', 'Hair'
|
|
NAIL = 'NAIL', 'Nail'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class SpecimenQuality(models.TextChoices):
|
|
ACCEPTABLE = 'ACCEPTABLE', 'Acceptable'
|
|
SUBOPTIMAL = 'SUBOPTIMAL', 'Suboptimal'
|
|
REJECTED = 'REJECTED', 'Rejected'
|
|
|
|
class RejectionReason(models.TextChoices):
|
|
HEMOLYZED = 'HEMOLYZED', 'Hemolyzed'
|
|
CLOTTED = 'CLOTTED', 'Clotted'
|
|
INSUFFICIENT_VOLUME = 'INSUFFICIENT_VOLUME', 'Insufficient Volume'
|
|
CONTAMINATED = 'CONTAMINATED', 'Contaminated'
|
|
MISLABELED = 'MISLABELED', 'Mislabeled'
|
|
EXPIRED = 'EXPIRED', 'Expired'
|
|
IMPROPER_STORAGE = 'IMPROPER_STORAGE', 'Improper Storage'
|
|
DAMAGED_CONTAINER = 'DAMAGED_CONTAINER', 'Damaged Container'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class StorageTemperature(models.TextChoices):
|
|
ROOM_TEMP = 'ROOM_TEMP', 'Room Temperature'
|
|
REFRIGERATED = 'REFRIGERATED', 'Refrigerated (2-8°C)'
|
|
FROZEN = 'FROZEN', 'Frozen (-20°C)'
|
|
DEEP_FROZEN = 'DEEP_FROZEN', 'Deep Frozen (-80°C)'
|
|
|
|
class SpecimenStatus(models.TextChoices):
|
|
COLLECTED = 'COLLECTED', 'Collected'
|
|
IN_TRANSIT = 'IN_TRANSIT', 'In Transit'
|
|
RECEIVED = 'RECEIVED', 'Received'
|
|
PROCESSING = 'PROCESSING', 'Processing'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
REJECTED = 'REJECTED', 'Rejected'
|
|
DISPOSED = '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=SpecimenType.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=SpecimenQuality.choices,
|
|
default=SpecimenQuality.ACCEPTABLE,
|
|
help_text='Specimen quality'
|
|
)
|
|
rejection_reason = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
choices=RejectionReason.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=StorageTemperature.choices,
|
|
default=StorageTemperature.ROOM_TEMP,
|
|
help_text='Storage temperature'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=SpecimenStatus.choices,
|
|
default=SpecimenStatus.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.
|
|
"""
|
|
|
|
class ResultType(models.TextChoices):
|
|
NUMERIC = 'NUMERIC', 'Numeric'
|
|
TEXT = 'TEXT', 'Text'
|
|
CODED = 'CODED', 'Coded'
|
|
NARRATIVE = 'NARRATIVE', 'Narrative'
|
|
|
|
class AbnormalFlag(models.TextChoices):
|
|
NORMAL = 'N', 'Normal'
|
|
HIGH = 'H', 'High'
|
|
LOW = 'L', 'Low'
|
|
CRITICAL_HIGH = 'HH', 'Critical High'
|
|
CRITICAL_LOW = 'LL', 'Critical Low'
|
|
ABNORMAL = 'A', 'Abnormal'
|
|
|
|
class ResultStatus(models.TextChoices):
|
|
PENDING = 'PENDING', 'Pending'
|
|
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
VERIFIED = 'VERIFIED', 'Verified'
|
|
AMENDED = 'AMENDED', 'Amended'
|
|
CANCELLED = '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=ResultType.choices,
|
|
default=ResultType.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=AbnormalFlag.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=ResultStatus.choices,
|
|
default=ResultStatus.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.
|
|
"""
|
|
|
|
class ControlLevel(models.TextChoices):
|
|
NORMAL = 'NORMAL', 'Normal'
|
|
LOW = 'LOW', 'Low'
|
|
HIGH = 'HIGH', 'High'
|
|
CRITICAL = 'CRITICAL', 'Critical'
|
|
|
|
class QCStatus(models.TextChoices):
|
|
PASSED = 'PASSED', 'Passed'
|
|
FAILED = 'FAILED', 'Failed'
|
|
WARNING = 'WARNING', 'Warning'
|
|
PENDING = '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=ControlLevel.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=QCStatus.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.
|
|
"""
|
|
|
|
class Gender(models.TextChoices):
|
|
MALE = 'M', 'Male'
|
|
FEMALE = 'F', 'Female'
|
|
ALL = '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=Gender.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"
|