525 lines
19 KiB
Python
525 lines
19 KiB
Python
"""
|
|
Nursing views for the Tenhal Multidisciplinary Healthcare Platform.
|
|
|
|
This module contains views for nursing documentation including:
|
|
- Nursing encounters (vitals, anthropometrics)
|
|
- Growth charts
|
|
- Allergy tracking
|
|
- Observation notes
|
|
"""
|
|
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.db.models import Q
|
|
from django.http import JsonResponse, HttpResponseRedirect
|
|
from django.shortcuts import get_object_or_404, redirect
|
|
from django.utils import timezone
|
|
from django.views.generic import ListView, DetailView, CreateView, UpdateView, View
|
|
from django.urls import reverse_lazy
|
|
from django.contrib import messages
|
|
|
|
from core.mixins import (
|
|
TenantFilterMixin,
|
|
RolePermissionMixin,
|
|
AuditLogMixin,
|
|
HTMXResponseMixin,
|
|
SuccessMessageMixin,
|
|
PaginationMixin,
|
|
)
|
|
from core.models import User, Patient
|
|
from appointments.models import Appointment
|
|
from .models import NursingEncounter
|
|
from .forms import NursingEncounterForm
|
|
|
|
|
|
# Sign View
|
|
class NursingEncounterSignView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, View):
|
|
"""Sign a nursing encounter."""
|
|
allowed_roles = [User.Role.ADMIN, User.Role.NURSE]
|
|
|
|
def post(self, request, pk):
|
|
encounter = get_object_or_404(NursingEncounter, pk=pk, tenant=request.user.tenant)
|
|
if encounter.signed_by:
|
|
messages.warning(request, "This encounter has already been signed.")
|
|
return HttpResponseRedirect(reverse_lazy('nursing:encounter_detail', kwargs={'pk': pk}))
|
|
if encounter.filled_by != request.user and request.user.role != User.Role.ADMIN:
|
|
messages.error(request, "Only the nurse who filled this encounter or an administrator can sign it.")
|
|
return HttpResponseRedirect(reverse_lazy('nursing:encounter_detail', kwargs={'pk': pk}))
|
|
encounter.signed_by = request.user
|
|
encounter.signed_at = timezone.now()
|
|
encounter.save()
|
|
messages.success(request, "Encounter signed successfully!")
|
|
return HttpResponseRedirect(reverse_lazy('nursing:encounter_detail', kwargs={'pk': pk}))
|
|
|
|
|
|
class NursingEncounterListView(LoginRequiredMixin, TenantFilterMixin, PaginationMixin,
|
|
HTMXResponseMixin, ListView):
|
|
"""
|
|
Nursing encounter list view.
|
|
|
|
Features:
|
|
- Filter by patient, date range
|
|
- Search by patient name/MRN
|
|
- Role-based filtering (nurses see their encounters)
|
|
- Quick vitals summary
|
|
"""
|
|
model = NursingEncounter
|
|
template_name = 'nursing/encounter_list.html'
|
|
htmx_template_name = 'nursing/partials/encounter_list_partial.html'
|
|
context_object_name = 'encounters'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
"""Get filtered queryset."""
|
|
queryset = super().get_queryset()
|
|
user = self.request.user
|
|
|
|
# Role-based filtering
|
|
if user.role == User.Role.NURSE:
|
|
queryset = queryset.filter(filled_by=user)
|
|
|
|
# Apply search
|
|
search_query = self.request.GET.get('search', '').strip()
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(patient__first_name_en__icontains=search_query) |
|
|
Q(patient__last_name_en__icontains=search_query) |
|
|
Q(patient__mrn__icontains=search_query)
|
|
)
|
|
|
|
# Apply filters
|
|
patient_id = self.request.GET.get('patient')
|
|
if patient_id:
|
|
queryset = queryset.filter(patient_id=patient_id)
|
|
|
|
date_from = self.request.GET.get('date_from')
|
|
if date_from:
|
|
queryset = queryset.filter(encounter_date__gte=date_from)
|
|
|
|
date_to = self.request.GET.get('date_to')
|
|
if date_to:
|
|
queryset = queryset.filter(encounter_date__lte=date_to)
|
|
|
|
return queryset.select_related('patient', 'filled_by', 'appointment', 'signed_by').order_by('-encounter_date')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add filter options and unsigned counts."""
|
|
context = super().get_context_data(**kwargs)
|
|
user = self.request.user
|
|
|
|
# Get unsigned encounters
|
|
unsigned_query = NursingEncounter.objects.filter(tenant=user.tenant, signed_by__isnull=True)
|
|
if user.role == User.Role.NURSE:
|
|
unsigned_query = unsigned_query.filter(filled_by=user)
|
|
|
|
context['unsigned_count'] = unsigned_query.count()
|
|
context['unsigned_items'] = unsigned_query.select_related('patient', 'filled_by').order_by('-encounter_date')[:10]
|
|
|
|
# Add current filters
|
|
context['current_filters'] = {
|
|
'search': self.request.GET.get('search', ''),
|
|
'patient': self.request.GET.get('patient', ''),
|
|
'date_from': self.request.GET.get('date_from', ''),
|
|
'date_to': self.request.GET.get('date_to', ''),
|
|
}
|
|
|
|
return context
|
|
|
|
|
|
class NursingEncounterDetailView(LoginRequiredMixin, TenantFilterMixin, DetailView):
|
|
"""
|
|
Nursing encounter detail view.
|
|
|
|
Features:
|
|
- Full vitals and anthropometrics
|
|
- BMI calculation and interpretation
|
|
- Growth chart visualization
|
|
- Allergy information
|
|
- Observation notes
|
|
"""
|
|
model = NursingEncounter
|
|
template_name = 'nursing/encounter_detail.html'
|
|
context_object_name = 'encounter'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add calculated values and interpretations."""
|
|
context = super().get_context_data(**kwargs)
|
|
encounter = self.object
|
|
|
|
# BMI interpretation
|
|
if encounter.bmi:
|
|
context['bmi_interpretation'] = self._interpret_bmi(encounter.bmi)
|
|
|
|
# Vital signs interpretation
|
|
context['vitals_interpretation'] = self._interpret_vitals(encounter)
|
|
|
|
# Get previous encounters for comparison
|
|
context['previous_encounters'] = NursingEncounter.objects.filter(
|
|
patient=encounter.patient,
|
|
encounter_date__lt=encounter.encounter_date
|
|
).order_by('-encounter_date')[:5]
|
|
|
|
# Growth chart data (for visualization)
|
|
context['growth_data'] = self._get_growth_chart_data(encounter.patient)
|
|
|
|
return context
|
|
|
|
def _interpret_bmi(self, bmi):
|
|
"""Interpret BMI value."""
|
|
if bmi < 18.5:
|
|
return {'category': 'Underweight', 'color': 'warning'}
|
|
elif 18.5 <= bmi < 25:
|
|
return {'category': 'Normal weight', 'color': 'success'}
|
|
elif 25 <= bmi < 30:
|
|
return {'category': 'Overweight', 'color': 'warning'}
|
|
else:
|
|
return {'category': 'Obese', 'color': 'danger'}
|
|
|
|
def _interpret_vitals(self, encounter):
|
|
"""Interpret vital signs."""
|
|
interpretations = {}
|
|
|
|
# Heart rate
|
|
if encounter.hr_bpm:
|
|
if encounter.hr_bpm < 60:
|
|
interpretations['hr'] = {'status': 'Low', 'color': 'warning'}
|
|
elif 60 <= encounter.hr_bpm <= 100:
|
|
interpretations['hr'] = {'status': 'Normal', 'color': 'success'}
|
|
else:
|
|
interpretations['hr'] = {'status': 'High', 'color': 'danger'}
|
|
|
|
# Blood pressure
|
|
if encounter.bp_systolic and encounter.bp_diastolic:
|
|
if encounter.bp_systolic < 120 and encounter.bp_diastolic < 80:
|
|
interpretations['bp'] = {'status': 'Normal', 'color': 'success'}
|
|
elif encounter.bp_systolic < 140 and encounter.bp_diastolic < 90:
|
|
interpretations['bp'] = {'status': 'Elevated', 'color': 'warning'}
|
|
else:
|
|
interpretations['bp'] = {'status': 'High', 'color': 'danger'}
|
|
|
|
# SpO2
|
|
if encounter.spo2:
|
|
if encounter.spo2 >= 95:
|
|
interpretations['spo2'] = {'status': 'Normal', 'color': 'success'}
|
|
elif encounter.spo2 >= 90:
|
|
interpretations['spo2'] = {'status': 'Low', 'color': 'warning'}
|
|
else:
|
|
interpretations['spo2'] = {'status': 'Critical', 'color': 'danger'}
|
|
|
|
return interpretations
|
|
|
|
def _get_growth_chart_data(self, patient):
|
|
"""Get growth chart data for patient."""
|
|
encounters = NursingEncounter.objects.filter(
|
|
patient=patient
|
|
).order_by('encounter_date')
|
|
|
|
data = {
|
|
'dates': [],
|
|
'weight': [],
|
|
'height': [],
|
|
'head_circ': [],
|
|
'bmi': [],
|
|
}
|
|
|
|
for encounter in encounters:
|
|
data['dates'].append(encounter.encounter_date.strftime('%Y-%m-%d'))
|
|
data['weight'].append(float(encounter.weight_kg) if encounter.weight_kg else None)
|
|
data['height'].append(float(encounter.height_cm) if encounter.height_cm else None)
|
|
data['head_circ'].append(float(encounter.head_circumference_cm) if encounter.head_circumference_cm else None)
|
|
data['bmi'].append(float(encounter.bmi) if encounter.bmi else None)
|
|
|
|
return data
|
|
|
|
|
|
class NursingEncounterCreateView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin,
|
|
SuccessMessageMixin, CreateView):
|
|
"""
|
|
Nursing encounter creation view.
|
|
|
|
Features:
|
|
- Record vitals and anthropometrics
|
|
- Auto-calculate BMI
|
|
- Link to appointment
|
|
- Allergy documentation
|
|
"""
|
|
model = NursingEncounter
|
|
form_class = NursingEncounterForm
|
|
template_name = 'nursing/encounter_form.html'
|
|
success_message = "Nursing encounter recorded successfully!"
|
|
allowed_roles = [User.Role.ADMIN, User.Role.NURSE]
|
|
|
|
def get_success_url(self):
|
|
"""Redirect to encounter detail."""
|
|
return reverse_lazy('nursing:encounter_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
"""Set tenant and filled_by."""
|
|
# Set tenant
|
|
form.instance.tenant = self.request.user.tenant
|
|
|
|
# Set filled_by
|
|
form.instance.filled_by = self.request.user
|
|
|
|
# Set encounter date if not provided
|
|
if not form.instance.encounter_date:
|
|
form.instance.encounter_date = timezone.now().date()
|
|
|
|
# Calculate BMI if height and weight provided
|
|
if form.instance.height_cm and form.instance.weight_kg:
|
|
height_m = form.instance.height_cm / 100
|
|
form.instance.bmi = form.instance.weight_kg / (height_m ** 2)
|
|
|
|
# Save encounter
|
|
response = super().form_valid(form)
|
|
|
|
# Update appointment status if linked
|
|
if self.object.appointment:
|
|
appointment = self.object.appointment
|
|
if appointment.status == Appointment.Status.IN_PROGRESS:
|
|
# Keep in progress, nursing is part of the visit
|
|
pass
|
|
|
|
return response
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add form title and patient/appointment info."""
|
|
context = super().get_context_data(**kwargs)
|
|
context['form_title'] = 'Record Nursing Encounter'
|
|
context['submit_text'] = 'Save Encounter'
|
|
|
|
# Get patient if provided
|
|
patient_id = self.request.GET.get('patient')
|
|
if patient_id:
|
|
try:
|
|
context['patient'] = Patient.objects.get(
|
|
pk=patient_id,
|
|
tenant=self.request.user.tenant
|
|
)
|
|
except Patient.DoesNotExist:
|
|
pass
|
|
|
|
# Get appointment if provided
|
|
appointment_id = self.request.GET.get('appointment_id')
|
|
if appointment_id:
|
|
try:
|
|
context['appointment'] = Appointment.objects.get(
|
|
pk=appointment_id,
|
|
tenant=self.request.user.tenant
|
|
)
|
|
context['patient'] = context['appointment'].patient
|
|
except Appointment.DoesNotExist:
|
|
pass
|
|
|
|
return context
|
|
|
|
|
|
class NursingEncounterUpdateView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin,
|
|
AuditLogMixin, SuccessMessageMixin, UpdateView):
|
|
"""
|
|
Nursing encounter update view.
|
|
|
|
Features:
|
|
- Update vitals and anthropometrics
|
|
- Recalculate BMI
|
|
- Version history
|
|
"""
|
|
model = NursingEncounter
|
|
form_class = NursingEncounterForm
|
|
template_name = 'nursing/encounter_form.html'
|
|
success_message = "Nursing encounter updated successfully!"
|
|
allowed_roles = [User.Role.ADMIN, User.Role.NURSE]
|
|
|
|
def get_success_url(self):
|
|
"""Redirect to encounter detail."""
|
|
return reverse_lazy('nursing:encounter_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
"""Recalculate BMI if needed."""
|
|
# Recalculate BMI if height or weight changed
|
|
if form.instance.height_cm and form.instance.weight_kg:
|
|
height_m = form.instance.height_cm / 100
|
|
form.instance.bmi = form.instance.weight_kg / (height_m ** 2)
|
|
|
|
return super().form_valid(form)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add form title and history."""
|
|
context = super().get_context_data(**kwargs)
|
|
context['form_title'] = f'Update Nursing Encounter - {self.object.patient.mrn}'
|
|
context['submit_text'] = 'Update Encounter'
|
|
|
|
# Add version history if available
|
|
if hasattr(self.object, 'history'):
|
|
context['history'] = self.object.history.all()[:10]
|
|
|
|
return context
|
|
|
|
|
|
class PatientVitalsHistoryView(LoginRequiredMixin, TenantFilterMixin, ListView):
|
|
"""
|
|
Patient vitals history view.
|
|
|
|
Features:
|
|
- All vitals for a specific patient
|
|
- Trend visualization
|
|
- Growth charts
|
|
- Export to PDF
|
|
"""
|
|
model = NursingEncounter
|
|
template_name = 'nursing/patient_vitals_history.html'
|
|
context_object_name = 'encounters'
|
|
paginate_by = 50
|
|
|
|
def get_queryset(self):
|
|
"""Get all encounters for patient."""
|
|
patient_id = self.kwargs.get('patient_id')
|
|
return NursingEncounter.objects.filter(
|
|
patient_id=patient_id,
|
|
tenant=self.request.user.tenant
|
|
).order_by('-encounter_date')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add patient and chart data."""
|
|
context = super().get_context_data(**kwargs)
|
|
|
|
patient_id = self.kwargs.get('patient_id')
|
|
context['patient'] = get_object_or_404(
|
|
Patient,
|
|
pk=patient_id,
|
|
tenant=self.request.user.tenant
|
|
)
|
|
|
|
# Get chart data
|
|
encounters = self.get_queryset()
|
|
context['chart_data'] = self._prepare_chart_data(encounters)
|
|
|
|
# Get statistics
|
|
context['stats'] = self._calculate_statistics(encounters)
|
|
|
|
return context
|
|
|
|
def _prepare_chart_data(self, encounters):
|
|
"""Prepare data for charts."""
|
|
data = {
|
|
'dates': [],
|
|
'weight': [],
|
|
'height': [],
|
|
'bmi': [],
|
|
'hr': [],
|
|
'bp_sys': [],
|
|
'bp_dia': [],
|
|
'spo2': [],
|
|
'temperature': [],
|
|
}
|
|
|
|
for encounter in encounters.order_by('encounter_date'):
|
|
data['dates'].append(encounter.encounter_date.strftime('%Y-%m-%d'))
|
|
data['weight'].append(float(encounter.weight_kg) if encounter.weight_kg else None)
|
|
data['height'].append(float(encounter.height_cm) if encounter.height_cm else None)
|
|
data['bmi'].append(float(encounter.bmi) if encounter.bmi else None)
|
|
data['hr'].append(encounter.hr_bpm if encounter.hr_bpm else None)
|
|
data['bp_sys'].append(encounter.bp_systolic if encounter.bp_systolic else None)
|
|
data['bp_dia'].append(encounter.bp_diastolic if encounter.bp_diastolic else None)
|
|
data['spo2'].append(encounter.spo2 if encounter.spo2 else None)
|
|
data['temperature'].append(float(encounter.temperature) if encounter.temperature else None)
|
|
|
|
return data
|
|
|
|
def _calculate_statistics(self, encounters):
|
|
"""Calculate statistics from encounters."""
|
|
if not encounters:
|
|
return {}
|
|
|
|
latest = encounters.first()
|
|
oldest = encounters.last()
|
|
|
|
stats = {
|
|
'total_encounters': encounters.count(),
|
|
'latest_weight': latest.weight_kg if latest.weight_kg else None,
|
|
'latest_height': latest.height_cm if latest.height_cm else None,
|
|
'latest_bmi': latest.bmi if latest.bmi else None,
|
|
}
|
|
|
|
# Calculate weight change if we have both
|
|
if latest.weight_kg and oldest.weight_kg and encounters.count() > 1:
|
|
stats['weight_change'] = latest.weight_kg - oldest.weight_kg
|
|
|
|
# Calculate height change if we have both
|
|
if latest.height_cm and oldest.height_cm and encounters.count() > 1:
|
|
stats['height_change'] = latest.height_cm - oldest.height_cm
|
|
|
|
return stats
|
|
|
|
|
|
class GrowthChartView(LoginRequiredMixin, TenantFilterMixin, DetailView):
|
|
"""
|
|
Growth chart visualization view.
|
|
|
|
Features:
|
|
- WHO growth standards overlay
|
|
- Patient measurements plotted
|
|
- Percentile calculations
|
|
- Export to PDF
|
|
"""
|
|
model = Patient
|
|
template_name = 'nursing/growth_chart.html'
|
|
context_object_name = 'patient'
|
|
pk_url_kwarg = 'patient_id'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
"""Add growth chart data."""
|
|
context = super().get_context_data(**kwargs)
|
|
patient = self.object
|
|
|
|
# Get all encounters
|
|
encounters = NursingEncounter.objects.filter(
|
|
patient=patient
|
|
).order_by('encounter_date')
|
|
|
|
# Prepare chart data
|
|
context['chart_data'] = self._prepare_growth_chart_data(encounters, patient)
|
|
|
|
# Get WHO standards (placeholder - would need actual WHO data)
|
|
context['who_standards'] = self._get_who_standards(patient)
|
|
|
|
return context
|
|
|
|
def _prepare_growth_chart_data(self, encounters, patient):
|
|
"""Prepare growth chart data."""
|
|
data = {
|
|
'age_months': [],
|
|
'weight': [],
|
|
'height': [],
|
|
'head_circ': [],
|
|
'bmi': [],
|
|
}
|
|
|
|
for encounter in encounters:
|
|
# Calculate age in months at encounter
|
|
age_months = self._calculate_age_months(patient.date_of_birth, encounter.encounter_date)
|
|
|
|
data['age_months'].append(age_months)
|
|
data['weight'].append(float(encounter.weight_kg) if encounter.weight_kg else None)
|
|
data['height'].append(float(encounter.height_cm) if encounter.height_cm else None)
|
|
data['head_circ'].append(float(encounter.head_circumference_cm) if encounter.head_circumference_cm else None)
|
|
data['bmi'].append(float(encounter.bmi) if encounter.bmi else None)
|
|
|
|
return data
|
|
|
|
def _calculate_age_months(self, birth_date, encounter_date):
|
|
"""Calculate age in months."""
|
|
years = encounter_date.year - birth_date.year
|
|
months = encounter_date.month - birth_date.month
|
|
return years * 12 + months
|
|
|
|
def _get_who_standards(self, patient):
|
|
"""Get WHO growth standards for patient's gender."""
|
|
# TODO: Implement WHO growth standards
|
|
# This would load actual WHO percentile data based on patient gender
|
|
return {
|
|
'percentiles': [3, 15, 50, 85, 97],
|
|
'weight': {},
|
|
'height': {},
|
|
'bmi': {},
|
|
}
|