""" 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