""" 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()}"