420 lines
12 KiB
Python
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()}"
|