agdar/nursing/models.py
2025-11-02 14:35:35 +03:00

420 lines
12 KiB
Python

"""
Nursing models for the Tenhal Multidisciplinary Healthcare Platform.
This module handles nursing encounters, vital signs, anthropometric measurements,
and growth chart tracking based on MD-N-F-1 form.
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from simple_history.models import HistoricalRecords
from core.models import (
UUIDPrimaryKeyMixin,
TimeStampedMixin,
TenantOwnedMixin,
ClinicallySignableMixin,
)
class NursingEncounter(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin, ClinicallySignableMixin):
"""
Nursing encounter with vital signs and anthropometric measurements.
Based on MD-N-F-1 form.
"""
class CRT(models.TextChoices):
LESS_THAN_2S = 'LESS_THAN_2S', _('< 2 seconds')
MORE_THAN_2S = 'MORE_THAN_2S', _('> 2 seconds')
# Core Relationships
patient = models.ForeignKey(
'core.Patient',
on_delete=models.CASCADE,
related_name='nursing_encounters',
verbose_name=_("Patient")
)
appointment = models.ForeignKey(
'appointments.Appointment',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='nursing_encounters',
verbose_name=_("Appointment")
)
encounter_date = models.DateField(
verbose_name=_("Encounter Date")
)
filled_by = models.ForeignKey(
'core.User',
on_delete=models.SET_NULL,
null=True,
related_name='nursing_encounters_filled',
verbose_name=_("Filled By")
)
# Anthropometric Measurements
height_cm = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text=_("Height in centimeters"),
verbose_name=_("Height (cm)")
)
weight_kg = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text=_("Weight in kilograms"),
verbose_name=_("Weight (kg)")
)
head_circumference_cm = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text=_("Head circumference in centimeters"),
verbose_name=_("Head Circumference (cm)")
)
# Vital Signs
hr_bpm = models.PositiveIntegerField(
null=True,
blank=True,
help_text=_("Heart rate in beats per minute"),
verbose_name=_("Heart Rate (bpm)")
)
bp_systolic = models.PositiveIntegerField(
null=True,
blank=True,
help_text=_("Systolic blood pressure in mmHg"),
verbose_name=_("BP Systolic (mmHg)")
)
bp_diastolic = models.PositiveIntegerField(
null=True,
blank=True,
help_text=_("Diastolic blood pressure in mmHg"),
verbose_name=_("BP Diastolic (mmHg)")
)
respiratory_rate = models.PositiveIntegerField(
null=True,
blank=True,
help_text=_("Respiratory rate per minute"),
verbose_name=_("Respiratory Rate (/min)")
)
spo2 = models.PositiveIntegerField(
null=True,
blank=True,
help_text=_("Oxygen saturation percentage"),
verbose_name=_("SpO2 (%)")
)
temperature = models.DecimalField(
max_digits=4,
decimal_places=1,
null=True,
blank=True,
help_text=_("Temperature in Celsius"),
verbose_name=_("Temperature (°C)")
)
crt = models.CharField(
max_length=20,
choices=CRT.choices,
blank=True,
help_text=_("Capillary Refill Time"),
verbose_name=_("CRT")
)
# Pain Assessment
pain_score = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text=_("Pain score from 0 (no pain) to 10 (worst pain)"),
verbose_name=_("Pain Score (0-10)")
)
# Allergies
allergy_present = models.BooleanField(
default=False,
verbose_name=_("Allergy Present")
)
allergy_details = models.TextField(
blank=True,
verbose_name=_("Allergy Details")
)
# Observations
observations = models.TextField(
blank=True,
verbose_name=_("Observations")
)
history = HistoricalRecords()
class Meta:
verbose_name = _("Nursing Encounter")
verbose_name_plural = _("Nursing Encounters")
ordering = ['-encounter_date', '-created_at']
indexes = [
models.Index(fields=['patient', 'encounter_date']),
models.Index(fields=['appointment']),
models.Index(fields=['tenant', 'encounter_date']),
]
def __str__(self):
return f"Nursing Encounter - {self.patient} - {self.encounter_date}"
@property
def bmi(self):
"""Calculate BMI from height and weight."""
if self.height_cm and self.weight_kg:
height_m = float(self.height_cm) / 100
return round(float(self.weight_kg) / (height_m ** 2), 2)
return None
@property
def bmi_category(self):
"""Categorize BMI (simplified for adults)."""
bmi = self.bmi
if not bmi:
return None
if bmi < 18.5:
return _("Underweight")
elif bmi < 25:
return _("Normal weight")
elif bmi < 30:
return _("Overweight")
else:
return _("Obese")
@property
def blood_pressure(self):
"""Return formatted blood pressure."""
if self.bp_systolic and self.bp_diastolic:
return f"{self.bp_systolic}/{self.bp_diastolic}"
return None
@property
def has_abnormal_vitals(self):
"""Check if any vital signs are outside normal ranges (simplified)."""
abnormal = []
# Heart rate (normal adult: 60-100 bpm)
if self.hr_bpm and (self.hr_bpm < 60 or self.hr_bpm > 100):
abnormal.append('HR')
# Blood pressure (normal: <120/<80)
if self.bp_systolic and self.bp_systolic >= 140:
abnormal.append('BP Systolic')
if self.bp_diastolic and self.bp_diastolic >= 90:
abnormal.append('BP Diastolic')
# SpO2 (normal: >95%)
if self.spo2 and self.spo2 < 95:
abnormal.append('SpO2')
# Temperature (normal: 36.5-37.5°C)
if self.temperature and (self.temperature < 36.5 or self.temperature > 37.5):
abnormal.append('Temperature')
return abnormal
class GrowthChart(UUIDPrimaryKeyMixin, TimeStampedMixin):
"""
Growth chart data points for tracking patient growth over time.
Used to plot growth curves and percentiles.
"""
patient = models.ForeignKey(
'core.Patient',
on_delete=models.CASCADE,
related_name='growth_chart_data',
verbose_name=_("Patient")
)
nursing_encounter = models.ForeignKey(
NursingEncounter,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='growth_chart_entries',
verbose_name=_("Nursing Encounter")
)
measurement_date = models.DateField(
verbose_name=_("Measurement Date")
)
age_months = models.PositiveIntegerField(
help_text=_("Age in months at time of measurement"),
verbose_name=_("Age (months)")
)
# Measurements
height_cm = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
verbose_name=_("Height (cm)")
)
weight_kg = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
verbose_name=_("Weight (kg)")
)
head_circumference_cm = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
verbose_name=_("Head Circumference (cm)")
)
# Percentiles (calculated based on WHO/CDC growth charts)
percentile_height = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text=_("Height percentile (0-100)"),
verbose_name=_("Height Percentile")
)
percentile_weight = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text=_("Weight percentile (0-100)"),
verbose_name=_("Weight Percentile")
)
percentile_head_circumference = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text=_("Head circumference percentile (0-100)"),
verbose_name=_("Head Circumference Percentile")
)
percentile_bmi = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text=_("BMI percentile (0-100)"),
verbose_name=_("BMI Percentile")
)
class Meta:
verbose_name = _("Growth Chart Entry")
verbose_name_plural = _("Growth Chart Entries")
ordering = ['patient', 'measurement_date']
indexes = [
models.Index(fields=['patient', 'measurement_date']),
models.Index(fields=['patient', 'age_months']),
]
def __str__(self):
return f"Growth Chart - {self.patient} - {self.measurement_date} ({self.age_months} months)"
@property
def bmi(self):
"""Calculate BMI."""
if self.height_cm and self.weight_kg:
height_m = float(self.height_cm) / 100
return round(float(self.weight_kg) / (height_m ** 2), 2)
return None
def save(self, *args, **kwargs):
# Auto-populate from nursing encounter if linked
if self.nursing_encounter and not self.height_cm:
self.height_cm = self.nursing_encounter.height_cm
self.weight_kg = self.nursing_encounter.weight_kg
self.head_circumference_cm = self.nursing_encounter.head_circumference_cm
self.measurement_date = self.nursing_encounter.encounter_date
# Calculate age in months if not provided
if not self.age_months and self.measurement_date:
from dateutil.relativedelta import relativedelta
age_delta = relativedelta(self.measurement_date, self.patient.date_of_birth)
self.age_months = age_delta.years * 12 + age_delta.months
super().save(*args, **kwargs)
class VitalSignsAlert(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Alerts for abnormal vital signs that require attention.
Auto-generated when vitals are outside normal ranges.
"""
class Severity(models.TextChoices):
LOW = 'LOW', _('Low')
MEDIUM = 'MEDIUM', _('Medium')
HIGH = 'HIGH', _('High')
CRITICAL = 'CRITICAL', _('Critical')
class Status(models.TextChoices):
ACTIVE = 'ACTIVE', _('Active')
ACKNOWLEDGED = 'ACKNOWLEDGED', _('Acknowledged')
RESOLVED = 'RESOLVED', _('Resolved')
nursing_encounter = models.ForeignKey(
NursingEncounter,
on_delete=models.CASCADE,
related_name='alerts',
verbose_name=_("Nursing Encounter")
)
vital_sign = models.CharField(
max_length=50,
help_text=_("Which vital sign triggered the alert"),
verbose_name=_("Vital Sign")
)
value = models.CharField(
max_length=50,
verbose_name=_("Value")
)
severity = models.CharField(
max_length=20,
choices=Severity.choices,
verbose_name=_("Severity")
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.ACTIVE,
verbose_name=_("Status")
)
acknowledged_by = models.ForeignKey(
'core.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='acknowledged_alerts',
verbose_name=_("Acknowledged By")
)
acknowledged_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Acknowledged At")
)
notes = models.TextField(
blank=True,
verbose_name=_("Notes")
)
class Meta:
verbose_name = _("Vital Signs Alert")
verbose_name_plural = _("Vital Signs Alerts")
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'severity']),
models.Index(fields=['nursing_encounter']),
models.Index(fields=['tenant', 'status']),
]
def __str__(self):
return f"Alert: {self.vital_sign} - {self.nursing_encounter.patient} - {self.get_severity_display()}"