2725 lines
78 KiB
Python
2725 lines
78 KiB
Python
"""
|
|
EMR app models for hospital management system.
|
|
Provides electronic medical records, clinical documentation, and patient care management.
|
|
"""
|
|
|
|
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, time
|
|
import json
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models as django_models
|
|
|
|
|
|
class EncounterManager(django_models.Manager):
|
|
"""
|
|
Custom manager for Encounter model with common queries.
|
|
"""
|
|
|
|
def active_encounters(self, tenant):
|
|
"""Get all active encounters for a tenant."""
|
|
return self.filter(
|
|
tenant=tenant,
|
|
status__in=['ARRIVED', 'TRIAGED', 'IN_PROGRESS', 'ON_HOLD']
|
|
)
|
|
|
|
def encounters_by_provider(self, provider, tenant, days_back=30):
|
|
"""Get encounters by provider within specified days."""
|
|
start_date = timezone.now().date() - timedelta(days=days_back)
|
|
return self.filter(
|
|
tenant=tenant,
|
|
provider=provider,
|
|
start_datetime__date__gte=start_date
|
|
).order_by('-start_datetime')
|
|
|
|
def encounters_by_patient(self, patient, tenant):
|
|
"""Get all encounters for a specific patient."""
|
|
return self.filter(
|
|
tenant=tenant,
|
|
patient=patient
|
|
).order_by('-start_datetime')
|
|
|
|
def encounters_by_type(self, encounter_type, tenant, days_back=30):
|
|
"""Get encounters by type within specified days."""
|
|
start_date = timezone.now().date() - timedelta(days=days_back)
|
|
return self.filter(
|
|
tenant=tenant,
|
|
encounter_type=encounter_type,
|
|
start_datetime__date__gte=start_date
|
|
).order_by('-start_datetime')
|
|
|
|
def todays_encounters(self, tenant):
|
|
"""Get today's encounters for a tenant."""
|
|
today = timezone.now().date()
|
|
return self.filter(
|
|
tenant=tenant,
|
|
start_datetime__date=today
|
|
).order_by('-start_datetime')
|
|
|
|
def unsigned_encounters(self, tenant):
|
|
"""Get encounters that need signing off."""
|
|
return self.filter(
|
|
tenant=tenant,
|
|
status='FINISHED',
|
|
signed_off=False
|
|
).order_by('-end_datetime')
|
|
|
|
|
|
class Encounter(models.Model):
|
|
"""
|
|
Clinical encounter model for tracking patient visits and care episodes.
|
|
"""
|
|
objects = EncounterManager()
|
|
|
|
class EncounterType(models.TextChoices):
|
|
INPATIENT = 'INPATIENT', 'Inpatient'
|
|
OUTPATIENT = 'OUTPATIENT', 'Outpatient'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency'
|
|
URGENT_CARE = 'URGENT_CARE', 'Urgent Care'
|
|
OBSERVATION = 'OBSERVATION', 'Observation'
|
|
TELEMEDICINE = 'TELEMEDICINE', 'Telemedicine'
|
|
HOME_VISIT = 'HOME_VISIT', 'Home Visit'
|
|
CONSULTATION = 'CONSULTATION', 'Consultation'
|
|
FOLLOW_UP = 'FOLLOW_UP', 'Follow-up'
|
|
PROCEDURE = 'PROCEDURE', 'Procedure'
|
|
SURGERY = 'SURGERY', 'Surgery'
|
|
DIAGNOSTIC = 'DIAGNOSTIC', 'Diagnostic'
|
|
PREVENTIVE = 'PREVENTIVE', 'Preventive Care'
|
|
|
|
class EncounterClass(models.TextChoices):
|
|
AMB = 'AMB', 'Ambulatory'
|
|
EMER = 'EMER', 'Emergency'
|
|
FLD = 'FLD', 'Field'
|
|
HH = 'HH', 'Home Health'
|
|
IMP = 'IMP', 'Inpatient'
|
|
ACUTE = 'ACUTE', 'Inpatient Acute'
|
|
NONAC = 'NONAC', 'Inpatient Non-Acute'
|
|
OBSENC = 'OBSENC', 'Observation Encounter'
|
|
PRENC = 'PRENC', 'Pre-Admission'
|
|
SS = 'SS', 'Short Stay'
|
|
VR = 'VR', 'Virtual'
|
|
|
|
class EncounterStatus(models.TextChoices):
|
|
PLANNED = 'PLANNED', 'Planned'
|
|
ARRIVED = 'ARRIVED', 'Arrived'
|
|
TRIAGED = 'TRIAGED', 'Triaged'
|
|
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
|
|
ON_HOLD = 'ON_HOLD', 'On Hold'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
ENTERED_IN_ERROR = 'ENTERED_IN_ERROR', 'Entered in Error'
|
|
UNKNOWN = 'UNKNOWN', 'Unknown'
|
|
|
|
class Priority(models.TextChoices):
|
|
ROUTINE = 'ROUTINE', 'Routine'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
STAT = 'STAT', 'STAT'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency'
|
|
|
|
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='encounters',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Encounter Information
|
|
encounter_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique encounter identifier'
|
|
)
|
|
|
|
# Patient and Provider
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='encounters',
|
|
help_text='Patient for this encounter'
|
|
)
|
|
provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='encounters',
|
|
help_text='Primary provider for this encounter'
|
|
)
|
|
|
|
# Encounter Details
|
|
encounter_type = models.CharField(
|
|
max_length=30,
|
|
choices=EncounterType.choices,
|
|
help_text='Type of encounter'
|
|
)
|
|
|
|
encounter_class = models.CharField(
|
|
max_length=20,
|
|
choices=EncounterClass.choices,
|
|
help_text='Encounter class (HL7 standard)'
|
|
)
|
|
|
|
# Timing
|
|
start_datetime = models.DateTimeField(
|
|
help_text='Encounter start date and time'
|
|
)
|
|
end_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Encounter end date and time'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=EncounterStatus.choices,
|
|
default=EncounterStatus.PLANNED,
|
|
help_text='Current encounter status'
|
|
)
|
|
|
|
# Location Information
|
|
location = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Encounter location'
|
|
)
|
|
room_number = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Room number'
|
|
)
|
|
|
|
# Related Records
|
|
appointment = models.ForeignKey(
|
|
'appointments.AppointmentRequest',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='encounters',
|
|
help_text='Related appointment'
|
|
)
|
|
admission = models.ForeignKey(
|
|
'inpatients.Admission',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='encounters',
|
|
help_text='Related admission'
|
|
)
|
|
|
|
# Clinical Information
|
|
chief_complaint = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Chief complaint'
|
|
)
|
|
reason_for_visit = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Reason for visit'
|
|
)
|
|
|
|
# Priority and Acuity
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=Priority.choices,
|
|
default=Priority.ROUTINE,
|
|
help_text='Encounter priority'
|
|
)
|
|
acuity_level = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
|
help_text='Patient acuity level (1-5, 5 being highest)'
|
|
)
|
|
|
|
# Documentation Status
|
|
documentation_complete = models.BooleanField(
|
|
default=False,
|
|
help_text='Documentation is complete'
|
|
)
|
|
signed_off = models.BooleanField(
|
|
default=False,
|
|
help_text='Encounter has been signed off'
|
|
)
|
|
signed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='signed_encounters',
|
|
help_text='Provider who signed off'
|
|
)
|
|
signed_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time of sign-off'
|
|
)
|
|
|
|
# Billing Information
|
|
billable = models.BooleanField(
|
|
default=True,
|
|
help_text='Encounter is billable'
|
|
)
|
|
billing_codes = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Associated billing codes'
|
|
)
|
|
|
|
# Quality Measures
|
|
quality_measures = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text='Quality measure data'
|
|
)
|
|
|
|
# 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_encounters',
|
|
help_text='User who created the encounter'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_encounter'
|
|
verbose_name = 'Encounter'
|
|
verbose_name_plural = 'Encounters'
|
|
ordering = ['-start_datetime']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'start_datetime']),
|
|
models.Index(fields=['provider']),
|
|
models.Index(fields=['encounter_type']),
|
|
models.Index(fields=['start_datetime']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.start_datetime.strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
@property
|
|
def duration(self):
|
|
"""
|
|
Calculate encounter duration.
|
|
"""
|
|
if self.end_datetime:
|
|
return self.end_datetime - self.start_datetime
|
|
elif self.status in ['IN_PROGRESS', 'ON_HOLD']:
|
|
return timezone.now() - self.start_datetime
|
|
return None
|
|
|
|
@property
|
|
def is_active(self):
|
|
"""
|
|
Check if encounter is currently active.
|
|
"""
|
|
return self.status in ['ARRIVED', 'TRIAGED', 'IN_PROGRESS', 'ON_HOLD']
|
|
|
|
def get_status_color(self):
|
|
"""
|
|
Get Bootstrap color class for status display.
|
|
"""
|
|
|
|
status_colors = {
|
|
'PLANNED': 'danger',
|
|
'ARRIVED': 'warning',
|
|
'TRIAGED': 'success',
|
|
'IN_PROGRESS': 'info',
|
|
'ON_HOLD': 'warning',
|
|
'COMPLETED': 'warning',
|
|
'CANCELLED': 'secondary',
|
|
'ENTERED_IN_ERROR': 'secondary',
|
|
'UNKNOWN': 'primary',
|
|
}
|
|
return status_colors.get(self.status, 'secondary')
|
|
|
|
|
|
class VitalSigns(models.Model):
|
|
"""
|
|
Vital signs model for tracking patient vital signs measurements.
|
|
"""
|
|
|
|
class TemperatureMethod(models.TextChoices):
|
|
ORAL = 'ORAL', 'Oral'
|
|
RECTAL = 'RECTAL', 'Rectal'
|
|
AXILLARY = 'AXILLARY', 'Axillary'
|
|
TYMPANIC = 'TYMPANIC', 'Tympanic'
|
|
TEMPORAL = 'TEMPORAL', 'Temporal'
|
|
CORE = 'CORE', 'Core'
|
|
|
|
class BpPosition(models.TextChoices):
|
|
SITTING = 'SITTING', 'Sitting'
|
|
STANDING = 'STANDING', 'Standing'
|
|
LYING = 'LYING', 'Lying'
|
|
SUPINE = 'SUPINE', 'Supine'
|
|
|
|
class BpCuffSize(models.TextChoices):
|
|
SMALL = 'SMALL', 'Small'
|
|
REGULAR = 'REGULAR', 'Regular'
|
|
LARGE = 'LARGE', 'Large'
|
|
EXTRA_LARGE = 'EXTRA_LARGE', 'Extra Large'
|
|
PEDIATRIC = 'PEDIATRIC', 'Pediatric'
|
|
|
|
class HeartRhythm(models.TextChoices):
|
|
REGULAR = 'REGULAR', 'Regular'
|
|
REGULARLY_IRREGULAR = 'REGULARLY_IRREGULAR', 'Regularly irregular'
|
|
IRREGULARLY_IRREGULAR = 'IRREGULARLY_IRREGULAR', 'Irregularly irregular'
|
|
IRREGULAR_UNSPECIFIED = 'IRREGULAR_UNSPECIFIED', 'Irregular (unspecified)'
|
|
|
|
class OxygenDelivery(models.TextChoices):
|
|
ROOM_AIR = 'ROOM_AIR', 'Room Air'
|
|
NASAL_CANNULA = 'NASAL_CANNULA', 'Nasal Cannula'
|
|
SIMPLE_MASK = 'SIMPLE_MASK', 'Simple Mask'
|
|
NON_REBREATHER = 'NON_REBREATHER', 'Non-Rebreather Mask'
|
|
VENTURI_MASK = 'VENTURI_MASK', 'Venturi Mask'
|
|
CPAP = 'CPAP', 'CPAP'
|
|
BIPAP = 'BIPAP', 'BiPAP'
|
|
MECHANICAL_VENTILATION = 'MECHANICAL_VENTILATION', 'Mechanical Ventilation'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
# Encounter relationship
|
|
encounter = models.ForeignKey(
|
|
Encounter,
|
|
on_delete=models.CASCADE,
|
|
related_name='vital_signs',
|
|
help_text='Associated encounter'
|
|
)
|
|
|
|
# Patient relationship
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='vital_signs',
|
|
help_text='Patient'
|
|
)
|
|
|
|
# Measurement Information
|
|
measurement_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique measurement identifier'
|
|
)
|
|
|
|
# Timing
|
|
measured_datetime = models.DateTimeField(
|
|
default=timezone.now,
|
|
help_text='Date and time of measurement'
|
|
)
|
|
|
|
# Vital Signs Measurements
|
|
temperature = models.DecimalField(
|
|
max_digits=4,
|
|
decimal_places=1,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Temperature in Celsius'
|
|
)
|
|
temperature_method = models.CharField(
|
|
max_length=20,
|
|
choices=TemperatureMethod.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Temperature measurement method'
|
|
)
|
|
|
|
# Blood Pressure
|
|
systolic_bp = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
validators=[MinValueValidator(50), MaxValueValidator(300)],
|
|
help_text='Systolic blood pressure (mmHg)'
|
|
)
|
|
diastolic_bp = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
validators=[MinValueValidator(30), MaxValueValidator(200)],
|
|
help_text='Diastolic blood pressure (mmHg)'
|
|
)
|
|
bp_position = models.CharField(
|
|
max_length=20,
|
|
choices=BpPosition.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient position during BP measurement'
|
|
)
|
|
bp_cuff_size = models.CharField(
|
|
max_length=20,
|
|
choices=BpCuffSize.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Blood pressure cuff size'
|
|
)
|
|
|
|
# Heart Rate
|
|
heart_rate = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
validators=[MinValueValidator(20), MaxValueValidator(300)],
|
|
help_text='Heart rate (beats per minute)'
|
|
)
|
|
heart_rhythm = models.CharField(
|
|
max_length=25,
|
|
choices=HeartRhythm.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Heart rhythm'
|
|
)
|
|
|
|
# Respiratory Rate
|
|
respiratory_rate = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
validators=[MinValueValidator(5), MaxValueValidator(60)],
|
|
help_text='Respiratory rate (breaths per minute)'
|
|
)
|
|
|
|
# Oxygen Saturation
|
|
oxygen_saturation = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
validators=[MinValueValidator(50), MaxValueValidator(100)],
|
|
help_text='Oxygen saturation (%)'
|
|
)
|
|
oxygen_delivery = models.CharField(
|
|
max_length=30,
|
|
choices=OxygenDelivery.choices,
|
|
default=OxygenDelivery.ROOM_AIR,
|
|
help_text='Oxygen delivery method'
|
|
)
|
|
oxygen_flow_rate = models.DecimalField(
|
|
max_digits=4,
|
|
decimal_places=1,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Oxygen flow rate (L/min)'
|
|
)
|
|
|
|
# Pain Scale
|
|
pain_scale = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
validators=[MinValueValidator(0), MaxValueValidator(10)],
|
|
help_text='Pain scale (0-10)'
|
|
)
|
|
pain_location = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Pain location'
|
|
)
|
|
pain_quality = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Pain quality description'
|
|
)
|
|
|
|
# Weight and Height
|
|
weight = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=1,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Weight in pounds'
|
|
)
|
|
height = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=1,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Height in inches'
|
|
)
|
|
|
|
# BMI (calculated)
|
|
bmi = models.DecimalField(
|
|
max_digits=4,
|
|
decimal_places=1,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Body Mass Index'
|
|
)
|
|
|
|
# Additional Measurements
|
|
head_circumference = models.DecimalField(
|
|
max_digits=4,
|
|
decimal_places=1,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Head circumference in cm (pediatric)'
|
|
)
|
|
|
|
# Device Information
|
|
device_used = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Device used for measurements'
|
|
)
|
|
device_calibrated = models.BooleanField(
|
|
default=True,
|
|
help_text='Device was calibrated'
|
|
)
|
|
|
|
# Staff Information
|
|
measured_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='vital_signs_measurements',
|
|
help_text='Staff member who took measurements'
|
|
)
|
|
verified_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='verified_vital_signs',
|
|
help_text='Staff member who verified measurements'
|
|
)
|
|
|
|
# Quality Flags
|
|
critical_values = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Critical values identified'
|
|
)
|
|
alerts_generated = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Alerts generated from measurements'
|
|
)
|
|
|
|
# Notes
|
|
notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Additional notes about measurements'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'emr_vital_signs'
|
|
verbose_name = 'Vital Signs'
|
|
verbose_name_plural = 'Vital Signs'
|
|
ordering = ['-measured_datetime']
|
|
indexes = [
|
|
models.Index(fields=['patient', 'measured_datetime']),
|
|
models.Index(fields=['encounter']),
|
|
models.Index(fields=['measured_datetime']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.measured_datetime.strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
def clean(self):
|
|
"""
|
|
Custom validation for VitalSigns model.
|
|
"""
|
|
# Validate blood pressure
|
|
if (self.systolic_bp and not self.diastolic_bp) or (self.diastolic_bp and not self.systolic_bp):
|
|
raise ValidationError('Both systolic and diastolic blood pressure must be provided together.')
|
|
|
|
if self.systolic_bp and self.diastolic_bp and self.systolic_bp <= self.diastolic_bp:
|
|
raise ValidationError('Systolic blood pressure must be greater than diastolic blood pressure.')
|
|
|
|
# Validate oxygen saturation and delivery
|
|
if self.oxygen_delivery and self.oxygen_delivery != 'ROOM_AIR' and not self.oxygen_flow_rate:
|
|
raise ValidationError('Oxygen flow rate is required when oxygen delivery method is specified.')
|
|
|
|
# Validate pain scale
|
|
if self.pain_scale and (self.pain_scale < 0 or self.pain_scale > 10):
|
|
raise ValidationError('Pain scale must be between 0 and 10.')
|
|
|
|
# Validate temperature
|
|
if self.temperature and (self.temperature < 30 or self.temperature > 45):
|
|
raise ValidationError('Temperature must be between 30°C and 45°C.')
|
|
|
|
# Validate heart rate
|
|
if self.heart_rate and (self.heart_rate < 20 or self.heart_rate > 300):
|
|
raise ValidationError('Heart rate must be between 20 and 300 bpm.')
|
|
|
|
# Validate respiratory rate
|
|
if self.respiratory_rate and (self.respiratory_rate < 5 or self.respiratory_rate > 60):
|
|
raise ValidationError('Respiratory rate must be between 5 and 60 breaths per minute.')
|
|
|
|
# Validate oxygen saturation
|
|
if self.oxygen_saturation and (self.oxygen_saturation < 50 or self.oxygen_saturation > 100):
|
|
raise ValidationError('Oxygen saturation must be between 50% and 100%.')
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Calculate BMI if weight and height are provided.
|
|
"""
|
|
self.full_clean() # Run validation before saving
|
|
|
|
if self.weight and self.height:
|
|
# BMI = (weight in pounds / (height in inches)^2) * 703
|
|
self.bmi = (self.weight / (self.height ** 2)) * 703
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def blood_pressure(self):
|
|
"""
|
|
Return formatted blood pressure.
|
|
"""
|
|
if self.systolic_bp and self.diastolic_bp:
|
|
return f"{self.systolic_bp}/{self.diastolic_bp}"
|
|
return None
|
|
|
|
@property
|
|
def has_critical_values(self):
|
|
"""
|
|
Check if any measurements have critical values.
|
|
"""
|
|
return len(self.critical_values) > 0
|
|
|
|
@property
|
|
def is_normal_temperature(self):
|
|
"""
|
|
Check if temperature is within normal range (36.1°C - 37.2°C).
|
|
"""
|
|
return self.temperature and 36.1 <= self.temperature <= 37.2
|
|
|
|
@property
|
|
def is_normal_blood_pressure(self):
|
|
"""
|
|
Check if blood pressure is within normal range (90/60 - 120/80).
|
|
"""
|
|
if not (self.systolic_bp and self.diastolic_bp):
|
|
return None
|
|
return 90 <= self.systolic_bp <= 140 and 60 <= self.diastolic_bp <= 90
|
|
|
|
@property
|
|
def is_normal_oxygen_saturation(self):
|
|
"""
|
|
Check if oxygen saturation is normal (>= 95%).
|
|
"""
|
|
return self.oxygen_saturation and self.oxygen_saturation >= 95
|
|
|
|
def get_vital_signs_summary(self):
|
|
"""
|
|
Get a summary of vital signs for display.
|
|
"""
|
|
summary = []
|
|
if self.temperature:
|
|
summary.append(f"T: {self.temperature}°C")
|
|
if self.blood_pressure:
|
|
summary.append(f"BP: {self.blood_pressure}")
|
|
if self.heart_rate:
|
|
summary.append(f"HR: {self.heart_rate}")
|
|
if self.respiratory_rate:
|
|
summary.append(f"RR: {self.respiratory_rate}")
|
|
if self.oxygen_saturation:
|
|
summary.append(f"SpO2: {self.oxygen_saturation}%")
|
|
return " | ".join(summary) if summary else "No vital signs recorded"
|
|
|
|
|
|
class ProblemList(models.Model):
|
|
"""
|
|
Problem list model for tracking patient problems and diagnoses.
|
|
"""
|
|
|
|
class CodingSystem(models.TextChoices):
|
|
ICD10 = 'ICD10', 'ICD-10'
|
|
ICD9 = 'ICD9', 'ICD-9'
|
|
SNOMED = 'SNOMED', 'SNOMED CT'
|
|
CPT = 'CPT', 'CPT'
|
|
LOINC = 'LOINC', 'LOINC'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class ProblemType(models.TextChoices):
|
|
DIAGNOSIS = 'DIAGNOSIS', 'Diagnosis'
|
|
SYMPTOM = 'SYMPTOM', 'Symptom'
|
|
FINDING = 'FINDING', 'Finding'
|
|
COMPLAINT = 'COMPLAINT', 'Complaint'
|
|
CONDITION = 'CONDITION', 'Condition'
|
|
DISORDER = 'DISORDER', 'Disorder'
|
|
SYNDROME = 'SYNDROME', 'Syndrome'
|
|
INJURY = 'INJURY', 'Injury'
|
|
ALLERGY = 'ALLERGY', 'Allergy'
|
|
INTOLERANCE = 'INTOLERANCE', 'Intolerance'
|
|
RISK_FACTOR = 'RISK_FACTOR', 'Risk Factor'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class Severity(models.TextChoices):
|
|
MILD = 'MILD', 'Mild'
|
|
MODERATE = 'MODERATE', 'Moderate'
|
|
SEVERE = 'SEVERE', 'Severe'
|
|
CRITICAL = 'CRITICAL', 'Critical'
|
|
UNKNOWN = 'UNKNOWN', 'Unknown'
|
|
|
|
class Priority(models.TextChoices):
|
|
LOW = 'LOW', 'Low'
|
|
MEDIUM = 'MEDIUM', 'Medium'
|
|
HIGH = 'HIGH', 'High'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
|
|
class ProblemStatus(models.TextChoices):
|
|
ACTIVE = 'ACTIVE', 'Active'
|
|
INACTIVE = 'INACTIVE', 'Inactive'
|
|
RESOLVED = 'RESOLVED', 'Resolved'
|
|
REMISSION = 'REMISSION', 'In Remission'
|
|
RECURRENCE = 'RECURRENCE', 'Recurrence'
|
|
RELAPSE = 'RELAPSE', 'Relapse'
|
|
UNKNOWN = 'UNKNOWN', 'Unknown'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class Laterality(models.TextChoices):
|
|
LEFT = 'LEFT', 'Left'
|
|
RIGHT = 'RIGHT', 'Right'
|
|
BILATERAL = 'BILATERAL', 'Bilateral'
|
|
UNILATERAL = 'UNILATERAL', 'Unilateral'
|
|
NOT_APPLICABLE = 'NOT_APPLICABLE', 'Not Applicable'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='problem_lists',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Patient relationship
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='problems',
|
|
help_text='Patient'
|
|
)
|
|
|
|
# Problem Information
|
|
problem_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique problem identifier'
|
|
)
|
|
|
|
# Problem Details
|
|
problem_name = models.CharField(
|
|
max_length=200,
|
|
help_text='Problem name or description'
|
|
)
|
|
problem_code = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='ICD-10 or SNOMED code'
|
|
)
|
|
coding_system = models.CharField(
|
|
max_length=20,
|
|
choices=CodingSystem.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Coding system used'
|
|
)
|
|
|
|
# Problem Classification
|
|
problem_type = models.CharField(
|
|
max_length=30,
|
|
choices=ProblemType.choices,
|
|
help_text='Type of problem'
|
|
)
|
|
|
|
# Clinical Information
|
|
onset_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date of onset'
|
|
)
|
|
onset_description = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Description of onset'
|
|
)
|
|
|
|
# Severity and Priority
|
|
severity = models.CharField(
|
|
max_length=20,
|
|
choices=Severity.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Problem severity'
|
|
)
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=Priority.choices,
|
|
default=Priority.MEDIUM,
|
|
help_text='Problem priority'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=ProblemStatus.choices,
|
|
default=ProblemStatus.ACTIVE,
|
|
help_text='Current problem status'
|
|
)
|
|
|
|
# Resolution Information
|
|
resolution_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date problem was resolved'
|
|
)
|
|
resolution_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Notes about problem resolution'
|
|
)
|
|
|
|
# Provider Information
|
|
diagnosing_provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='diagnosed_problems',
|
|
help_text='Provider who diagnosed the problem'
|
|
)
|
|
managing_provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='managed_problems',
|
|
help_text='Provider managing the problem'
|
|
)
|
|
|
|
# Related Information
|
|
related_encounter = models.ForeignKey(
|
|
Encounter,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='problems_identified',
|
|
help_text='Encounter where problem was identified'
|
|
)
|
|
|
|
# Clinical Context
|
|
body_site = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Body site affected'
|
|
)
|
|
laterality = models.CharField(
|
|
max_length=20,
|
|
choices=Laterality.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Laterality'
|
|
)
|
|
|
|
# Problem Notes
|
|
clinical_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical notes about the problem'
|
|
)
|
|
patient_concerns = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient concerns and comments'
|
|
)
|
|
|
|
# Goals and Outcomes
|
|
treatment_goals = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Treatment goals for this problem'
|
|
)
|
|
outcome_measures = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Outcome measures being tracked'
|
|
)
|
|
|
|
# Verification
|
|
verified = models.BooleanField(
|
|
default=False,
|
|
help_text='Problem has been verified'
|
|
)
|
|
verified_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='verified_problems',
|
|
help_text='Provider who verified the problem'
|
|
)
|
|
verified_date = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date problem was verified'
|
|
)
|
|
|
|
# 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_problems',
|
|
help_text='User who created the problem'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_problem_list'
|
|
verbose_name = 'Problem'
|
|
verbose_name_plural = 'Problem List'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'status']),
|
|
models.Index(fields=['problem_type']),
|
|
models.Index(fields=['priority']),
|
|
models.Index(fields=['onset_date']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.problem_name}"
|
|
|
|
@property
|
|
def is_active(self):
|
|
"""
|
|
Check if problem is currently active.
|
|
"""
|
|
return self.status == 'ACTIVE'
|
|
|
|
def clean(self):
|
|
"""
|
|
Custom validation for ProblemList model.
|
|
"""
|
|
# Validate resolution date is after onset date
|
|
if self.onset_date and self.resolution_date and self.resolution_date < self.onset_date:
|
|
raise ValidationError('Resolution date cannot be before onset date.')
|
|
|
|
# Validate resolution date is not in the future for resolved problems
|
|
if self.status in ['RESOLVED', 'REMISSION'] and self.resolution_date and self.resolution_date > timezone.now().date():
|
|
raise ValidationError('Resolution date cannot be in the future.')
|
|
|
|
# Validate onset date is not in the future
|
|
if self.onset_date and self.onset_date > timezone.now().date():
|
|
raise ValidationError('Onset date cannot be in the future.')
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Override save to perform custom validation.
|
|
"""
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def duration(self):
|
|
"""
|
|
Calculate problem duration.
|
|
"""
|
|
if self.onset_date:
|
|
end_date = self.resolution_date or timezone.now().date()
|
|
return end_date - self.onset_date
|
|
return None
|
|
|
|
@property
|
|
def duration_days(self):
|
|
"""
|
|
Get duration in days.
|
|
"""
|
|
duration = self.duration
|
|
return duration.days if duration else None
|
|
|
|
@property
|
|
def is_chronic(self):
|
|
"""
|
|
Check if problem is considered chronic (duration > 90 days).
|
|
"""
|
|
duration = self.duration
|
|
return duration and duration.days > 90
|
|
|
|
@property
|
|
def can_be_resolved(self):
|
|
"""
|
|
Check if problem can still be resolved.
|
|
"""
|
|
return self.status in ['ACTIVE', 'RECURRENCE', 'RELAPSE']
|
|
|
|
def get_status_color(self):
|
|
"""
|
|
Get Bootstrap color class for status display.
|
|
"""
|
|
status_colors = {
|
|
'ACTIVE': 'danger',
|
|
'INACTIVE': 'warning',
|
|
'RESOLVED': 'success',
|
|
'REMISSION': 'info',
|
|
'RECURRENCE': 'warning',
|
|
'RELAPSE': 'warning',
|
|
'UNKNOWN': 'secondary',
|
|
'OTHER': 'secondary',
|
|
}
|
|
return status_colors.get(self.status, 'secondary')
|
|
|
|
def get_priority_color(self):
|
|
"""
|
|
Get Bootstrap color class for priority display.
|
|
"""
|
|
|
|
priority_colors = {
|
|
'LOW': 'success',
|
|
'MEDIUM': 'info',
|
|
'HIGH': 'warning',
|
|
'URGENT': 'danger',
|
|
}
|
|
return priority_colors.get(self.priority, 'secondary')
|
|
|
|
def get_severity_color(self):
|
|
"""
|
|
Get Bootstrap color class for priority display.
|
|
"""
|
|
|
|
severity_colors = {
|
|
'MILD': 'success',
|
|
'MODERATE': 'info',
|
|
'SEVERE': 'warning',
|
|
'CRITICAL': 'danger',
|
|
}
|
|
return severity_colors.get(self.severity, 'secondary')
|
|
|
|
|
|
class CarePlan(models.Model):
|
|
"""
|
|
Care plan model for managing patient care coordination and goals.
|
|
"""
|
|
class PlanType(models.TextChoices):
|
|
COMPREHENSIVE = 'COMPREHENSIVE', 'Comprehensive Care Plan'
|
|
DISEASE_SPECIFIC = 'DISEASE_SPECIFIC', 'Disease-Specific Plan'
|
|
PREVENTIVE = 'PREVENTIVE', 'Preventive Care Plan'
|
|
CHRONIC_CARE = 'CHRONIC_CARE', 'Chronic Care Management'
|
|
ACUTE_CARE = 'ACUTE_CARE', 'Acute Care Plan'
|
|
DISCHARGE = 'DISCHARGE', 'Discharge Planning'
|
|
REHABILITATION = 'REHABILITATION', 'Rehabilitation Plan'
|
|
PALLIATIVE = 'PALLIATIVE', 'Palliative Care Plan'
|
|
MENTAL_HEALTH = 'MENTAL_HEALTH', 'Mental Health Plan'
|
|
MEDICATION = 'MEDICATION', 'Medication Management'
|
|
NUTRITION = 'NUTRITION', 'Nutrition Plan'
|
|
EXERCISE = 'EXERCISE', 'Exercise Plan'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class PlanCategory(models.TextChoices):
|
|
ASSESSMENT = 'ASSESSMENT', 'Assessment and Monitoring'
|
|
TREATMENT = 'TREATMENT', 'Treatment'
|
|
EDUCATION = 'EDUCATION', 'Patient Education'
|
|
COORDINATION = 'COORDINATION', 'Care Coordination'
|
|
PREVENTION = 'PREVENTION', 'Prevention'
|
|
LIFESTYLE = 'LIFESTYLE', 'Lifestyle Modification'
|
|
MEDICATION = 'MEDICATION', 'Medication Management'
|
|
FOLLOW_UP = 'FOLLOW_UP', 'Follow-up Care'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency Planning'
|
|
SUPPORT = 'SUPPORT', 'Support Services'
|
|
|
|
class PlanStatus(models.TextChoices):
|
|
DRAFT = 'DRAFT', 'Draft'
|
|
ACTIVE = 'ACTIVE', 'Active'
|
|
ON_HOLD = 'ON_HOLD', 'On Hold'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
ERROR = 'ERROR', 'Entered in Error'
|
|
UNKNOWN = 'UNKNOWN', 'Unknown'
|
|
|
|
class PlanPriority(models.TextChoices):
|
|
LOW = 'LOW', 'Low'
|
|
ROUTINE = 'ROUTINE', 'Routine'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
STAT = 'STAT', 'STAT'
|
|
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='care_plans',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Patient relationship
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='care_plans',
|
|
help_text='Patient'
|
|
)
|
|
|
|
# Care Plan Information
|
|
care_plan_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique care plan identifier'
|
|
)
|
|
|
|
# Plan Details
|
|
title = models.CharField(
|
|
max_length=200,
|
|
help_text='Care plan title'
|
|
)
|
|
description = models.TextField(
|
|
help_text='Care plan description'
|
|
)
|
|
|
|
# Plan Type and Category
|
|
plan_type = models.CharField(
|
|
max_length=30,
|
|
choices=PlanType.choices,
|
|
help_text='Type of care plan'
|
|
)
|
|
|
|
|
|
|
|
category = models.CharField(
|
|
max_length=50,
|
|
choices=PlanCategory.choices,
|
|
help_text='Care plan category'
|
|
)
|
|
|
|
# Timing
|
|
start_date = models.DateField(
|
|
help_text='Care plan start date'
|
|
)
|
|
end_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Care plan end date'
|
|
)
|
|
target_completion_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Target completion date'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=PlanStatus.choices,
|
|
default=PlanStatus.DRAFT,
|
|
help_text='Care plan status'
|
|
)
|
|
|
|
# Priority
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=PlanPriority.choices,
|
|
default=PlanPriority.ROUTINE,
|
|
help_text='Care plan priority'
|
|
)
|
|
|
|
# Provider Information
|
|
primary_provider = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='primary_care_plans',
|
|
help_text='Primary provider responsible for care plan'
|
|
)
|
|
care_team = models.ManyToManyField(
|
|
settings.AUTH_USER_MODEL,
|
|
related_name='care_team_plans',
|
|
blank=True,
|
|
help_text='Care team members'
|
|
)
|
|
|
|
# Related Problems
|
|
related_problems = models.ManyToManyField(
|
|
ProblemList,
|
|
related_name='care_plans',
|
|
blank=True,
|
|
help_text='Related problems addressed by this plan'
|
|
)
|
|
|
|
# Goals and Objectives
|
|
goals = models.JSONField(
|
|
default=list,
|
|
help_text='Care plan goals'
|
|
)
|
|
objectives = models.JSONField(
|
|
default=list,
|
|
help_text='Specific objectives and targets'
|
|
)
|
|
|
|
# Interventions and Activities
|
|
interventions = models.JSONField(
|
|
default=list,
|
|
help_text='Planned interventions'
|
|
)
|
|
activities = models.JSONField(
|
|
default=list,
|
|
help_text='Specific activities and tasks'
|
|
)
|
|
|
|
# Monitoring and Evaluation
|
|
monitoring_parameters = models.JSONField(
|
|
default=list,
|
|
help_text='Parameters to monitor'
|
|
)
|
|
evaluation_criteria = models.JSONField(
|
|
default=list,
|
|
help_text='Criteria for evaluating progress'
|
|
)
|
|
|
|
# Patient Involvement
|
|
patient_goals = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient-identified goals'
|
|
)
|
|
patient_preferences = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient preferences and concerns'
|
|
)
|
|
patient_barriers = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Identified barriers to care'
|
|
)
|
|
|
|
# Resources and Support
|
|
resources_needed = models.JSONField(
|
|
default=list,
|
|
help_text='Resources needed for plan implementation'
|
|
)
|
|
support_systems = models.JSONField(
|
|
default=list,
|
|
help_text='Available support systems'
|
|
)
|
|
|
|
# Progress Tracking
|
|
progress_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Progress notes'
|
|
)
|
|
last_reviewed = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date of last review'
|
|
)
|
|
next_review_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Next scheduled review date'
|
|
)
|
|
|
|
# Outcomes
|
|
outcomes_achieved = models.JSONField(
|
|
default=list,
|
|
help_text='Outcomes achieved'
|
|
)
|
|
completion_percentage = models.PositiveIntegerField(
|
|
default=0,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='Completion percentage'
|
|
)
|
|
|
|
# Approval and Authorization
|
|
approved = models.BooleanField(
|
|
default=False,
|
|
help_text='Care plan has been approved'
|
|
)
|
|
approved_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='approved_care_plans',
|
|
help_text='Provider who approved the plan'
|
|
)
|
|
approved_date = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date of approval'
|
|
)
|
|
|
|
# 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_care_plans',
|
|
help_text='User who created the care plan'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_care_plan'
|
|
verbose_name = 'Care Plan'
|
|
verbose_name_plural = 'Care Plans'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'status']),
|
|
models.Index(fields=['primary_provider']),
|
|
models.Index(fields=['start_date', 'end_date']),
|
|
models.Index(fields=['priority']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.title}"
|
|
|
|
@property
|
|
def is_active(self):
|
|
"""
|
|
Check if care plan is currently active.
|
|
"""
|
|
return self.status == 'ACTIVE'
|
|
|
|
def clean(self):
|
|
"""
|
|
Custom validation for CarePlan model.
|
|
"""
|
|
# Validate date ranges
|
|
if self.end_date and self.start_date and self.end_date < self.start_date:
|
|
raise ValidationError('End date cannot be before start date.')
|
|
|
|
if self.target_completion_date and self.start_date and self.target_completion_date < self.start_date:
|
|
raise ValidationError('Target completion date cannot be before start date.')
|
|
|
|
if self.next_review_date and self.start_date and self.next_review_date < self.start_date:
|
|
raise ValidationError('Next review date cannot be before start date.')
|
|
|
|
# Validate completion percentage
|
|
if self.completion_percentage < 0 or self.completion_percentage > 100:
|
|
raise ValidationError('Completion percentage must be between 0 and 100.')
|
|
|
|
# Validate status-specific logic
|
|
if self.status == 'COMPLETED' and self.completion_percentage != 100:
|
|
raise ValidationError('Completed care plans must have 100% completion.')
|
|
|
|
if self.status == 'COMPLETED' and not self.end_date:
|
|
raise ValidationError('Completed care plans must have an end date.')
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Override save to perform custom validation and business logic.
|
|
"""
|
|
self.full_clean()
|
|
|
|
# Auto-set end_date for completed plans
|
|
if self.status == 'COMPLETED' and not self.end_date:
|
|
self.end_date = timezone.now().date()
|
|
|
|
# Auto-set completion percentage for completed plans
|
|
if self.status == 'COMPLETED' and self.completion_percentage != 100:
|
|
self.completion_percentage = 100
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_overdue(self):
|
|
"""
|
|
Check if care plan is overdue for review.
|
|
"""
|
|
if self.next_review_date:
|
|
return timezone.now().date() > self.next_review_date
|
|
return False
|
|
|
|
@property
|
|
def days_remaining(self):
|
|
"""
|
|
Calculate days remaining until target completion.
|
|
"""
|
|
if self.target_completion_date:
|
|
return (self.target_completion_date - timezone.now().date()).days
|
|
return None
|
|
|
|
@property
|
|
def duration_days(self):
|
|
"""
|
|
Calculate total planned duration in days.
|
|
"""
|
|
if self.start_date and self.end_date:
|
|
return (self.end_date - self.start_date).days
|
|
return None
|
|
|
|
@property
|
|
def progress_percentage(self):
|
|
"""
|
|
Calculate progress percentage based on time elapsed.
|
|
"""
|
|
if not self.start_date or not self.end_date:
|
|
return None
|
|
|
|
total_days = (self.end_date - self.start_date).days
|
|
if total_days <= 0:
|
|
return 100
|
|
|
|
elapsed_days = (timezone.now().date() - self.start_date).days
|
|
if elapsed_days <= 0:
|
|
return 0
|
|
|
|
return min(100, int((elapsed_days / total_days) * 100))
|
|
|
|
@property
|
|
def can_be_completed(self):
|
|
"""
|
|
Check if care plan can be marked as completed.
|
|
"""
|
|
return self.status in ['ACTIVE', 'ON_HOLD']
|
|
|
|
def get_status_color(self):
|
|
"""
|
|
Get Bootstrap color class for status display.
|
|
"""
|
|
status_colors = {
|
|
'DRAFT': 'secondary',
|
|
'ACTIVE': 'success',
|
|
'ON_HOLD': 'warning',
|
|
'COMPLETED': 'primary',
|
|
'CANCELLED': 'danger',
|
|
'ERROR': 'danger',
|
|
'UNKNOWN': 'secondary',
|
|
}
|
|
return status_colors.get(self.status, 'secondary')
|
|
|
|
def update_progress(self, new_percentage, notes=None):
|
|
"""
|
|
Update care plan progress.
|
|
"""
|
|
if 0 <= new_percentage <= 100:
|
|
self.completion_percentage = new_percentage
|
|
if notes:
|
|
self.progress_notes = notes
|
|
self.last_reviewed = timezone.now().date()
|
|
self.save()
|
|
return True
|
|
return False
|
|
|
|
|
|
class ClinicalNote(models.Model):
|
|
"""
|
|
Clinical note model for documenting patient care and observations.
|
|
"""
|
|
|
|
class NoteType(models.TextChoices):
|
|
PROGRESS = 'PROGRESS', 'Progress'
|
|
ADMISSION = 'ADMISSION', 'Admission Note'
|
|
DISCHARGE = 'DISCHARGE', 'Discharge Note'
|
|
CONSULTATION = 'CONSULTATION', 'Consultation Note'
|
|
PROCEDURE = 'PROCEDURE', 'Procedure Note'
|
|
OPERATIVE = 'OPERATIVE', 'Operative Note'
|
|
NURSING = 'NURSING', 'Nursing Note'
|
|
THERAPY = 'THERAPY', 'Therapy Note'
|
|
SOCIAL_WORK = 'SOCIAL_WORK', 'Social Work Note'
|
|
PSYCHOLOGY = 'PSYCHOLOGY', 'Psychology Note'
|
|
NUTRITION = 'NUTRITION', 'Nutrition Note'
|
|
PHARMACY = 'PHARMACY', 'Pharmacy Note'
|
|
CASE_MANAGEMENT = 'CASE_MANAGEMENT', 'Case Management Note'
|
|
EDUCATION = 'EDUCATION', 'Patient Education Note'
|
|
TELEPHONE = 'TELEPHONE', 'Telephone Note'
|
|
ADDENDUM = 'ADDENDUM', 'Addendum'
|
|
CORRECTION = 'CORRECTION', 'Correction'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class NoteStatus(models.TextChoices):
|
|
DRAFT = 'DRAFT', 'Draft'
|
|
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
SIGNED = 'SIGNED', 'Signed'
|
|
AMENDED = 'AMENDED', 'Amended'
|
|
CORRECTED = 'CORRECTED', 'Corrected'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
ERROR = 'ERROR', 'Entered in Error'
|
|
UNKNOWN = 'UNKNOWN', 'Unknown'
|
|
|
|
class NoteSignatureMethod(models.TextChoices):
|
|
ELECTRONIC = 'ELECTRONIC', 'Electronic'
|
|
DIGITAL = 'DIGITAL', 'Digital Signature'
|
|
BIOMETRIC = 'BIOMETRIC', 'Biometric Signature'
|
|
PASSWORD = 'PASSWORD', 'Password'
|
|
TOKEN = 'TOKEN', 'Token Authentication'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
|
|
# Encounter relationship
|
|
encounter = models.ForeignKey(
|
|
Encounter,
|
|
on_delete=models.CASCADE,
|
|
related_name='clinical_notes',
|
|
help_text='Associated encounter'
|
|
)
|
|
|
|
# Patient relationship
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='clinical_notes',
|
|
help_text='Patient'
|
|
)
|
|
|
|
# Note Information
|
|
note_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique note identifier'
|
|
)
|
|
|
|
# Note Details
|
|
note_type = models.CharField(
|
|
max_length=30,
|
|
choices=NoteType.choices,
|
|
help_text='Type of clinical note'
|
|
)
|
|
|
|
title = models.CharField(
|
|
max_length=200,
|
|
help_text='Note title'
|
|
)
|
|
|
|
# Content
|
|
content = models.TextField(
|
|
help_text='Note content'
|
|
)
|
|
|
|
# Template Information
|
|
template = models.ForeignKey(
|
|
'NoteTemplate',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='notes',
|
|
help_text='Template used for this note'
|
|
)
|
|
structured_data = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text='Structured data from template'
|
|
)
|
|
|
|
# Provider Information
|
|
author = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='authored_notes',
|
|
help_text='Note author'
|
|
)
|
|
co_signers = models.ManyToManyField(
|
|
settings.AUTH_USER_MODEL,
|
|
related_name='co_signed_notes',
|
|
blank=True,
|
|
help_text='Co-signers for this note'
|
|
)
|
|
|
|
# Status and Workflow
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=NoteStatus.choices,
|
|
default=NoteStatus.DRAFT,
|
|
help_text='Note status'
|
|
)
|
|
|
|
# Signatures
|
|
electronically_signed = models.BooleanField(
|
|
default=False,
|
|
help_text='Note has been electronically signed'
|
|
)
|
|
signed_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time of signature'
|
|
)
|
|
signature_method = models.CharField(
|
|
max_length=20,
|
|
choices=NoteSignatureMethod.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Method of signature'
|
|
)
|
|
|
|
# Amendment Information
|
|
amended_note = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='amendments',
|
|
help_text='Original note if this is an amendment'
|
|
)
|
|
amendment_reason = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Reason for amendment'
|
|
)
|
|
|
|
# Quality and Compliance
|
|
quality_score = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='Documentation quality score'
|
|
)
|
|
compliance_flags = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Compliance flags and issues'
|
|
)
|
|
|
|
# Timing
|
|
note_datetime = models.DateTimeField(
|
|
default=timezone.now,
|
|
help_text='Date and time note was written'
|
|
)
|
|
|
|
# Privacy and Access
|
|
confidential = models.BooleanField(
|
|
default=False,
|
|
help_text='Note contains confidential information'
|
|
)
|
|
restricted_access = models.BooleanField(
|
|
default=False,
|
|
help_text='Access to note is restricted'
|
|
)
|
|
access_restrictions = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text='Specific access restrictions'
|
|
)
|
|
|
|
# Related Information
|
|
related_problems = models.ManyToManyField(
|
|
ProblemList,
|
|
related_name='related_clinical_notes',
|
|
blank=True,
|
|
help_text='Related problems'
|
|
)
|
|
related_care_plans = models.ManyToManyField(
|
|
CarePlan,
|
|
related_name='clinical_notes',
|
|
blank=True,
|
|
help_text='Related care plans'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'emr_clinical_note'
|
|
verbose_name = 'Clinical Note'
|
|
verbose_name_plural = 'Clinical Notes'
|
|
ordering = ['-note_datetime']
|
|
indexes = [
|
|
models.Index(fields=['patient', 'note_datetime']),
|
|
models.Index(fields=['encounter']),
|
|
models.Index(fields=['author']),
|
|
models.Index(fields=['note_type']),
|
|
models.Index(fields=['status']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.title} ({self.note_datetime.strftime('%Y-%m-%d')})"
|
|
|
|
@property
|
|
def is_signed(self):
|
|
"""
|
|
Check if note has been signed.
|
|
"""
|
|
return self.status == 'SIGNED' and self.electronically_signed
|
|
|
|
@property
|
|
def word_count(self):
|
|
"""
|
|
Calculate word count of note content.
|
|
"""
|
|
return len(self.content.split())
|
|
|
|
|
|
class NoteTemplate(models.Model):
|
|
"""
|
|
Note template model for standardizing clinical documentation.
|
|
"""
|
|
|
|
class NoteType(models.TextChoices):
|
|
PROGRESS = 'PROGRESS', 'Progress Note'
|
|
ADMISSION = 'ADMISSION', 'Admission Note'
|
|
DISCHARGE = 'DISCHARGE', 'Discharge Note'
|
|
CONSULTATION = 'CONSULTATION', 'Consultation Note'
|
|
PROCEDURE = 'PROCEDURE', 'Procedure Note'
|
|
OPERATIVE = 'OPERATIVE', 'Operative Note'
|
|
NURSING = 'NURSING', 'Nursing Note'
|
|
THERAPY = 'THERAPY', 'Therapy Note'
|
|
SOCIAL_WORK = 'SOCIAL_WORK', 'Social Work Note'
|
|
PSYCHOLOGY = 'PSYCHOLOGY', 'Psychology Note'
|
|
NUTRITION = 'NUTRITION', 'Nutrition Note'
|
|
PHARMACY = 'PHARMACY', 'Pharmacy Note'
|
|
CASE_MANAGEMENT = 'CASE_MANAGEMENT', 'Case Management Note'
|
|
EDUCATION = 'EDUCATION', 'Patient Education Note'
|
|
TELEPHONE = 'TELEPHONE', 'Telephone Note'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class NoteSpecialty(models.TextChoices):
|
|
GENERAL_MEDICINE = 'GENERAL_MEDICINE', 'General Medicine'
|
|
SURGERY = 'SURGERY', 'Surgery'
|
|
CARDIOLOGY = 'CARDIOLOGY', 'Cardiology'
|
|
NEUROLOGY = 'NEUROLOGY', 'Neurology'
|
|
ONCOLOGY = 'ONCOLOGY', 'Oncology'
|
|
PEDIATRICS = 'PEDIATRICS', 'Pediatrics'
|
|
OBSTETRICS = 'OBSTETRICS', 'Obstetrics'
|
|
GYNECOLOGY = 'GYNECOLOGY', 'Gynecology'
|
|
ORTHOPEDICS = 'ORTHOPEDICS', 'Orthopedics'
|
|
PSYCHIATRY = 'PSYCHIATRY', 'Psychiatry'
|
|
EMERGENCY = 'EMERGENCY', 'Emergency Medicine'
|
|
CRITICAL_CARE = 'CRITICAL_CARE', 'Critical Care'
|
|
REHABILITATION = 'REHABILITATION', 'Rehabilitation'
|
|
NURSING = 'NURSING', 'Nursing'
|
|
THERAPY = 'THERAPY', 'Therapy'
|
|
SOCIAL_WORK = 'SOCIAL_WORK', 'Social Work'
|
|
NUTRITION = 'NUTRITION', 'Nutrition'
|
|
PHARMACY = 'PHARMACY', 'Pharmacy'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='note_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=200,
|
|
help_text='Template name'
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Template description'
|
|
)
|
|
|
|
# Template Type
|
|
note_type = models.CharField(
|
|
max_length=30,
|
|
choices=NoteType.choices,
|
|
help_text='Type of note this template is for'
|
|
)
|
|
|
|
specialty = models.CharField(
|
|
max_length=100,
|
|
choices=NoteSpecialty.choices,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Medical specialty'
|
|
)
|
|
|
|
# Template Content
|
|
template_content = models.TextField(
|
|
help_text='Template content with placeholders'
|
|
)
|
|
structured_fields = models.JSONField(
|
|
default=list,
|
|
help_text='Structured fields definition'
|
|
)
|
|
|
|
# Usage Information
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Template is active and available for use'
|
|
)
|
|
is_default = models.BooleanField(
|
|
default=False,
|
|
help_text='Default template for this note type'
|
|
)
|
|
usage_count = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of times template has been used'
|
|
)
|
|
|
|
# Version Control
|
|
version = models.CharField(
|
|
max_length=20,
|
|
default='1.0',
|
|
help_text='Template version'
|
|
)
|
|
previous_version = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='newer_versions',
|
|
help_text='Previous version of this template'
|
|
)
|
|
|
|
# Quality and Compliance
|
|
quality_indicators = models.JSONField(
|
|
default=list,
|
|
help_text='Quality indicators to track'
|
|
)
|
|
compliance_requirements = models.JSONField(
|
|
default=list,
|
|
help_text='Compliance requirements'
|
|
)
|
|
|
|
# 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_note_templates',
|
|
help_text='User who created the template'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_note_template'
|
|
verbose_name = 'Note Template'
|
|
verbose_name_plural = 'Note Templates'
|
|
ordering = ['note_type', 'name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
models.Index(fields=['note_type', 'specialty']),
|
|
models.Index(fields=['is_default']),
|
|
]
|
|
unique_together = ['tenant', 'note_type', 'specialty', 'is_default']
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.get_note_type_display()})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Ensure only one default template per note type and specialty.
|
|
"""
|
|
if self.is_default:
|
|
# Remove default flag from other templates
|
|
NoteTemplate.objects.filter(
|
|
tenant=self.tenant,
|
|
note_type=self.note_type,
|
|
specialty=self.specialty,
|
|
is_default=True
|
|
).exclude(id=self.id).update(is_default=False)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class Icd10(models.Model):
|
|
"""
|
|
ICD-10-CM tabular code entry.
|
|
Handles chapters/sections/diagnoses (with parent-child hierarchy).
|
|
"""
|
|
# Tenant relationship
|
|
# tenant = models.ForeignKey(
|
|
# 'core.Tenant',
|
|
# on_delete=models.CASCADE,
|
|
# related_name='icd10_codes',
|
|
# help_text='Organization tenant'
|
|
# )
|
|
|
|
code = models.CharField(max_length=10, unique=True, db_index=True)
|
|
description = models.TextField(blank=True, null=True)
|
|
chapter_name = models.CharField(max_length=255, blank=True, null=True)
|
|
section_name = models.CharField(max_length=255, blank=True, null=True)
|
|
parent = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.CASCADE,
|
|
blank=True,
|
|
null=True,
|
|
related_name='children'
|
|
)
|
|
is_header = models.BooleanField(default=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'emr_icd10'
|
|
verbose_name = 'ICD-10 Code'
|
|
verbose_name_plural = 'ICD-10 Codes'
|
|
ordering = ['code']
|
|
|
|
|
|
def __str__(self):
|
|
return f"{self.code} — {self.description[:80] if self.description else ''}"
|
|
|
|
|
|
class ClinicalRecommendation(models.Model):
|
|
"""
|
|
Clinical recommendation model for AI-powered clinical decision support.
|
|
"""
|
|
|
|
class RecommendationCategory(models.TextChoices):
|
|
PREVENTIVE = 'PREVENTIVE', 'Preventive Care'
|
|
DIAGNOSTIC = 'DIAGNOSTIC', 'Diagnostic'
|
|
TREATMENT = 'TREATMENT', 'Treatment'
|
|
MONITORING = 'MONITORING', 'Monitoring'
|
|
LIFESTYLE = 'LIFESTYLE', 'Lifestyle'
|
|
MEDICATION = 'MEDICATION', 'Medication'
|
|
FOLLOW_UP = 'FOLLOW_UP', 'Follow-up'
|
|
REFERRAL = 'REFERRAL', 'Referral'
|
|
EDUCATION = 'EDUCATION', 'Patient Education'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class RecommendationPriority(models.TextChoices):
|
|
LOW = 'LOW', 'Low'
|
|
MEDIUM = 'MEDIUM', 'Medium'
|
|
HIGH = 'HIGH', 'High'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
CRITICAL = 'CRITICAL', 'Critical'
|
|
|
|
class RecommendationStatus(models.TextChoices):
|
|
PENDING = 'PENDING', 'Pending'
|
|
ACTIVE = 'ACTIVE', 'Active'
|
|
ACCEPTED = 'ACCEPTED', 'Accepted'
|
|
DEFERRED = 'DEFERRED', 'Deferred'
|
|
DISMISSED = 'DISMISSED', 'Dismissed'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
EXPIRED = 'EXPIRED', 'Expired'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='clinical_recommendations',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Patient relationship
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='clinical_recommendations',
|
|
help_text='Patient'
|
|
)
|
|
|
|
# Recommendation details
|
|
recommendation_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique recommendation identifier'
|
|
)
|
|
|
|
title = models.CharField(
|
|
max_length=200,
|
|
help_text='Recommendation title'
|
|
)
|
|
description = models.TextField(
|
|
help_text='Detailed recommendation description'
|
|
)
|
|
|
|
# Classification
|
|
category = models.CharField(
|
|
max_length=20,
|
|
choices=RecommendationCategory.choices,
|
|
help_text='Recommendation category'
|
|
)
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=RecommendationPriority.choices,
|
|
default=RecommendationPriority.MEDIUM,
|
|
help_text='Recommendation priority'
|
|
)
|
|
|
|
# Clinical details
|
|
evidence_level = models.CharField(
|
|
max_length=10,
|
|
help_text='Level of evidence (1A, 1B, 2A, etc.)'
|
|
)
|
|
source = models.CharField(
|
|
max_length=100,
|
|
help_text='Source of recommendation (guideline, study, etc.)'
|
|
)
|
|
rationale = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Clinical rationale for recommendation'
|
|
)
|
|
|
|
# Status and actions
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=RecommendationStatus.choices,
|
|
default=RecommendationStatus.PENDING,
|
|
help_text='Current recommendation status'
|
|
)
|
|
|
|
# Action tracking
|
|
accepted_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='accepted_recommendations',
|
|
help_text='Provider who accepted the recommendation'
|
|
)
|
|
accepted_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time recommendation was accepted'
|
|
)
|
|
|
|
deferred_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='deferred_recommendations',
|
|
help_text='Provider who deferred the recommendation'
|
|
)
|
|
deferred_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time recommendation was deferred'
|
|
)
|
|
|
|
dismissed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='dismissed_recommendations',
|
|
help_text='Provider who dismissed the recommendation'
|
|
)
|
|
dismissed_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time recommendation was dismissed'
|
|
)
|
|
|
|
# Timing
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
expires_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Recommendation expiration date'
|
|
)
|
|
|
|
# Related data
|
|
related_problems = models.ManyToManyField(
|
|
ProblemList,
|
|
related_name='recommendations',
|
|
blank=True,
|
|
help_text='Related problems'
|
|
)
|
|
related_encounter = models.ForeignKey(
|
|
Encounter,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='recommendations',
|
|
help_text='Related encounter'
|
|
)
|
|
|
|
# Metadata
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_recommendations',
|
|
help_text='User who created the recommendation'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_clinical_recommendation'
|
|
verbose_name = 'Clinical Recommendation'
|
|
verbose_name_plural = 'Clinical Recommendations'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'status']),
|
|
models.Index(fields=['category']),
|
|
models.Index(fields=['priority']),
|
|
models.Index(fields=['created_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.title}"
|
|
|
|
@property
|
|
def is_expired(self):
|
|
"""
|
|
Check if recommendation has expired.
|
|
"""
|
|
if self.expires_at:
|
|
return timezone.now() > self.expires_at
|
|
return False
|
|
|
|
@property
|
|
def is_active(self):
|
|
"""
|
|
Check if recommendation is currently active.
|
|
"""
|
|
return self.status in ['PENDING', 'ACTIVE'] and not self.is_expired
|
|
|
|
|
|
class AllergyAlert(models.Model):
|
|
"""
|
|
Allergy alert model for tracking patient allergies and reactions.
|
|
"""
|
|
|
|
class AlertSeverity(models.TextChoices):
|
|
MILD = 'MILD', 'Mild'
|
|
MODERATE = 'MODERATE', 'Moderate'
|
|
SEVERE = 'SEVERE', 'Severe'
|
|
LIFE_THREATENING = 'LIFE_THREATENING', 'Life-threatening'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='allergy_alerts',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Patient relationship
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='allergy_alerts',
|
|
help_text='Patient'
|
|
)
|
|
|
|
# Alert details
|
|
alert_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique alert identifier'
|
|
)
|
|
|
|
allergen = models.CharField(
|
|
max_length=100,
|
|
help_text='Allergen name'
|
|
)
|
|
|
|
reaction_type = models.CharField(
|
|
max_length=100,
|
|
help_text='Type of allergic reaction'
|
|
)
|
|
|
|
severity = models.CharField(
|
|
max_length=20,
|
|
choices=AlertSeverity.choices,
|
|
help_text='Alert severity'
|
|
)
|
|
|
|
# Clinical details
|
|
symptoms = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Allergic reaction symptoms'
|
|
)
|
|
onset = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Reaction onset timing'
|
|
)
|
|
|
|
# Status
|
|
resolved = models.BooleanField(
|
|
default=False,
|
|
help_text='Alert has been resolved'
|
|
)
|
|
resolved_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date alert was resolved'
|
|
)
|
|
resolved_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='resolved_allergy_alerts',
|
|
help_text='Provider who resolved the alert'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
detected_at = models.DateTimeField(
|
|
default=timezone.now,
|
|
help_text='When alert was detected'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_allergy_alert'
|
|
verbose_name = 'Allergy Alert'
|
|
verbose_name_plural = 'Allergy Alerts'
|
|
ordering = ['-detected_at']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'resolved']),
|
|
models.Index(fields=['patient', 'resolved']),
|
|
models.Index(fields=['severity']),
|
|
models.Index(fields=['detected_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.allergen}"
|
|
|
|
|
|
class TreatmentProtocol(models.Model):
|
|
"""
|
|
Treatment protocol model for standardized treatment approaches.
|
|
"""
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='treatment_protocols',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Protocol details
|
|
protocol_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique protocol identifier'
|
|
)
|
|
|
|
name = models.CharField(
|
|
max_length=200,
|
|
help_text='Protocol name'
|
|
)
|
|
|
|
description = models.TextField(
|
|
help_text='Protocol description'
|
|
)
|
|
|
|
# Clinical details
|
|
indication = models.TextField(
|
|
help_text='Clinical indications for use'
|
|
)
|
|
|
|
goals = models.JSONField(
|
|
default=list,
|
|
help_text='Treatment goals'
|
|
)
|
|
|
|
interventions = models.JSONField(
|
|
default=list,
|
|
help_text='Required interventions'
|
|
)
|
|
|
|
monitoring_parameters = models.JSONField(
|
|
default=list,
|
|
help_text='Parameters to monitor'
|
|
)
|
|
|
|
# Effectiveness
|
|
success_rate = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
help_text='Protocol success rate (%)'
|
|
)
|
|
|
|
average_duration = models.PositiveIntegerField(
|
|
help_text='Average treatment duration in days'
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Protocol is active and available'
|
|
)
|
|
|
|
# Usage tracking
|
|
usage_count = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of times protocol 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_protocols',
|
|
help_text='User who created the protocol'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_treatment_protocol'
|
|
verbose_name = 'Treatment Protocol'
|
|
verbose_name_plural = 'Treatment Protocols'
|
|
ordering = ['-success_rate', 'name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
models.Index(fields=['success_rate']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.success_rate}% success)"
|
|
|
|
|
|
class ClinicalGuideline(models.Model):
|
|
"""
|
|
Clinical guideline model for referencing medical guidelines.
|
|
"""
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='clinical_guidelines',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Guideline details
|
|
guideline_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique guideline identifier'
|
|
)
|
|
|
|
title = models.CharField(
|
|
max_length=300,
|
|
help_text='Guideline title'
|
|
)
|
|
|
|
organization = models.CharField(
|
|
max_length=100,
|
|
help_text='Publishing organization'
|
|
)
|
|
|
|
summary = models.TextField(
|
|
help_text='Guideline summary'
|
|
)
|
|
|
|
url = models.URLField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Link to full guideline'
|
|
)
|
|
|
|
# Publication details
|
|
publication_date = models.DateField(
|
|
help_text='Guideline publication date'
|
|
)
|
|
|
|
last_updated = models.DateField(
|
|
auto_now=True,
|
|
help_text='Last updated date'
|
|
)
|
|
|
|
version = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Guideline version'
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Guideline is current and active'
|
|
)
|
|
|
|
# Relevance
|
|
keywords = models.JSONField(
|
|
default=list,
|
|
help_text='Keywords for searching'
|
|
)
|
|
|
|
specialties = models.JSONField(
|
|
default=list,
|
|
help_text='Relevant medical specialties'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'emr_clinical_guideline'
|
|
verbose_name = 'Clinical Guideline'
|
|
verbose_name_plural = 'Clinical Guidelines'
|
|
ordering = ['-last_updated', 'title']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
models.Index(fields=['organization']),
|
|
models.Index(fields=['publication_date']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.title} - {self.organization}"
|
|
|
|
|
|
class CriticalAlert(models.Model):
|
|
"""
|
|
Critical alert model for high-priority clinical alerts.
|
|
"""
|
|
|
|
class AlertPriority(models.TextChoices):
|
|
HIGH = 'HIGH', 'High'
|
|
URGENT = 'URGENT', 'Urgent'
|
|
CRITICAL = 'CRITICAL', 'Critical'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='critical_alerts',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Patient relationship
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='critical_alerts',
|
|
help_text='Patient'
|
|
)
|
|
|
|
# Alert details
|
|
alert_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique alert identifier'
|
|
)
|
|
|
|
title = models.CharField(
|
|
max_length=200,
|
|
help_text='Alert title'
|
|
)
|
|
|
|
description = models.TextField(
|
|
help_text='Alert description'
|
|
)
|
|
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=AlertPriority.choices,
|
|
default=AlertPriority.HIGH,
|
|
help_text='Alert priority level'
|
|
)
|
|
|
|
# Clinical details
|
|
recommendation = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Recommended actions'
|
|
)
|
|
|
|
# Status
|
|
acknowledged = models.BooleanField(
|
|
default=False,
|
|
help_text='Alert has been acknowledged'
|
|
)
|
|
|
|
acknowledged_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='emr_acknowledged_alerts',
|
|
help_text='Provider who acknowledged the alert'
|
|
)
|
|
|
|
acknowledged_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time alert was acknowledged'
|
|
)
|
|
|
|
# Timing
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
expires_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Alert expiration date'
|
|
)
|
|
|
|
# Related data
|
|
related_encounter = models.ForeignKey(
|
|
Encounter,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='critical_alerts',
|
|
help_text='Related encounter'
|
|
)
|
|
|
|
# Metadata
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_alerts',
|
|
help_text='User who created the alert'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_critical_alert'
|
|
verbose_name = 'Critical Alert'
|
|
verbose_name_plural = 'Critical Alerts'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'acknowledged']),
|
|
models.Index(fields=['patient', 'acknowledged']),
|
|
models.Index(fields=['priority']),
|
|
models.Index(fields=['created_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.title}"
|
|
|
|
@property
|
|
def is_expired(self):
|
|
"""
|
|
Check if alert has expired.
|
|
"""
|
|
if self.expires_at:
|
|
return timezone.now() > self.expires_at
|
|
return False
|
|
|
|
@property
|
|
def is_active(self):
|
|
"""
|
|
Check if alert is currently active.
|
|
"""
|
|
return not self.acknowledged and not self.is_expired
|
|
|
|
|
|
class DiagnosticSuggestion(models.Model):
|
|
"""
|
|
Diagnostic suggestion model for AI-suggested diagnostic tests.
|
|
"""
|
|
|
|
class Status(models.TextChoices):
|
|
PENDING = 'PENDING', 'Pending'
|
|
ORDERED = 'ORDERED', 'Ordered'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='diagnostic_suggestions',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Patient relationship
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='diagnostic_suggestions',
|
|
help_text='Patient'
|
|
)
|
|
|
|
# Suggestion details
|
|
suggestion_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique suggestion identifier'
|
|
)
|
|
|
|
test_name = models.CharField(
|
|
max_length=200,
|
|
help_text='Suggested test name'
|
|
)
|
|
|
|
test_code = models.CharField(
|
|
max_length=20,
|
|
help_text='Test code or identifier'
|
|
)
|
|
|
|
indication = models.TextField(
|
|
help_text='Clinical indication for the test'
|
|
)
|
|
|
|
confidence = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text='AI confidence score (%)'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=Status.choices,
|
|
default=Status.ORDERED,
|
|
help_text='Suggestion status'
|
|
)
|
|
|
|
# Ordering information
|
|
ordered_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='ordered_suggestions',
|
|
help_text='Provider who ordered the test'
|
|
)
|
|
|
|
ordered_at = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time test was ordered'
|
|
)
|
|
|
|
# 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_suggestions',
|
|
help_text='User who created the suggestion'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'emr_diagnostic_suggestion'
|
|
verbose_name = 'Diagnostic Suggestion'
|
|
verbose_name_plural = 'Diagnostic Suggestions'
|
|
ordering = ['-confidence', '-created_at']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'status']),
|
|
models.Index(fields=['confidence']),
|
|
models.Index(fields=['created_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.test_name} ({self.confidence}%)"
|