681 lines
29 KiB
Python
681 lines
29 KiB
Python
"""
|
||
Operating Theatre app forms for CRUD operations.
|
||
Implements healthcare-appropriate form validation and business rules.
|
||
"""
|
||
|
||
from django import forms
|
||
from django.core.exceptions import ValidationError
|
||
from django.utils import timezone
|
||
from datetime import datetime, time, timedelta
|
||
import json, re
|
||
from accounts.models import User
|
||
from patients.models import PatientProfile
|
||
from .models import (
|
||
OperatingRoom, ORBlock, SurgicalCase, SurgicalNote,
|
||
EquipmentUsage, SurgicalNoteTemplate
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# OPERATING ROOM FORMS
|
||
# ============================================================================
|
||
|
||
def _list_to_text(value):
|
||
if isinstance(value, list):
|
||
return "\n".join(str(v) for v in value)
|
||
return "" if value in (None, "", []) else str(value)
|
||
|
||
def _text_to_list(value):
|
||
if value is None:
|
||
return []
|
||
raw = str(value).strip()
|
||
if not raw:
|
||
return []
|
||
# Try JSON list first
|
||
try:
|
||
parsed = json.loads(raw)
|
||
if isinstance(parsed, list):
|
||
return [str(x).strip() for x in parsed if str(x).strip()]
|
||
except Exception:
|
||
pass
|
||
# Fallback: newline / comma split
|
||
items = []
|
||
for line in raw.splitlines():
|
||
for piece in line.split(","):
|
||
p = piece.strip()
|
||
if p:
|
||
items.append(p)
|
||
return items
|
||
|
||
class OperatingRoomForm(forms.ModelForm):
|
||
"""
|
||
Full form aligned to the panel template:
|
||
- exposes environment, capabilities, scheduling fields
|
||
- maps equipment_list & special_features (JSON) <-> textarea
|
||
- enforces tenant-scoped uniqueness of room_number
|
||
- validates temp/humidity ranges, and simple numeric sanity checks
|
||
"""
|
||
class Meta:
|
||
model = OperatingRoom
|
||
fields = [
|
||
# Basic
|
||
'room_number', 'room_name', 'room_type', 'status',
|
||
# Physical
|
||
'floor_number', 'building', 'wing', 'room_size', 'ceiling_height',
|
||
# Environment
|
||
'temperature_min', 'temperature_max',
|
||
'humidity_min', 'humidity_max',
|
||
'air_changes_per_hour', 'positive_pressure',
|
||
# Capabilities & equipment
|
||
'supports_robotic', 'supports_laparoscopic',
|
||
'supports_microscopy', 'supports_laser',
|
||
'has_c_arm', 'has_ct', 'has_mri', 'has_ultrasound', 'has_neuromonitoring',
|
||
'equipment_list', 'special_features',
|
||
# Scheduling / staffing
|
||
'max_case_duration', 'turnover_time', 'cleaning_time',
|
||
'required_nurses', 'required_techs',
|
||
'is_active', 'accepts_emergency',
|
||
]
|
||
widgets = {
|
||
# Basic
|
||
'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., OR-01', 'required': True}),
|
||
'room_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Main Operating Room 1', 'required': True}),
|
||
'room_type': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||
'status': forms.Select(attrs={'class': 'form-control', 'required': True}),
|
||
# Physical
|
||
'floor_number': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 200, 'required': True}),
|
||
'building': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., Main Building'}),
|
||
'wing': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g., East Wing'}),
|
||
'room_size': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 2000}),
|
||
'ceiling_height': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 20}),
|
||
# Environment
|
||
'temperature_min': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||
'temperature_max': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||
'humidity_min': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||
'humidity_max': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||
'air_changes_per_hour':forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 120}),
|
||
'positive_pressure': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
# Capabilities & imaging
|
||
'supports_robotic': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'supports_laparoscopic':forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'supports_microscopy': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'supports_laser': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'has_c_arm': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'has_ct': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'has_mri': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'has_ultrasound': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'has_neuromonitoring': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'equipment_list': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'One per line, or comma-separated…'}),
|
||
'special_features': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Hybrid OR, laminar flow, etc.'}),
|
||
# Scheduling / staffing
|
||
'max_case_duration': forms.NumberInput(attrs={'class': 'form-control', 'min': 30, 'max': 1440}),
|
||
'turnover_time': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 240}),
|
||
'cleaning_time': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 240}),
|
||
'required_nurses': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 20}),
|
||
'required_techs': forms.NumberInput(attrs={'class': 'form-control', 'min': 0, 'max': 20}),
|
||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
'accepts_emergency':forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
}
|
||
help_texts = {
|
||
'room_number': 'Unique identifier (per tenant). Letters, numbers, dashes.',
|
||
'room_size': 'Square meters.',
|
||
'temperature_min': 'Typical ORs: 18–26°C.',
|
||
'humidity_min': 'Typical ORs: 30–60%.',
|
||
'air_changes_per_hour': '20+ is common in OR standards.',
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
self.tenant = kwargs.pop('tenant', None)
|
||
super().__init__(*args, **kwargs)
|
||
if self.instance and self.instance.pk:
|
||
self.fields['equipment_list'].initial = _list_to_text(self.instance.equipment_list)
|
||
self.fields['special_features'].initial = _list_to_text(self.instance.special_features)
|
||
|
||
# JSONField <-> textarea mapping
|
||
def clean_equipment_list(self):
|
||
return _text_to_list(self.cleaned_data.get('equipment_list'))
|
||
|
||
def clean_special_features(self):
|
||
return _text_to_list(self.cleaned_data.get('special_features'))
|
||
|
||
def clean_room_number(self):
|
||
room_number = (self.cleaned_data.get('room_number') or '').strip()
|
||
if not room_number:
|
||
return room_number
|
||
if not re.match(r'^[A-Za-z0-9\-]+$', room_number):
|
||
raise ValidationError('Room number may contain only letters, numbers, and dashes.')
|
||
# tenant-scoped uniqueness
|
||
qs = OperatingRoom.objects.all()
|
||
tenant = self.tenant or getattr(self.instance, 'tenant', None)
|
||
if tenant is not None:
|
||
qs = qs.filter(tenant=tenant)
|
||
qs = qs.filter(room_number__iexact=room_number)
|
||
if self.instance.pk:
|
||
qs = qs.exclude(pk=self.instance.pk)
|
||
if qs.exists():
|
||
raise ValidationError('Room number must be unique within the tenant.')
|
||
return room_number
|
||
|
||
def clean(self):
|
||
cleaned = super().clean()
|
||
# Temperature/humidity ranges
|
||
tmin, tmax = cleaned.get('temperature_min'), cleaned.get('temperature_max')
|
||
hmin, hmax = cleaned.get('humidity_min'), cleaned.get('humidity_max')
|
||
if tmin is not None and tmax is not None and tmin >= tmax:
|
||
self.add_error('temperature_max', 'Maximum temperature must be greater than minimum temperature.')
|
||
if hmin is not None and hmax is not None and hmin >= hmax:
|
||
self.add_error('humidity_max', 'Maximum humidity must be greater than minimum humidity.')
|
||
# Simple sanity checks
|
||
for field, minv in [('max_case_duration', 1), ('turnover_time', 0), ('cleaning_time', 0)]:
|
||
v = cleaned.get(field)
|
||
if v is not None and v < minv:
|
||
self.add_error(field, f'{field.replace("_", " ").title()} must be ≥ {minv}.')
|
||
return cleaned
|
||
|
||
|
||
# ============================================================================
|
||
# OR BLOCK FORMS
|
||
# ============================================================================
|
||
|
||
class ORBlockForm(forms.ModelForm):
|
||
"""
|
||
Form for creating and updating OR blocks.
|
||
"""
|
||
|
||
class Meta:
|
||
model = ORBlock
|
||
fields = [
|
||
'operating_room', 'primary_surgeon', 'date', 'start_time', 'end_time',
|
||
'block_type', 'notes'
|
||
]
|
||
widgets = {
|
||
'operating_room': forms.Select(attrs={'class': 'form-control'}),
|
||
'primary_surgeon': forms.Select(attrs={'class': 'form-control'}),
|
||
'date': forms.DateInput(attrs={
|
||
'class': 'form-control',
|
||
'type': 'date'
|
||
}),
|
||
'start_time': forms.TimeInput(attrs={
|
||
'class': 'form-control',
|
||
'type': 'time'
|
||
}),
|
||
'end_time': forms.TimeInput(attrs={
|
||
'class': 'form-control',
|
||
'type': 'time'
|
||
}),
|
||
'block_type': forms.Select(attrs={'class': 'form-control'}),
|
||
'notes': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 3,
|
||
'placeholder': 'Block notes and special instructions...'
|
||
}),
|
||
}
|
||
help_texts = {
|
||
'date': 'Date for this OR block',
|
||
'start_time': 'Block start time',
|
||
'end_time': 'Block end time',
|
||
'block_type': 'Type of surgical block',
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
# Filter operating rooms by tenant and active status
|
||
self.fields['operating_room'].queryset = OperatingRoom.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True
|
||
).order_by('room_number')
|
||
|
||
# Filter surgeons by tenant and role
|
||
self.fields['surgeon'].queryset = User.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True,
|
||
role__in=['SURGEON', 'ATTENDING_PHYSICIAN']
|
||
).order_by('first_name', 'last_name')
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
date = cleaned_data.get('date')
|
||
start_time = cleaned_data.get('start_time')
|
||
end_time = cleaned_data.get('end_time')
|
||
operating_room = cleaned_data.get('operating_room')
|
||
|
||
# Validate date is not in the past
|
||
if date and date < timezone.now().date():
|
||
raise ValidationError('Block date cannot be in the past.')
|
||
|
||
# Validate time range
|
||
if start_time and end_time:
|
||
if start_time >= end_time:
|
||
raise ValidationError('End time must be after start time.')
|
||
|
||
# Check minimum block duration (30 minutes)
|
||
start_datetime = datetime.combine(timezone.now().date(), start_time)
|
||
end_datetime = datetime.combine(timezone.now().date(), end_time)
|
||
duration = end_datetime - start_datetime
|
||
|
||
if duration < timedelta(minutes=30):
|
||
raise ValidationError('Block duration must be at least 30 minutes.')
|
||
|
||
# Check for overlapping blocks
|
||
if date and start_time and end_time and operating_room:
|
||
overlapping_blocks = ORBlock.objects.filter(
|
||
operating_room=operating_room,
|
||
date=date,
|
||
start_time__lt=end_time,
|
||
end_time__gt=start_time
|
||
)
|
||
|
||
if self.instance.pk:
|
||
overlapping_blocks = overlapping_blocks.exclude(pk=self.instance.pk)
|
||
|
||
if overlapping_blocks.exists():
|
||
raise ValidationError(
|
||
f'This time slot overlaps with an existing block in {operating_room.room_number}.'
|
||
)
|
||
|
||
return cleaned_data
|
||
|
||
|
||
# ============================================================================
|
||
# SURGICAL CASE FORMS
|
||
# ============================================================================
|
||
|
||
class SurgicalCaseForm(forms.ModelForm):
|
||
"""
|
||
Form for creating and updating surgical cases.
|
||
"""
|
||
|
||
class Meta:
|
||
model = SurgicalCase
|
||
fields = [
|
||
'patient', 'primary_procedure', 'procedure_codes', 'primary_surgeon',
|
||
'anesthesiologist', 'or_block',
|
||
'scheduled_start', 'estimated_duration',
|
||
'case_type', 'anesthesia_type',
|
||
'diagnosis', 'diagnosis_codes', 'complications',
|
||
'clinical_notes', 'status'
|
||
]
|
||
widgets = {
|
||
'patient': forms.Select(attrs={'class': 'form-control'}),
|
||
'primary_procedure': forms.TextInput(attrs={
|
||
'class': 'form-control',
|
||
'placeholder': 'e.g., Laparoscopic Cholecystectomy'
|
||
}),
|
||
'primary_surgeon': forms.Select(attrs={'class': 'form-control'}),
|
||
'anesthesiologist': forms.Select(attrs={'class': 'form-control'}),
|
||
'or_block': forms.Select(attrs={'class': 'form-control'}),
|
||
'scheduled_start': forms.DateTimeInput(attrs={
|
||
'class': 'form-control',
|
||
'type': 'datetime-local'
|
||
}),
|
||
'estimated_duration': forms.NumberInput(attrs={
|
||
'class': 'form-control',
|
||
'min': 15,
|
||
'max': 1440,
|
||
'step': 15
|
||
}),
|
||
'case_type': forms.Select(attrs={'class': 'form-control'}),
|
||
'anesthesia_type': forms.Select(attrs={'class': 'form-control'}),
|
||
'diagnosis': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 3,
|
||
'placeholder': 'Primary diagnosis...'
|
||
}),
|
||
'diagnosis_codes': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 2,
|
||
'placeholder': 'ICD-10 codes...'
|
||
}),
|
||
'complications': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 3,
|
||
'placeholder': 'Any complications encountered...'
|
||
}),
|
||
'clinical_notes': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 4,
|
||
'placeholder': 'Additional case notes...'
|
||
}),
|
||
'status': forms.Select(attrs={'class': 'form-control'}),
|
||
}
|
||
help_texts = {
|
||
'procedure_name': 'Full name of the surgical procedure',
|
||
'procedure_code': 'CPT or ICD procedure code',
|
||
'estimated_duration_minutes': 'Estimated duration in minutes (15-minute increments)',
|
||
'priority': 'Case priority level',
|
||
'case_type': 'Type of surgical case',
|
||
'anesthesia_type': 'Type of anesthesia required',
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
# Filter patients by tenant
|
||
self.fields['patient'].queryset = PatientProfile.objects.filter(
|
||
tenant=user.tenant
|
||
).order_by('last_name', 'first_name')
|
||
|
||
# Filter operating rooms by tenant and active status
|
||
self.fields['operating_room'].queryset = OperatingRoom.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True
|
||
).order_by('room_number')
|
||
|
||
# Filter medical staff by tenant and appropriate roles
|
||
surgeon_queryset = User.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True,
|
||
role__in=['SURGEON', 'ATTENDING_PHYSICIAN']
|
||
).order_by('first_name', 'last_name')
|
||
|
||
anesthesiologist_queryset = User.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True,
|
||
role='ANESTHESIOLOGIST'
|
||
).order_by('first_name', 'last_name')
|
||
|
||
self.fields['primary_surgeon'].queryset = surgeon_queryset
|
||
self.fields['assisting_surgeon'].queryset = surgeon_queryset
|
||
self.fields['anesthesiologist'].queryset = anesthesiologist_queryset
|
||
|
||
# Make assisting surgeon and anesthesiologist optional
|
||
self.fields['assisting_surgeon'].required = False
|
||
self.fields['anesthesiologist'].required = False
|
||
|
||
def clean_scheduled_start_time(self):
|
||
scheduled_start_time = self.cleaned_data['scheduled_start_time']
|
||
|
||
# Validate scheduled time is not in the past (allow some buffer for updates)
|
||
if scheduled_start_time and scheduled_start_time < timezone.now() - timedelta(hours=1):
|
||
raise ValidationError('Scheduled start time cannot be more than 1 hour in the past.')
|
||
|
||
return scheduled_start_time
|
||
|
||
def clean_estimated_duration_minutes(self):
|
||
duration = self.cleaned_data['estimated_duration_minutes']
|
||
|
||
if duration and (duration < 15 or duration > 1440):
|
||
raise ValidationError('Duration must be between 15 minutes and 24 hours.')
|
||
|
||
if duration and duration % 15 != 0:
|
||
raise ValidationError('Duration must be in 15-minute increments.')
|
||
|
||
return duration
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
scheduled_start_time = cleaned_data.get('scheduled_start_time')
|
||
operating_room = cleaned_data.get('operating_room')
|
||
estimated_duration = cleaned_data.get('estimated_duration_minutes')
|
||
priority = cleaned_data.get('priority')
|
||
|
||
# Check for room availability (basic conflict detection)
|
||
if scheduled_start_time and operating_room and estimated_duration:
|
||
estimated_end_time = scheduled_start_time + timedelta(minutes=estimated_duration)
|
||
|
||
# Check for overlapping cases (unless emergency)
|
||
if priority != 'EMERGENCY':
|
||
overlapping_cases = SurgicalCase.objects.filter(
|
||
operating_room=operating_room,
|
||
scheduled_start_time__lt=estimated_end_time,
|
||
status__in=['SCHEDULED', 'IN_PROGRESS']
|
||
)
|
||
|
||
# Add estimated end time filter for existing cases
|
||
for case in overlapping_cases:
|
||
if case.estimated_duration_minutes:
|
||
case_end_time = case.scheduled_start_time + timedelta(
|
||
minutes=case.estimated_duration_minutes
|
||
)
|
||
if case_end_time > scheduled_start_time:
|
||
if self.instance.pk != case.pk:
|
||
raise ValidationError(
|
||
f'Time slot conflicts with existing case in {operating_room.room_number}. '
|
||
f'Existing case: {case.procedure_name} '
|
||
f'({case.scheduled_start_time.strftime("%H:%M")} - '
|
||
f'{case_end_time.strftime("%H:%M")})'
|
||
)
|
||
|
||
return cleaned_data
|
||
|
||
|
||
# ============================================================================
|
||
# SURGICAL NOTE FORMS
|
||
# ============================================================================
|
||
|
||
class SurgicalNoteForm(forms.ModelForm):
|
||
"""
|
||
Form for creating surgical notes.
|
||
"""
|
||
|
||
class Meta:
|
||
model = SurgicalNote
|
||
fields = [
|
||
'surgical_case', 'template_used', 'surgeon', 'procedure_performed',
|
||
'findings', 'complications', 'postop_instructions'
|
||
]
|
||
widgets = {
|
||
'surgical_case': forms.Select(attrs={'class': 'form-control'}),
|
||
'template_used': forms.Select(attrs={'class': 'form-control'}),
|
||
'surgeon': forms.Select(attrs={'class': 'form-control'}),
|
||
'procedure_performed': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 10,
|
||
'placeholder': 'Enter procedure details...'
|
||
}),
|
||
'findings': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 4,
|
||
'placeholder': 'Surgical findings...'
|
||
}),
|
||
'complications': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 3,
|
||
'placeholder': 'Any complications encountered...'
|
||
}),
|
||
'postop_instructions': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 3,
|
||
'placeholder': 'Post-operative instructions...'
|
||
}),
|
||
}
|
||
help_texts = {
|
||
'template_used': 'Select a template to pre-populate content',
|
||
'surgeon': 'Primary surgeon for this note',
|
||
'procedure_performed': 'Detailed description of the procedure',
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
# Filter surgical cases by tenant
|
||
self.fields['surgical_case'].queryset = SurgicalCase.objects.filter(
|
||
tenant=user.tenant
|
||
).select_related('patient').order_by('-scheduled_start_time')
|
||
|
||
# Filter templates by tenant and active status
|
||
self.fields['template'].queryset = SurgicalNoteTemplate.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True
|
||
).order_by('template_name')
|
||
|
||
# Make template optional
|
||
self.fields['template'].required = False
|
||
|
||
def clean_note_content(self):
|
||
note_content = self.cleaned_data['note_content']
|
||
|
||
if not note_content or len(note_content.strip()) < 10:
|
||
raise ValidationError('Note content must be at least 10 characters long.')
|
||
|
||
return note_content
|
||
|
||
|
||
# ============================================================================
|
||
# EQUIPMENT USAGE FORMS
|
||
# ============================================================================
|
||
|
||
class EquipmentUsageForm(forms.ModelForm):
|
||
"""
|
||
Form for creating and updating equipment usage records.
|
||
"""
|
||
|
||
class Meta:
|
||
model = EquipmentUsage
|
||
fields = [
|
||
'equipment_name', 'equipment_type', 'serial_number',
|
||
'surgical_case', 'start_time', 'end_time',
|
||
'quantity_used', 'notes'
|
||
]
|
||
widgets = {
|
||
'equipment_name': forms.TextInput(attrs={
|
||
'class': 'form-control',
|
||
'placeholder': 'e.g., Da Vinci Robot #1'
|
||
}),
|
||
'equipment_type': forms.Select(attrs={'class': 'form-control'}),
|
||
'serial_number': forms.TextInput(attrs={
|
||
'class': 'form-control',
|
||
'placeholder': 'Equipment serial number'
|
||
}),
|
||
'surgical_case': forms.Select(attrs={'class': 'form-control'}),
|
||
'start_time': forms.DateTimeInput(attrs={
|
||
'class': 'form-control',
|
||
'type': 'datetime-local'
|
||
}),
|
||
'end_time': forms.DateTimeInput(attrs={
|
||
'class': 'form-control',
|
||
'type': 'datetime-local'
|
||
}),
|
||
'quantity_used': forms.NumberInput(attrs={
|
||
'class': 'form-control',
|
||
'min': 1
|
||
}),
|
||
'notes': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 3,
|
||
'placeholder': 'Equipment usage notes...'
|
||
}),
|
||
}
|
||
help_texts = {
|
||
'equipment_name': 'Name or description of the equipment',
|
||
'equipment_type': 'Category of equipment',
|
||
'serial_number': 'Unique serial number for the equipment',
|
||
'start_time': 'When equipment usage started',
|
||
'end_time': 'When equipment usage ended (leave blank if still in use)',
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
# Filter operating rooms by tenant
|
||
self.fields['operating_room'].queryset = OperatingRoom.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True
|
||
).order_by('room_number')
|
||
|
||
# Filter surgical cases by tenant
|
||
self.fields['surgical_case'].queryset = SurgicalCase.objects.filter(
|
||
tenant=user.tenant
|
||
).select_related('patient').order_by('-scheduled_start_time')
|
||
|
||
# Make surgical case optional
|
||
self.fields['surgical_case'].required = False
|
||
# Make end time optional
|
||
self.fields['end_time'].required = False
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
start_time = cleaned_data.get('start_time')
|
||
end_time = cleaned_data.get('end_time')
|
||
|
||
# Validate time range if both are provided
|
||
if start_time and end_time:
|
||
if start_time >= end_time:
|
||
raise ValidationError('End time must be after start time.')
|
||
|
||
# Check for reasonable duration (not more than 24 hours)
|
||
duration = end_time - start_time
|
||
if duration > timedelta(hours=24):
|
||
raise ValidationError('Equipment usage cannot exceed 24 hours.')
|
||
|
||
# Validate start time is not too far in the future
|
||
if start_time and start_time > timezone.now() + timedelta(hours=24):
|
||
raise ValidationError('Start time cannot be more than 24 hours in the future.')
|
||
|
||
return cleaned_data
|
||
|
||
|
||
# ============================================================================
|
||
# SURGICAL NOTE TEMPLATE FORMS
|
||
# ============================================================================
|
||
|
||
class SurgicalNoteTemplateForm(forms.ModelForm):
|
||
"""
|
||
Form for creating and updating surgical note templates.
|
||
"""
|
||
|
||
class Meta:
|
||
model = SurgicalNoteTemplate
|
||
fields = [
|
||
'name', 'specialty', 'procedure_type',
|
||
'description', 'findings_template', 'postop_instructions_template',
|
||
'is_active'
|
||
]
|
||
widgets = {
|
||
'name': forms.TextInput(attrs={
|
||
'class': 'form-control',
|
||
'placeholder': 'e.g., Laparoscopic Cholecystectomy Template'
|
||
}),
|
||
'specialty': forms.Select(attrs={'class': 'form-control'}),
|
||
'procedure_type': forms.TextInput(attrs={
|
||
'class': 'form-control',
|
||
'placeholder': 'e.g., Laparoscopic Surgery'
|
||
}),
|
||
'description': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 4,
|
||
'placeholder': 'Template description and usage notes...'
|
||
}),
|
||
'findings_template': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 4,
|
||
'placeholder': 'Default findings template text...'
|
||
}),
|
||
'postop_instructions_template': forms.Textarea(attrs={
|
||
'class': 'form-control',
|
||
'rows': 4,
|
||
'placeholder': 'Default post-op instructions template...'
|
||
}),
|
||
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
}
|
||
help_texts = {
|
||
'name': 'Descriptive name for this template',
|
||
'specialty': 'Medical specialty this template is for',
|
||
'procedure_type': 'Type of procedure this template covers',
|
||
'description': 'Template description and usage notes',
|
||
'is_active': 'Whether this template is available for use',
|
||
}
|
||
|
||
def clean_name(self):
|
||
name = self.cleaned_data['name']
|
||
|
||
# Check for uniqueness within tenant
|
||
queryset = SurgicalNoteTemplate.objects.filter(name=name)
|
||
if self.instance.pk:
|
||
queryset = queryset.exclude(pk=self.instance.pk)
|
||
|
||
if queryset.exists():
|
||
raise ValidationError('Template name must be unique.')
|
||
|
||
return name
|
||
|