616 lines
24 KiB
Python
616 lines
24 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
|
|
|
|
from accounts.models import User
|
|
from patients.models import PatientProfile
|
|
from .models import (
|
|
OperatingRoom, ORBlock, SurgicalCase, SurgicalNote,
|
|
EquipmentUsage, SurgicalNoteTemplate
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# OPERATING ROOM FORMS
|
|
# ============================================================================
|
|
|
|
class OperatingRoomForm(forms.ModelForm):
|
|
"""
|
|
Form for creating and updating operating rooms.
|
|
"""
|
|
|
|
class Meta:
|
|
model = OperatingRoom
|
|
fields = [
|
|
'room_number', 'room_name', 'room_type', 'floor_number', 'building',
|
|
'room_size', 'equipment_list', 'special_features',
|
|
'status', 'is_active', 'wing'
|
|
]
|
|
widgets = {
|
|
'room_number': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'e.g., OR-01'
|
|
}),
|
|
'room_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'e.g., Main Operating Room 1'
|
|
}),
|
|
'room_type': forms.Select(attrs={'class': 'form-control'}),
|
|
'floor_number': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': 1,
|
|
'max': 50
|
|
}),
|
|
'building': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'e.g., Main Building'
|
|
}),
|
|
'room_size': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': 1,
|
|
'max': 1000
|
|
}),
|
|
'equipment_list': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 4,
|
|
'placeholder': 'List available equipment...'
|
|
}),
|
|
'special_features': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Special capabilities and features...'
|
|
}),
|
|
'status': forms.Select(attrs={'class': 'form-control'}),
|
|
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
|
'wing': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'e.g., East Wing'
|
|
}),
|
|
}
|
|
help_texts = {
|
|
'room_number': 'Unique identifier for the operating room',
|
|
'room_type': 'Type of procedures this room is designed for',
|
|
'room_size': 'Size of the room in square meters',
|
|
'equipment_list': 'List of permanently installed equipment',
|
|
'special_features': 'Special features like imaging, robotics, etc.',
|
|
}
|
|
|
|
def clean_room_number(self):
|
|
room_number = self.cleaned_data['room_number']
|
|
|
|
# Check for uniqueness within tenant
|
|
queryset = OperatingRoom.objects.filter(room_number=room_number)
|
|
if self.instance.pk:
|
|
queryset = queryset.exclude(pk=self.instance.pk)
|
|
|
|
if queryset.exists():
|
|
raise ValidationError('Room number must be unique.')
|
|
|
|
return room_number
|
|
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
status = cleaned_data.get('status')
|
|
is_active = cleaned_data.get('is_active')
|
|
|
|
# Validate status and active state consistency
|
|
if not is_active and status not in ['OUT_OF_SERVICE', 'MAINTENANCE']:
|
|
raise ValidationError(
|
|
'Inactive rooms must have status "Out of Service" or "Maintenance".'
|
|
)
|
|
|
|
return cleaned_data
|
|
|
|
|
|
# ============================================================================
|
|
# 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
|
|
|