1769 lines
62 KiB
Python
1769 lines
62 KiB
Python
"""
|
||
Operating Theatre app views with healthcare-focused CRUD operations.
|
||
Implements appropriate access patterns for surgical and OR management workflows.
|
||
"""
|
||
|
||
from django.shortcuts import render, get_object_or_404, redirect
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||
from django.views import View
|
||
from django.views.generic import (
|
||
ListView, DetailView, CreateView, UpdateView, DeleteView, TemplateView
|
||
)
|
||
from django.db.models import Q, Count, Avg, F, DurationField, ExpressionWrapper
|
||
from django.contrib import messages
|
||
from django.urls import reverse_lazy, reverse
|
||
from django.core.paginator import Paginator
|
||
from django.template.loader import render_to_string
|
||
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
||
from datetime import datetime, timedelta, date
|
||
import json
|
||
from django.views.decorators.http import require_POST, require_GET, require_http_methods
|
||
from django.utils import timezone
|
||
from django.utils.dateparse import parse_datetime, parse_date, parse_time
|
||
from core.utils import AuditLogger
|
||
from .models import *
|
||
from .forms import *
|
||
|
||
|
||
class OperatingTheatreDashboardView(LoginRequiredMixin, TemplateView):
|
||
template_name = 'operating_theatre/dashboard.html'
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
tenant = self.request.user.tenant
|
||
today = timezone.now().date()
|
||
|
||
# --- Stats ---
|
||
context.update({
|
||
'total_rooms': OperatingRoom.objects.filter(
|
||
tenant=tenant, is_active=True
|
||
).count(),
|
||
|
||
'rooms_available': OperatingRoom.objects.filter(
|
||
tenant=tenant, is_active=True, status='AVAILABLE'
|
||
).count(),
|
||
|
||
'rooms_in_use': OperatingRoom.objects.filter(
|
||
tenant=tenant, is_active=True, status='OCCUPIED' # was IN_USE
|
||
).count(),
|
||
|
||
'rooms_maintenance': OperatingRoom.objects.filter(
|
||
tenant=tenant, is_active=True, status='MAINTENANCE'
|
||
).count(),
|
||
|
||
'cases_today': SurgicalCase.objects.filter(
|
||
or_block__operating_room__tenant=tenant, # was admission__tenant
|
||
scheduled_start__date=today
|
||
).count(),
|
||
|
||
'cases_in_progress': SurgicalCase.objects.filter(
|
||
or_block__operating_room__tenant=tenant, # was admission__tenant
|
||
status='IN_PROGRESS'
|
||
).count(),
|
||
|
||
'cases_completed_today': SurgicalCase.objects.filter(
|
||
or_block__operating_room__tenant=tenant, # was admission__tenant
|
||
actual_end__date=today,
|
||
status='COMPLETED'
|
||
).count(),
|
||
|
||
'emergency_cases_today': SurgicalCase.objects.filter(
|
||
or_block__operating_room__tenant=tenant, # was admission__tenant
|
||
scheduled_start__date=today,
|
||
case_type='EMERGENCY'
|
||
).count(),
|
||
|
||
'blocks_today': ORBlock.objects.filter(
|
||
operating_room__tenant=tenant,
|
||
date=today
|
||
).count(),
|
||
|
||
'equipment_in_use': EquipmentUsage.objects.filter(
|
||
surgical_case__or_block__operating_room__tenant=tenant
|
||
# add a real "in use" condition if you have one (e.g., end_time__isnull=True)
|
||
).count(),
|
||
|
||
'notes_pending': SurgicalNote.objects.filter(
|
||
surgical_case__or_block__operating_room__tenant=tenant,
|
||
status='DRAFT'
|
||
).count(),
|
||
})
|
||
|
||
# --- Recent cases ---
|
||
context['recent_cases'] = (
|
||
SurgicalCase.objects
|
||
.filter(or_block__operating_room__tenant=tenant)
|
||
.select_related('patient', 'primary_surgeon', 'or_block', 'or_block__operating_room')
|
||
.order_by('-scheduled_start')[:10]
|
||
)
|
||
|
||
# --- Today’s schedule ---
|
||
context['todays_schedule'] = (
|
||
SurgicalCase.objects
|
||
.filter(or_block__operating_room__tenant=tenant, scheduled_start__date=today)
|
||
.select_related('patient', 'primary_surgeon', 'or_block', 'or_block__operating_room')
|
||
.order_by('scheduled_start')
|
||
)
|
||
|
||
# --- Room utilization (cases today per room) ---
|
||
context['room_utilization'] = (
|
||
OperatingRoom.objects
|
||
.filter(tenant=tenant, is_active=True)
|
||
.annotate(
|
||
cases_today=Count(
|
||
'or_blocks__surgical_cases',
|
||
filter=Q(or_blocks__surgical_cases__scheduled_start__date=today)
|
||
)
|
||
)
|
||
.order_by('room_number')
|
||
)
|
||
|
||
return context
|
||
|
||
|
||
class OperatingRoomListView(LoginRequiredMixin, ListView):
|
||
"""
|
||
List all operating rooms with filtering and search.
|
||
"""
|
||
model = OperatingRoom
|
||
template_name = 'operating_theatre/rooms/operating_room_list.html'
|
||
context_object_name = 'operating_rooms'
|
||
paginate_by = 25
|
||
|
||
def get_queryset(self):
|
||
queryset = OperatingRoom.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
# Search functionality
|
||
search = self.request.GET.get('search')
|
||
if search:
|
||
queryset = queryset.filter(
|
||
Q(room_number__icontains=search) |
|
||
Q(room_name__icontains=search) |
|
||
Q(location__icontains=search)
|
||
)
|
||
|
||
# Filter by room type
|
||
room_type = self.request.GET.get('room_type')
|
||
if room_type:
|
||
queryset = queryset.filter(room_type=room_type)
|
||
|
||
# Filter by status
|
||
status = self.request.GET.get('status')
|
||
if status:
|
||
queryset = queryset.filter(status=status)
|
||
|
||
# Filter by floor
|
||
floor = self.request.GET.get('floor')
|
||
if floor:
|
||
queryset = queryset.filter(floor=floor)
|
||
|
||
# Filter by active status
|
||
active_only = self.request.GET.get('active_only')
|
||
if active_only:
|
||
queryset = queryset.filter(is_active=True)
|
||
|
||
return queryset.order_by('room_number')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context.update({
|
||
'room_types': OperatingRoom._meta.get_field('room_type').choices,
|
||
'statuses': OperatingRoom._meta.get_field('status').choices,
|
||
'search_query': self.request.GET.get('search', ''),
|
||
})
|
||
return context
|
||
|
||
|
||
class OperatingRoomDetailView(LoginRequiredMixin, DetailView):
|
||
"""
|
||
Display detailed information about an operating room.
|
||
"""
|
||
model = OperatingRoom
|
||
template_name = 'operating_theatre/rooms/operating_room_detail.html'
|
||
context_object_name = 'operating_room'
|
||
|
||
def get_queryset(self):
|
||
# tenant scoping
|
||
return OperatingRoom.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
operating_room = self.object
|
||
today = timezone.now().date()
|
||
|
||
# Base QS for cases in this room
|
||
room_cases = (
|
||
SurgicalCase.objects
|
||
.filter(or_block__operating_room=operating_room)
|
||
.select_related('patient', 'primary_surgeon', 'or_block', 'or_block__operating_room')
|
||
)
|
||
|
||
# Today's cases
|
||
context['todays_cases'] = (
|
||
room_cases
|
||
.filter(scheduled_start__date=today)
|
||
.order_by('scheduled_start')
|
||
)
|
||
|
||
# Recent cases
|
||
context['recent_cases'] = room_cases.order_by('-scheduled_start')[:10]
|
||
|
||
# Equipment usage for this room (scope via case -> block -> room)
|
||
context['equipment_usage'] = (
|
||
EquipmentUsage.objects
|
||
.filter(surgical_case__or_block__operating_room=operating_room)
|
||
.select_related('surgical_case')
|
||
.order_by('-start_time')[:10]
|
||
)
|
||
|
||
# Average duration: (actual_end - actual_start) over completed cases
|
||
completed_cases = room_cases.filter(actual_start__isnull=False, actual_end__isnull=False)
|
||
duration_expr = ExpressionWrapper(
|
||
F('actual_end') - F('actual_start'),
|
||
output_field=DurationField()
|
||
)
|
||
avg_duration = completed_cases.aggregate(avg_duration=Avg(duration_expr))['avg_duration']
|
||
|
||
# Room statistics
|
||
now = timezone.now()
|
||
context['room_stats'] = {
|
||
'total_cases': room_cases.count(),
|
||
'cases_this_month': room_cases.filter(
|
||
scheduled_start__year=now.year,
|
||
scheduled_start__month=now.month
|
||
).count(),
|
||
'average_case_duration': avg_duration, # a datetime.timedelta or None
|
||
}
|
||
|
||
return context
|
||
|
||
|
||
class OperatingRoomCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||
"""
|
||
Create a new operating room (fields matched to the form/template).
|
||
"""
|
||
model = OperatingRoom
|
||
form_class = OperatingRoomForm
|
||
template_name = 'operating_theatre/rooms/operating_room_form.html'
|
||
permission_required = 'operating_theatre.add_operatingroom'
|
||
success_url = reverse_lazy('operating_theatre:operating_room_list')
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super().get_form_kwargs()
|
||
kwargs['tenant'] = self.request.user.tenant
|
||
return kwargs
|
||
|
||
def post(self, request, *args, **kwargs):
|
||
"""
|
||
Support 'Save as Draft' AJAX click from the template.
|
||
We return a JSON success without persisting anything (no draft model).
|
||
If you later want true draft saving, add a Draft model or a flag.
|
||
"""
|
||
if request.POST.get('save_draft') == 'true':
|
||
return JsonResponse({'success': True})
|
||
return super().post(request, *args, **kwargs)
|
||
|
||
def form_valid(self, form):
|
||
"""
|
||
Attach tenant (and created_by if desired) then save.
|
||
"""
|
||
form.instance.tenant = self.request.user.tenant
|
||
if hasattr(form.instance, 'created_by') and not form.instance.created_by_id:
|
||
form.instance.created_by = self.request.user
|
||
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
try:
|
||
AuditLogger.log_event(
|
||
user=self.request.user,
|
||
action='OPERATING_ROOM_CREATED',
|
||
model='OperatingRoom',
|
||
object_id=str(self.object.pk),
|
||
details={
|
||
'room_number': self.object.room_number,
|
||
'room_name': self.object.room_name,
|
||
'room_type': self.object.room_type,
|
||
},
|
||
)
|
||
except Exception:
|
||
# keep UX smooth even if audit logging fails
|
||
pass
|
||
|
||
messages.success(self.request, f'Operating room "{self.object.room_number}" created successfully.')
|
||
return response
|
||
|
||
|
||
class OperatingRoomUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||
"""
|
||
Update an existing operating room.
|
||
"""
|
||
model = OperatingRoom
|
||
form_class = OperatingRoomForm
|
||
template_name = 'operating_theatre/rooms/operating_room_form.html'
|
||
permission_required = 'operating_theatre.change_operatingroom'
|
||
|
||
def get_queryset(self):
|
||
return OperatingRoom.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def get_success_url(self):
|
||
return reverse('operating_theatre:operating_room_detail', kwargs={'pk': self.object.pk})
|
||
|
||
def form_valid(self, form):
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_action(
|
||
user=self.request.user,
|
||
action='OPERATING_ROOM_UPDATED',
|
||
model='OperatingRoom',
|
||
object_id=str(self.object.id),
|
||
details={
|
||
'room_number': self.object.room_number,
|
||
'changes': form.changed_data
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, f'Operating room "{self.object.room_number}" updated successfully.')
|
||
return response
|
||
|
||
|
||
class OperatingRoomDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||
"""
|
||
Delete an operating room (soft delete by deactivating).
|
||
"""
|
||
model = OperatingRoom
|
||
template_name = 'operating_theatre/rooms/operating_room_confirm_delete.html'
|
||
permission_required = 'operating_theatre.delete_operatingroom'
|
||
success_url = reverse_lazy('operating_theatre:operating_room_list')
|
||
|
||
def get_queryset(self):
|
||
return OperatingRoom.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def delete(self, request, *args, **kwargs):
|
||
self.object = self.get_object()
|
||
|
||
# Check if room has active cases
|
||
active_cases = self.object.surgical_cases.filter(
|
||
status__in=['SCHEDULED', 'IN_PROGRESS']
|
||
).count()
|
||
|
||
if active_cases > 0:
|
||
messages.error(
|
||
request,
|
||
f'Cannot deactivate room "{self.object.room_number}" - it has {active_cases} active cases.'
|
||
)
|
||
return redirect('operating_theatre:operating_room_detail', pk=self.object.pk)
|
||
|
||
# Soft delete by deactivating
|
||
self.object.is_active = False
|
||
self.object.status = 'OUT_OF_SERVICE'
|
||
self.object.save()
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=request.user,
|
||
action='OPERATING_ROOM_DEACTIVATED',
|
||
model='OperatingRoom',
|
||
object_id=str(self.object.id),
|
||
details={'room_number': self.object.room_number}
|
||
)
|
||
|
||
messages.success(request, f'Operating room "{self.object.room_number}" deactivated successfully.')
|
||
return redirect(self.success_url)
|
||
|
||
|
||
class SurgicalNoteTemplateListView(LoginRequiredMixin, ListView):
|
||
"""
|
||
List all surgical note templates with filtering and search.
|
||
"""
|
||
model = SurgicalNoteTemplate
|
||
template_name = 'operating_theatre/templates/surgical_note_template_list.html'
|
||
context_object_name = 'surgical_note_templates'
|
||
paginate_by = 25
|
||
|
||
def get_queryset(self):
|
||
queryset = SurgicalNoteTemplate.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
# Search functionality
|
||
search = self.request.GET.get('search')
|
||
if search:
|
||
queryset = queryset.filter(
|
||
Q(template_name__icontains=search) |
|
||
Q(procedure_type__icontains=search) |
|
||
Q(specialty__icontains=search)
|
||
)
|
||
|
||
# Filter by specialty
|
||
specialty = self.request.GET.get('specialty')
|
||
if specialty:
|
||
queryset = queryset.filter(specialty=specialty)
|
||
|
||
# Filter by active status
|
||
active_only = self.request.GET.get('active_only')
|
||
if active_only:
|
||
queryset = queryset.filter(is_active=True)
|
||
|
||
return queryset.order_by('name')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context.update({
|
||
'specialties': SurgicalNoteTemplate._meta.get_field('specialty').choices,
|
||
'search_query': self.request.GET.get('search', ''),
|
||
})
|
||
return context
|
||
|
||
|
||
class SurgicalNoteTemplateDetailView(LoginRequiredMixin, DetailView):
|
||
"""
|
||
Display detailed information about a surgical note template.
|
||
"""
|
||
model = SurgicalNoteTemplate
|
||
template_name = 'operating_theatre/templates/surgical_note_template_detail.html'
|
||
context_object_name = 'surgical_note_template'
|
||
|
||
def get_queryset(self):
|
||
return SurgicalNoteTemplate.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
template = self.object
|
||
|
||
# Get recent notes using this template
|
||
context['recent_notes'] = SurgicalNote.objects.filter(
|
||
template=template,
|
||
tenant=self.request.user.tenant
|
||
).select_related('surgical_case__patient').order_by('-created_at')[:10]
|
||
|
||
return context
|
||
|
||
|
||
class SurgicalNoteTemplateCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||
"""
|
||
Create a new surgical note template.
|
||
"""
|
||
model = SurgicalNoteTemplate
|
||
form_class = SurgicalNoteTemplateForm
|
||
template_name = 'operating_theatre/templates/surgical_note_template_form.html'
|
||
permission_required = 'operating_theatre.add_surgicalnotetemplate'
|
||
success_url = reverse_lazy('operating_theatre:surgical_note_template_list')
|
||
|
||
def form_valid(self, form):
|
||
form.instance.tenant = self.request.user.tenant
|
||
form.instance.created_by = self.request.user
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=self.request.user,
|
||
action='SURGICAL_NOTE_TEMPLATE_CREATED',
|
||
model='SurgicalNoteTemplate',
|
||
object_id=str(self.object.id),
|
||
details={
|
||
'template_name': self.object.template_name,
|
||
'specialty': self.object.specialty
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, f'Surgical note template "{self.object.template_name}" created successfully.')
|
||
return response
|
||
|
||
|
||
class SurgicalNoteTemplateUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||
"""
|
||
Update an existing surgical note template.
|
||
"""
|
||
model = SurgicalNoteTemplate
|
||
form_class = SurgicalNoteTemplateForm
|
||
template_name = 'operating_theatre/templates/surgical_note_template_form.html'
|
||
permission_required = 'operating_theatre.change_surgicalnotetemplate'
|
||
|
||
def get_queryset(self):
|
||
return SurgicalNoteTemplate.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def get_success_url(self):
|
||
return reverse('operating_theatre:surgical_note_template_detail', kwargs={'pk': self.object.pk})
|
||
|
||
def form_valid(self, form):
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=self.request.user,
|
||
action='SURGICAL_NOTE_TEMPLATE_UPDATED',
|
||
model='SurgicalNoteTemplate',
|
||
object_id=str(self.object.id),
|
||
details={
|
||
'template_name': self.object.template_name,
|
||
'changes': form.changed_data
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, f'Surgical note template "{self.object.template_name}" updated successfully.')
|
||
return response
|
||
|
||
|
||
class SurgicalNoteTemplateDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||
"""
|
||
Delete a surgical note template (soft delete by deactivating).
|
||
"""
|
||
model = SurgicalNoteTemplate
|
||
template_name = 'operating_theatre/templates/surgical_note_template_confirm_delete.html'
|
||
permission_required = 'operating_theatre.delete_surgicalnotetemplate'
|
||
success_url = reverse_lazy('operating_theatre:surgical_note_template_list')
|
||
|
||
def get_queryset(self):
|
||
return SurgicalNoteTemplate.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def delete(self, request, *args, **kwargs):
|
||
self.object = self.get_object()
|
||
|
||
# Soft delete by deactivating
|
||
self.object.is_active = False
|
||
self.object.save()
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=request.user,
|
||
action='SURGICAL_NOTE_TEMPLATE_DEACTIVATED',
|
||
model='SurgicalNoteTemplate',
|
||
object_id=str(self.object.id),
|
||
details={'template_name': self.object.template_name}
|
||
)
|
||
|
||
messages.success(request, f'Surgical note template "{self.object.template_name}" deactivated successfully.')
|
||
return redirect(self.success_url)
|
||
|
||
|
||
class ORBlockListView(LoginRequiredMixin, ListView):
|
||
"""
|
||
List all OR blocks with filtering and search.
|
||
"""
|
||
model = ORBlock
|
||
template_name = 'operating_theatre/blocks/block_list.html'
|
||
context_object_name = 'blocks'
|
||
paginate_by = 25
|
||
|
||
def get_queryset(self):
|
||
queryset = ORBlock.objects.filter(operating_room__tenant=self.request.user.tenant)
|
||
|
||
# Filter by date range
|
||
date_from = self.request.GET.get('date_from')
|
||
date_to = self.request.GET.get('date_to')
|
||
if date_from:
|
||
queryset = queryset.filter(date__gte=date_from)
|
||
if date_to:
|
||
queryset = queryset.filter(date__lte=date_to)
|
||
|
||
# Filter by surgeon
|
||
surgeon_id = self.request.GET.get('surgeon')
|
||
if surgeon_id:
|
||
queryset = queryset.filter(surgeon_id=surgeon_id)
|
||
|
||
# Filter by operating room
|
||
room_id = self.request.GET.get('room')
|
||
if room_id:
|
||
queryset = queryset.filter(operating_room_id=room_id)
|
||
|
||
return queryset.select_related(
|
||
'operating_room', 'primary_surgeon',
|
||
).order_by('-date', 'start_time')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context.update({
|
||
'operating_rooms': OperatingRoom.objects.filter(
|
||
tenant=self.request.user.tenant,
|
||
is_active=True
|
||
).order_by('room_number'),
|
||
})
|
||
return context
|
||
|
||
|
||
class ORBlockDetailView(LoginRequiredMixin, DetailView):
|
||
"""
|
||
Display detailed information about an OR block.
|
||
"""
|
||
model = ORBlock
|
||
template_name = 'operating_theatre/blocks/block_detail.html'
|
||
# context_object_name = 'block'
|
||
|
||
def get_queryset(self):
|
||
return ORBlock.objects.filter(operating_room__tenant=self.request.user.tenant)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
or_block = self.object
|
||
|
||
# Get cases scheduled in this block
|
||
scheduled_cases = SurgicalCase.objects.filter(
|
||
or_block__operating_room=or_block.operating_room,
|
||
scheduled_start__date=or_block.date,
|
||
scheduled_start__time__gte=or_block.start_time,
|
||
scheduled_start__time__lt=or_block.end_time,
|
||
or_block__operating_room__tenant=self.request.user.tenant
|
||
).select_related('patient', 'primary_surgeon').order_by('scheduled_start')
|
||
context['scheduled_cases'] = scheduled_cases
|
||
# Calculate utilization
|
||
total_block_minutes = (
|
||
datetime.combine(timezone.now().date(), or_block.end_time) -
|
||
datetime.combine(timezone.now().date(), or_block.start_time)
|
||
).total_seconds() / 60
|
||
|
||
used_minutes = 0
|
||
for case in scheduled_cases:
|
||
if case.estimated_duration:
|
||
used_minutes += case.estimated_duration
|
||
|
||
context['utilization_percentage'] = (
|
||
(used_minutes / total_block_minutes) * 100 if total_block_minutes > 0 else 0
|
||
)
|
||
context['total_block_minutes'] = total_block_minutes
|
||
return context
|
||
|
||
|
||
class ORBlockCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||
"""
|
||
Create a new OR block.
|
||
"""
|
||
model = ORBlock
|
||
form_class = ORBlockForm
|
||
template_name = 'operating_theatre/blocks/block_form.html'
|
||
permission_required = 'operating_theatre.add_orblock'
|
||
success_url = reverse_lazy('operating_theatre:or_block_list')
|
||
|
||
def form_valid(self, form):
|
||
form.instance.tenant = self.request.user.tenant
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=self.request.user,
|
||
action='OR_BLOCK_CREATED',
|
||
model='ORBlock',
|
||
object_id=str(self.object.id),
|
||
details={
|
||
'date': str(self.object.date),
|
||
'operating_room': self.object.operating_room.room_number,
|
||
'surgeon': f"{self.object.surgeon.first_name} {self.object.surgeon.last_name}"
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, 'OR block created successfully.')
|
||
return response
|
||
|
||
|
||
class ORBlockUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||
"""
|
||
Update an OR block (limited to notes and time adjustments).
|
||
"""
|
||
model = ORBlock
|
||
fields = ['start_time', 'end_time', 'notes'] # Restricted fields
|
||
template_name = 'operating_theatre/or_block_form.html'
|
||
permission_required = 'operating_theatre.change_orblock'
|
||
|
||
def get_queryset(self):
|
||
return ORBlock.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def get_success_url(self):
|
||
return reverse('operating_theatre:or_block_detail', kwargs={'pk': self.object.pk})
|
||
|
||
def form_valid(self, form):
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_action(
|
||
user=self.request.user,
|
||
action='OR_BLOCK_UPDATED',
|
||
model='ORBlock',
|
||
object_id=str(self.object.id),
|
||
details={
|
||
'date': str(self.object.date),
|
||
'changes': form.changed_data
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, 'OR block updated successfully.')
|
||
return response
|
||
|
||
|
||
class SurgicalCaseListView(LoginRequiredMixin, ListView):
|
||
"""
|
||
List all surgical cases with filtering and search.
|
||
"""
|
||
model = SurgicalCase
|
||
template_name = 'operating_theatre/cases/surgical_case_list.html'
|
||
context_object_name = 'surgical_cases'
|
||
paginate_by = 25
|
||
|
||
def get_queryset(self):
|
||
tenant = self.request.user.tenant
|
||
qs = SurgicalCase.objects.filter(admission__tenant=tenant)
|
||
|
||
# Search
|
||
search = self.request.GET.get('search', '').strip()
|
||
if search:
|
||
# You can keep mrn fields if PatientProfile has them
|
||
name_q = Q(patient__first_name__icontains=search) | Q(patient__last_name__icontains=search)
|
||
mrn_q = Q(patient__mrn__icontains=search)
|
||
proc_q = Q(primary_procedure__icontains=search)
|
||
qs = qs.filter(name_q | mrn_q | proc_q)
|
||
|
||
# Status
|
||
status = self.request.GET.get('status')
|
||
if status:
|
||
qs = qs.filter(status=status)
|
||
|
||
# Case type (your "priority" select)
|
||
case_type = self.request.GET.get('priority')
|
||
if case_type:
|
||
qs = qs.filter(case_type=case_type)
|
||
|
||
# Surgeon
|
||
surgeon_id = self.request.GET.get('surgeon')
|
||
if surgeon_id:
|
||
qs = qs.filter(primary_surgeon_id=surgeon_id)
|
||
|
||
# Room (goes through ORBlock -> OperatingRoom)
|
||
room_id = self.request.GET.get('room')
|
||
if room_id:
|
||
qs = qs.filter(or_block__operating_room_id=room_id)
|
||
|
||
# Date range (scheduled_start is a DateTimeField)
|
||
date_from = self.request.GET.get('date_from')
|
||
date_to = self.request.GET.get('date_to')
|
||
if date_from:
|
||
qs = qs.filter(scheduled_start__date__gte=date_from)
|
||
if date_to:
|
||
qs = qs.filter(scheduled_start__date__lte=date_to)
|
||
|
||
return qs.select_related('patient', 'primary_surgeon', 'or_block__operating_room').order_by('-scheduled_start')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
context = super().get_context_data(**kwargs)
|
||
tenant = self.request.user.tenant
|
||
|
||
context.update({
|
||
'statuses': SurgicalCase.CaseStatus.choices,
|
||
'case_types': SurgicalCase.CaseType.choices, # renamed for clarity
|
||
'operating_rooms': OperatingRoom.objects.filter(tenant=tenant, is_active=True).order_by('room_number'),
|
||
'surgeons': User.objects.filter(tenant=tenant, is_active=True).order_by('first_name', 'last_name'),
|
||
})
|
||
return context
|
||
|
||
|
||
class SurgicalCaseDetailView(LoginRequiredMixin, DetailView):
|
||
"""
|
||
Display detailed information about a surgical case.
|
||
"""
|
||
model = SurgicalCase
|
||
template_name = 'operating_theatre/cases/surgical_case_detail.html'
|
||
context_object_name = 'surgical_case'
|
||
|
||
def get_queryset(self):
|
||
return SurgicalCase.objects.filter(or_block__operating_room__tenant=self.request.user.tenant)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
surgical_case = self.object
|
||
|
||
# Get surgical notes for this case
|
||
# context['surgical_notes'] = surgical_case.surgical_notes
|
||
|
||
# Get equipment usage for this case
|
||
context['equipment_usage'] = EquipmentUsage.objects.filter(
|
||
surgical_case=surgical_case,
|
||
surgical_case__or_block__operating_room__tenant=self.request.user.tenant
|
||
).order_by('-start_time')
|
||
|
||
# Calculate actual duration if case is completed
|
||
if surgical_case.actual_start and surgical_case.actual_end:
|
||
context['actual_duration'] = surgical_case.actual_end - surgical_case.actual_start
|
||
|
||
return context
|
||
|
||
|
||
class SurgicalCaseCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||
"""
|
||
Create a new surgical case.
|
||
"""
|
||
model = SurgicalCase
|
||
form_class = SurgicalCaseForm
|
||
template_name = 'operating_theatre/cases/surgical_case_form.html'
|
||
permission_required = 'operating_theatre.add_surgicalcase'
|
||
success_url = reverse_lazy('operating_theatre:surgical_case_list')
|
||
|
||
def form_valid(self, form):
|
||
form.instance.tenant = self.request.user.tenant
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_action(
|
||
user=self.request.user,
|
||
action='SURGICAL_CASE_CREATED',
|
||
model='SurgicalCase',
|
||
object_id=str(self.object.case_id),
|
||
details={
|
||
'patient_name': f"{self.object.patient.first_name} {self.object.patient.last_name}",
|
||
'procedure_name': self.object.procedure_name,
|
||
'priority': self.object.priority
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, 'Surgical case created successfully.')
|
||
return response
|
||
|
||
|
||
class SurgicalCaseUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||
"""
|
||
Update surgical case (limited to status and notes after surgery starts).
|
||
"""
|
||
model = SurgicalCase
|
||
template_name = 'operating_theatre/cases/surgical_case_form.html'
|
||
permission_required = 'operating_theatre.change_surgicalcase'
|
||
|
||
def get_queryset(self):
|
||
return SurgicalCase.objects.filter(or_block__operating_room__tenant=self.request.user.tenant)
|
||
|
||
def get_form_class(self):
|
||
# Limit fields based on case status
|
||
if self.object.status in ['IN_PROGRESS', 'COMPLETED']:
|
||
# Limited fields for cases that have started
|
||
class RestrictedSurgicalCaseForm(SurgicalCaseForm):
|
||
class Meta(SurgicalCaseForm.Meta):
|
||
fields = ['status', 'complications']
|
||
return RestrictedSurgicalCaseForm
|
||
else:
|
||
return SurgicalCaseForm
|
||
|
||
def get_success_url(self):
|
||
return reverse('operating_theatre:surgical_case_detail', kwargs={'pk': self.object.pk})
|
||
|
||
def form_valid(self, form):
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=self.request.user,
|
||
action='SURGICAL_CASE_UPDATED',
|
||
model='SurgicalCase',
|
||
object_id=str(self.object.case_id),
|
||
details={
|
||
'patient_name': f"{self.object.patient.first_name} {self.object.patient.last_name}",
|
||
'changes': form.changed_data
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, 'Surgical case updated successfully.')
|
||
return response
|
||
|
||
|
||
class SurgicalNoteListView(LoginRequiredMixin, ListView):
|
||
"""
|
||
List all surgical notes with filtering and search.
|
||
"""
|
||
model = SurgicalNote
|
||
template_name = 'operating_theatre/notes/surgical_note_list.html'
|
||
context_object_name = 'notes'
|
||
paginate_by = 25
|
||
|
||
def get_queryset(self):
|
||
queryset = SurgicalNote.objects.filter(surgeon__tenant=self.request.user.tenant)
|
||
|
||
# Search functionality
|
||
search = self.request.GET.get('search')
|
||
if search:
|
||
queryset = queryset.filter(
|
||
Q(surgical_case__patient__first_name__icontains=search) |
|
||
Q(surgical_case__patient__last_name__icontains=search) |
|
||
Q(surgical_case__patient__mrn__icontains=search) |
|
||
Q(surgical_case__procedure_name__icontains=search) |
|
||
Q(note_content__icontains=search)
|
||
)
|
||
|
||
# Filter by note type
|
||
note_type = self.request.GET.get('note_type')
|
||
if note_type:
|
||
queryset = queryset.filter(note_type=note_type)
|
||
|
||
# Filter by status
|
||
status = self.request.GET.get('status')
|
||
if status:
|
||
queryset = queryset.filter(status=status)
|
||
|
||
# Filter by surgeon
|
||
surgeon_id = self.request.GET.get('surgeon')
|
||
if surgeon_id:
|
||
queryset = queryset.filter(surgeon_id=surgeon_id)
|
||
|
||
return queryset.select_related(
|
||
'surgical_case__patient', 'surgeon', 'template_used'
|
||
).order_by('-created_at')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context.update({
|
||
# 'note_types': SurgicalNote._meta.get_field('note_type').choices,
|
||
'statuses': SurgicalNote.NoteStatus.choices,
|
||
})
|
||
return context
|
||
|
||
|
||
class SurgicalNoteDetailView(LoginRequiredMixin, DetailView):
|
||
"""
|
||
Display detailed information about a surgical note.
|
||
"""
|
||
model = SurgicalNote
|
||
template_name = 'operating_theatre/notes/surgical_note_detail.html'
|
||
context_object_name = 'note'
|
||
|
||
def get_queryset(self):
|
||
return SurgicalNote.objects.filter(surgeon__tenant=self.request.user.tenant)
|
||
|
||
|
||
class SurgicalNoteCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||
"""
|
||
Create a new surgical note.
|
||
"""
|
||
model = SurgicalNote
|
||
form_class = SurgicalNoteForm
|
||
template_name = 'operating_theatre/notes/surgical_note_form.html'
|
||
permission_required = 'operating_theatre.add_surgicalnote'
|
||
success_url = reverse_lazy('operating_theatre:surgical_note_list')
|
||
|
||
def form_valid(self, form):
|
||
form.instance.tenant = self.request.user.tenant
|
||
form.instance.surgeon = self.request.user
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=self.request.user,
|
||
action='SURGICAL_NOTE_CREATED',
|
||
model='SurgicalNote',
|
||
object_id=str(self.object.note_id),
|
||
details={
|
||
'patient_name': f"{self.object.surgical_case.patient.first_name} {self.object.surgical_case.patient.last_name}",
|
||
'note_type': self.object.note_type
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, 'Surgical note created successfully.')
|
||
return response
|
||
|
||
|
||
class EquipmentUsageListView(LoginRequiredMixin, ListView):
|
||
"""
|
||
List all equipment usage records with filtering and search.
|
||
"""
|
||
model = EquipmentUsage
|
||
template_name = 'operating_theatre/equipment/equipment_list.html'
|
||
context_object_name = 'equipment_list'
|
||
paginate_by = 25
|
||
|
||
def get_queryset(self):
|
||
queryset = EquipmentUsage.objects.filter(surgical_case__admission__tenant=self.request.user.tenant)
|
||
|
||
# Filter by equipment type
|
||
equipment_type = self.request.GET.get('equipment_type')
|
||
if equipment_type:
|
||
queryset = queryset.filter(equipment_type=equipment_type)
|
||
|
||
# Filter by status
|
||
status = self.request.GET.get('status')
|
||
if status:
|
||
queryset = queryset.filter(status=status)
|
||
|
||
# Filter by operating room
|
||
room_id = self.request.GET.get('room')
|
||
if room_id:
|
||
queryset = queryset.filter(operating_room_id=room_id)
|
||
|
||
# Filter by date range
|
||
date_from = self.request.GET.get('date_from')
|
||
date_to = self.request.GET.get('date_to')
|
||
if date_from:
|
||
queryset = queryset.filter(start_time__date__gte=date_from)
|
||
if date_to:
|
||
queryset = queryset.filter(start_time__date__lte=date_to)
|
||
|
||
return queryset.select_related(
|
||
'surgical_case__or_block__operating_room', 'surgical_case__patient'
|
||
).order_by('-start_time')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context.update({
|
||
'equipment_types': EquipmentUsage.EquipmentType.choices,
|
||
# 'statuses': EquipmentUsage._meta.get_field('status').choices,
|
||
'operating_rooms': OperatingRoom.objects.filter(
|
||
tenant=self.request.user.tenant,
|
||
is_active=True
|
||
).order_by('room_number'),
|
||
})
|
||
return context
|
||
|
||
|
||
class EquipmentUsageDetailView(LoginRequiredMixin, DetailView):
|
||
"""
|
||
Display detailed information about equipment usage.
|
||
"""
|
||
model = EquipmentUsage
|
||
template_name = 'operating_theatre/equipment/equipment_detail.html'
|
||
context_object_name = 'equipment'
|
||
|
||
def get_queryset(self):
|
||
tenant = self.request.user.tenant
|
||
return EquipmentUsage.objects.filter(surgical_case__patient__tenant=tenant)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
equipment_usage = self.object
|
||
|
||
# Calculate usage duration if ended
|
||
if equipment_usage.end_time:
|
||
context['usage_duration'] = equipment_usage.end_time - equipment_usage.start_time
|
||
|
||
return context
|
||
|
||
|
||
class EquipmentUsageCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||
"""
|
||
Create a new equipment usage record.
|
||
"""
|
||
model = EquipmentUsage
|
||
form_class = EquipmentUsageForm
|
||
template_name = 'operating_theatre/equipment/equipment_form.html'
|
||
permission_required = 'operating_theatre.add_equipmentusage'
|
||
success_url = reverse_lazy('operating_theatre:equipment_usage_list')
|
||
|
||
def form_valid(self, form):
|
||
form.instance.tenant = self.request.user.tenant
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_action(
|
||
user=self.request.user,
|
||
action='EQUIPMENT_USAGE_CREATED',
|
||
model='EquipmentUsage',
|
||
object_id=str(self.object.id),
|
||
details={
|
||
'equipment_name': self.object.equipment_name,
|
||
'equipment_type': self.object.equipment_type,
|
||
'operating_room': self.object.operating_room.room_number
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, 'Equipment usage record created successfully.')
|
||
return response
|
||
|
||
|
||
class EquipmentUsageUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||
"""
|
||
Update equipment usage record (limited to status and end time).
|
||
"""
|
||
model = EquipmentUsage
|
||
fields = ['status', 'end_time', 'notes'] # Restricted fields
|
||
template_name = 'operating_theatre/equipment/equipment_form.html'
|
||
permission_required = 'operating_theatre.change_equipmentusage'
|
||
|
||
def get_queryset(self):
|
||
return EquipmentUsage.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def get_success_url(self):
|
||
return reverse('operating_theatre:equipment_usage_detail', kwargs={'pk': self.object.pk})
|
||
|
||
def form_valid(self, form):
|
||
response = super().form_valid(form)
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=self.request.user,
|
||
action='EQUIPMENT_USAGE_UPDATED',
|
||
model='EquipmentUsage',
|
||
object_id=str(self.object.id),
|
||
details={
|
||
'equipment_name': self.object.equipment_name,
|
||
'changes': form.changed_data
|
||
}
|
||
)
|
||
|
||
messages.success(self.request, 'Equipment usage record updated successfully.')
|
||
return response
|
||
|
||
|
||
class StartCaseView(LoginRequiredMixin, PermissionRequiredMixin, View):
|
||
"""
|
||
Mark a scheduled case as IN_PROGRESS and set room status to OCCUPIED.
|
||
"""
|
||
permission_required = 'operating_theatre.change_surgicalcase'
|
||
|
||
def post(self, request, pk):
|
||
case = get_object_or_404(SurgicalCase, pk=pk, admission__tenant=request.user.tenant)
|
||
if case.status != 'SCHEDULED':
|
||
messages.error(request, 'Only scheduled cases can be started.')
|
||
return redirect('operating_theatre:surgical_case_list')
|
||
|
||
case.status = 'IN_PROGRESS'
|
||
if not case.actual_start:
|
||
case.actual_start = timezone.now()
|
||
case.save()
|
||
|
||
# Set room status
|
||
if case.or_block and case.or_block.operating_room:
|
||
room = case.or_block.operating_room
|
||
room.status = 'OCCUPIED'
|
||
room.save(update_fields=['status'])
|
||
|
||
messages.success(request, f'Case {case.case_number} started.')
|
||
return redirect('operating_theatre:surgical_case_list')
|
||
|
||
|
||
class CompleteCaseView(LoginRequiredMixin, PermissionRequiredMixin, View):
|
||
"""
|
||
Mark an in-progress case as COMPLETED and set room to CLEANING.
|
||
"""
|
||
permission_required = 'operating_theatre.change_surgicalcase'
|
||
|
||
def post(self, request, pk):
|
||
case = get_object_or_404(SurgicalCase, pk=pk, admission__tenant=request.user.tenant)
|
||
if case.status != 'IN_PROGRESS':
|
||
messages.error(request, 'Only in-progress cases can be completed.')
|
||
return redirect('operating_theatre:surgical_case_list')
|
||
|
||
case.status = 'COMPLETED'
|
||
if not case.actual_end:
|
||
case.actual_end = timezone.now()
|
||
case.save()
|
||
|
||
# Set room status
|
||
if case.or_block and case.or_block.operating_room:
|
||
room = case.or_block.operating_room
|
||
room.status = 'CLEANING'
|
||
room.save(update_fields=['status'])
|
||
|
||
messages.success(request, f'Case {case.case_number} marked as completed.')
|
||
return redirect('operating_theatre:surgical_case_list')
|
||
|
||
|
||
class SurgicalNotePreviewView(LoginRequiredMixin, View):
|
||
"""
|
||
Preview a surgical note before finalizing or signing.
|
||
Allows users to review the note content in a read-only format.
|
||
"""
|
||
model = SurgicalNote
|
||
template_name = 'operating_theatre/surgical_note_preview.html'
|
||
context_object_name = 'surgical_note'
|
||
|
||
def get_queryset(self):
|
||
return SurgicalNote.objects.filter(tenant=self.request.user.tenant)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
surgical_note = self.object
|
||
|
||
# Add surgical case information
|
||
context['surgical_case'] = surgical_note.surgical_case
|
||
|
||
# Add template information if available
|
||
if surgical_note.template:
|
||
context['template'] = surgical_note.template
|
||
|
||
# Check if user can sign the note
|
||
context['can_sign'] = (
|
||
surgical_note.status == 'DRAFT' and
|
||
(surgical_note.created_by == self.request.user or
|
||
self.request.user.has_perm('operating_theatre.change_surgicalnote'))
|
||
)
|
||
|
||
# Check if user can edit the note
|
||
context['can_edit'] = (
|
||
surgical_note.status == 'DRAFT' and
|
||
surgical_note.created_by == self.request.user
|
||
)
|
||
|
||
# Add signature information
|
||
context['signatures'] = surgical_note.signatures.all().order_by('signed_at')
|
||
|
||
# Add related equipment usage
|
||
if surgical_note.surgical_case:
|
||
context['equipment_usage'] = EquipmentUsage.objects.filter(
|
||
surgical_case=surgical_note.surgical_case,
|
||
tenant=self.request.user.tenant
|
||
).order_by('start_time')
|
||
|
||
return context
|
||
|
||
|
||
@login_required
|
||
def sign_note(request, note_id):
|
||
"""
|
||
Sign a surgical note.
|
||
"""
|
||
if request.method == 'POST':
|
||
note = get_object_or_404(
|
||
SurgicalNote,
|
||
id=note_id,
|
||
tenant=request.user.tenant
|
||
)
|
||
|
||
# Only allow signing if note is in draft status
|
||
if note.status != 'DRAFT':
|
||
messages.error(request, 'Only draft notes can be signed.')
|
||
return redirect('operating_theatre:surgical_note_detail', pk=note.pk)
|
||
|
||
note.status = 'SIGNED'
|
||
note.signed_datetime = timezone.now()
|
||
note.signed_by = request.user
|
||
note.save()
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
tenant=request.user.tenant,
|
||
user=request.user,
|
||
action='SURGICAL_NOTE_SIGNED',
|
||
model='SurgicalNote',
|
||
object_id=str(note.note_id),
|
||
details={
|
||
'patient_name': f"{note.surgical_case.patient.first_name} {note.surgical_case.patient.last_name}",
|
||
'note_type': note.note_type
|
||
}
|
||
)
|
||
|
||
messages.success(request, 'Surgical note signed successfully.')
|
||
|
||
if request.headers.get('HX-Request'):
|
||
return render(request, 'operating_theatre/partials/note_status.html', {'note': note})
|
||
|
||
return redirect('operating_theatre:surgical_note_detail', pk=note.pk)
|
||
|
||
return JsonResponse({'success': False})
|
||
|
||
|
||
@login_required
|
||
def update_room_status(request, room_id):
|
||
"""
|
||
Update operating room status.
|
||
"""
|
||
if request.method == 'POST':
|
||
room = get_object_or_404(
|
||
OperatingRoom,
|
||
id=room_id,
|
||
tenant=request.user.tenant
|
||
)
|
||
|
||
new_status = request.POST.get('status')
|
||
if new_status in dict(OperatingRoom._meta.get_field('status').choices):
|
||
old_status = room.status
|
||
room.status = new_status
|
||
room.save()
|
||
|
||
# Log the action
|
||
AuditLogger.log_event(
|
||
user=request.user,
|
||
action='OPERATING_ROOM_STATUS_UPDATED',
|
||
model='OperatingRoom',
|
||
object_id=str(room.id),
|
||
details={
|
||
'room_number': room.room_number,
|
||
'old_status': old_status,
|
||
'new_status': new_status
|
||
}
|
||
)
|
||
|
||
messages.success(request, f'Room {room.room_number} status updated to {room.get_status_display()}.')
|
||
|
||
if request.headers.get('HX-Request'):
|
||
return render(request, 'operating_theatre/partials/room_status.html', {'room': room})
|
||
|
||
return redirect('operating_theatre:operating_room_detail', pk=room.pk)
|
||
|
||
return JsonResponse({'success': False})
|
||
|
||
|
||
@login_required
|
||
def case_search(request):
|
||
"""
|
||
HTMX endpoint for surgical case search.
|
||
"""
|
||
search = request.GET.get('search', '')
|
||
status = request.GET.get('status', '')
|
||
priority = request.GET.get('priority', '')
|
||
|
||
queryset = SurgicalCase.objects.filter(tenant=request.user.tenant)
|
||
|
||
if search:
|
||
queryset = queryset.filter(
|
||
Q(patient__first_name__icontains=search) |
|
||
Q(patient__last_name__icontains=search) |
|
||
Q(patient__mrn__icontains=search) |
|
||
Q(procedure_name__icontains=search)
|
||
)
|
||
|
||
if status:
|
||
queryset = queryset.filter(status=status)
|
||
|
||
if priority:
|
||
queryset = queryset.filter(priority=priority)
|
||
|
||
cases = queryset.select_related(
|
||
'patient', 'primary_surgeon', 'operating_room'
|
||
).order_by('-scheduled_start_time')[:20]
|
||
|
||
return render(request, 'operating_theatre/partials/case_list.html', {'cases': cases})
|
||
|
||
|
||
@login_required
|
||
def surgical_note_preview(request, pk):
|
||
"""
|
||
AJAX endpoint for surgical note preview modal.
|
||
Returns rendered preview content for modal display.
|
||
"""
|
||
surgical_note = get_object_or_404(
|
||
SurgicalNote,
|
||
id=pk,
|
||
tenant=request.user.tenant
|
||
)
|
||
|
||
context = {
|
||
'surgical_note': surgical_note,
|
||
'surgical_case': surgical_note.surgical_case,
|
||
'template': surgical_note.template_used,
|
||
'signatures': surgical_note.signatures.all().order_by('signed_at'),
|
||
'can_sign': (
|
||
surgical_note.status == 'DRAFT' and
|
||
(surgical_note.surgeon == request.user or
|
||
request.user.has_perm('operating_theatre.change_surgicalnote'))
|
||
),
|
||
'can_edit': (
|
||
surgical_note.status == 'DRAFT' and
|
||
surgical_note.surgeon == request.user
|
||
),
|
||
}
|
||
|
||
# Add equipment usage if available
|
||
if surgical_note.surgical_case:
|
||
context['equipment_usage'] = EquipmentUsage.objects.filter(
|
||
surgical_case=surgical_note.surgical_case,
|
||
tenant=request.user.tenant
|
||
).order_by('start_time')
|
||
|
||
return render(request, 'operating_theatre/partials/surgical_note_preview_modal.html', context)
|
||
|
||
|
||
def _parse_start(payload):
|
||
"""
|
||
Accepts either:
|
||
- payload['scheduled_start'] as ISO datetime string, or
|
||
- payload['date'] ('YYYY-MM-DD') + payload['start_time'] ('HH:MM' 24h)
|
||
Returns an aware datetime in the current timezone, or None.
|
||
"""
|
||
tz = timezone.get_current_timezone()
|
||
|
||
# Prefer unified datetime
|
||
if payload.get("scheduled_start"):
|
||
dt = parse_datetime(payload["scheduled_start"])
|
||
if dt is not None and timezone.is_naive(dt):
|
||
dt = timezone.make_aware(dt, tz)
|
||
return dt
|
||
|
||
# Fallback: date + time
|
||
d = parse_date(payload.get("date") or "")
|
||
t = parse_time(payload.get("start_time") or "")
|
||
if d and t:
|
||
naive = timezone.datetime.combine(d, t)
|
||
return timezone.make_aware(naive, tz)
|
||
return None
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def check_room_availability(request):
|
||
"""
|
||
POST JSON:
|
||
{
|
||
"room_id": <int>,
|
||
"duration": <int minutes>,
|
||
"scheduled_start": "YYYY-MM-DDTHH:MM[:SS]",
|
||
// OR:
|
||
"date": "YYYY-MM-DD",
|
||
"start_time": "HH:MM",
|
||
// Optional:
|
||
"case_id": <int>
|
||
}
|
||
"""
|
||
# ---- Parse JSON body
|
||
try:
|
||
payload = json.loads(request.body.decode("utf-8"))
|
||
except Exception:
|
||
return HttpResponseBadRequest("Invalid JSON payload")
|
||
|
||
room_id = payload.get("room_id")
|
||
duration = payload.get("duration")
|
||
case_id = payload.get("case_id")
|
||
|
||
if not room_id or duration is None:
|
||
return HttpResponseBadRequest("room_id and duration are required")
|
||
|
||
try:
|
||
duration = int(duration)
|
||
if duration <= 0:
|
||
raise ValueError
|
||
except ValueError:
|
||
return HttpResponseBadRequest("duration must be a positive integer (minutes)")
|
||
|
||
start_dt = _parse_start(payload)
|
||
if start_dt is None:
|
||
return HttpResponseBadRequest(
|
||
"Provide either 'scheduled_start' (ISO) or 'date' + 'start_time'"
|
||
)
|
||
|
||
# ---- Tenant scope & room checks
|
||
room = get_object_or_404(
|
||
OperatingRoom.objects.select_related("tenant"),
|
||
pk=room_id,
|
||
tenant=request.user.tenant,
|
||
)
|
||
|
||
# If room is not usable, return fast
|
||
if not room.is_active:
|
||
return JsonResponse({
|
||
"available": False,
|
||
"conflict_reason": "Room is inactive",
|
||
"conflicts": [],
|
||
})
|
||
|
||
if room.status in ("OUT_OF_ORDER", "MAINTENANCE", "CLOSED"):
|
||
return JsonResponse({
|
||
"available": False,
|
||
"conflict_reason": f"Room status is {room.get_status_display()}",
|
||
"conflicts": [],
|
||
})
|
||
|
||
end_dt = start_dt + timedelta(minutes=duration)
|
||
|
||
# ---- Find potentially overlapping cases
|
||
# We’ll fetch a reasonable window (same day +/- 1 day buffer) to reduce DB load, then check overlap in Python.
|
||
day_start = timezone.make_aware(
|
||
datetime.combine(start_dt.date(), datetime.min.time()),
|
||
timezone.get_current_timezone()
|
||
)
|
||
day_end = day_start + timedelta(days=2) # small buffer to capture cross-midnight or long cases
|
||
|
||
qs = SurgicalCase.objects.filter(
|
||
operating_room=room,
|
||
).exclude(
|
||
status__in=["CANCELLED"] # adjust based on your enum
|
||
)
|
||
|
||
if case_id:
|
||
qs = qs.exclude(pk=case_id)
|
||
|
||
# Try to use the most stable datetime field name(s) you’re using across the app:
|
||
# We saw both 'scheduled_start_time' and 'scheduled_start' in your templates; handle both.
|
||
# Pull candidates that start within the window or could overlap it.
|
||
# We'll just fetch cases for the surrounding window by their scheduled start field(s).
|
||
candidates = qs.filter(
|
||
Q(scheduled_start_time__gte=day_start, scheduled_start_time__lte=day_end)
|
||
| Q(scheduled_start__gte=day_start, scheduled_start__lte=day_end)
|
||
).select_related("patient")
|
||
|
||
conflicts = []
|
||
for c in candidates:
|
||
# Determine the other case's start & duration field names robustly
|
||
other_start = getattr(c, "scheduled_start", None) or getattr(c, "scheduled_start_time", None)
|
||
if other_start is None:
|
||
continue
|
||
|
||
other_duration = (
|
||
getattr(c, "estimated_duration", None)
|
||
or getattr(c, "estimated_duration_minutes", None)
|
||
or 120 # sane default if not set
|
||
)
|
||
try:
|
||
other_duration = int(other_duration)
|
||
except Exception:
|
||
other_duration = 120
|
||
|
||
other_end = other_start + timedelta(minutes=other_duration)
|
||
|
||
# Check overlap: [start_dt, end_dt) intersects [other_start, other_end)
|
||
if (start_dt < other_end) and (end_dt > other_start):
|
||
conflicts.append({
|
||
"id": c.pk,
|
||
"patient": getattr(c.patient, "get_full_name", lambda: str(c.patient))(),
|
||
"start": timezone.localtime(other_start).strftime("%Y-%m-%d %H:%M"),
|
||
"end": timezone.localtime(other_end).strftime("%Y-%m-%d %H:%M"),
|
||
"status": getattr(c, "status", "UNKNOWN"),
|
||
})
|
||
|
||
if conflicts:
|
||
return JsonResponse({
|
||
"available": False,
|
||
"conflict_reason": "Overlaps existing case",
|
||
"conflicts": conflicts,
|
||
})
|
||
|
||
# No overlaps found
|
||
return JsonResponse({
|
||
"available": True,
|
||
"conflict_reason": "",
|
||
"conflicts": [],
|
||
})
|
||
|
||
|
||
@login_required
|
||
def operating_theatre_stats(request):
|
||
"""
|
||
HTMX endpoint for operating theatre statistics.
|
||
"""
|
||
tenant = request.user.tenant
|
||
today = timezone.now().date()
|
||
|
||
stats = {
|
||
'rooms_available': OperatingRoom.objects.filter(
|
||
tenant=tenant,
|
||
is_active=True,
|
||
status='AVAILABLE'
|
||
).count(),
|
||
'rooms_in_use': OperatingRoom.objects.filter(
|
||
tenant=tenant,
|
||
is_active=True,
|
||
status='IN_USE'
|
||
).count(),
|
||
'cases_in_progress': SurgicalCase.objects.filter(
|
||
admission__tenant=tenant,
|
||
status='IN_PROGRESS'
|
||
).count(),
|
||
'cases_completed_today': SurgicalCase.objects.filter(
|
||
admission__tenant=tenant,
|
||
actual_end__date=today,
|
||
status='COMPLETED'
|
||
).count(),
|
||
'emergency_cases_today': SurgicalCase.objects.filter(
|
||
admission__tenant=tenant,
|
||
scheduled_start__date=today,
|
||
case_type='EMERGENCY'
|
||
).count(),
|
||
}
|
||
|
||
return render(request, 'operating_theatre/partials/or_stats.html', {'stats': stats})
|
||
|
||
|
||
@login_required
|
||
def case_search(request):
|
||
"""
|
||
HTMX endpoint for surgical case search.
|
||
"""
|
||
search = request.GET.get('search', '')
|
||
status = request.GET.get('status', '')
|
||
priority = request.GET.get('priority', '')
|
||
|
||
queryset = SurgicalCase.objects.filter(tenant=request.user.tenant)
|
||
|
||
if search:
|
||
queryset = queryset.filter(
|
||
Q(patient__first_name__icontains=search) |
|
||
Q(patient__last_name__icontains=search) |
|
||
Q(patient__mrn__icontains=search) |
|
||
Q(procedure_name__icontains=search)
|
||
)
|
||
|
||
if status:
|
||
queryset = queryset.filter(status=status)
|
||
|
||
if priority:
|
||
queryset = queryset.filter(priority=priority)
|
||
|
||
cases = queryset.select_related(
|
||
'patient', 'primary_surgeon', 'operating_room'
|
||
).order_by('-scheduled_start_time')[:20]
|
||
|
||
return render(request, 'operating_theatre/partials/case_list.html', {'cases': cases})
|
||
|
||
# ============================================================================
|
||
# Surgical Case Workflow Functions
|
||
# ============================================================================
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def confirm_case(request, pk):
|
||
"""
|
||
Confirm a scheduled surgical case.
|
||
"""
|
||
case = get_object_or_404(
|
||
SurgicalCase,
|
||
pk=pk,
|
||
or_block__operating_room__tenant=request.user.tenant
|
||
)
|
||
|
||
# Only scheduled cases can be confirmed
|
||
if case.status != 'SCHEDULED':
|
||
messages.error(request, 'Only scheduled cases can be confirmed')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
case.status = 'CONFIRMED'
|
||
case.save()
|
||
|
||
messages.success(request, f'Case {case.case_number} confirmed successfully')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def prep_case(request, pk):
|
||
"""
|
||
Mark case as in pre-operative prep.
|
||
"""
|
||
case = get_object_or_404(
|
||
SurgicalCase,
|
||
pk=pk,
|
||
or_block__operating_room__tenant=request.user.tenant
|
||
)
|
||
|
||
# Only confirmed cases can move to prep
|
||
if case.status not in ['SCHEDULED', 'CONFIRMED']:
|
||
messages.error(request, 'Only scheduled or confirmed cases can move to prep')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
case.status = 'PREP'
|
||
case.save()
|
||
|
||
messages.success(request, f'Case {case.case_number} moved to pre-operative prep')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def cancel_case(request, pk):
|
||
"""
|
||
Cancel a surgical case.
|
||
"""
|
||
case = get_object_or_404(
|
||
SurgicalCase,
|
||
pk=pk,
|
||
or_block__operating_room__tenant=request.user.tenant
|
||
)
|
||
|
||
# Cannot cancel completed cases
|
||
if case.status == 'COMPLETED':
|
||
messages.error(request, 'Cannot cancel a completed case')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
# Cannot cancel in-progress cases
|
||
if case.status == 'IN_PROGRESS':
|
||
messages.error(request, 'Cannot cancel a case that is in progress')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
reason = request.POST.get('reason', '')
|
||
|
||
case.status = 'CANCELLED'
|
||
if reason:
|
||
if case.clinical_notes:
|
||
case.clinical_notes += f"\n\nCancellation Reason ({timezone.now().strftime('%Y-%m-%d %H:%M')}): {reason}"
|
||
else:
|
||
case.clinical_notes = f"Cancellation Reason: {reason}"
|
||
case.save()
|
||
|
||
messages.warning(request, f'Case {case.case_number} cancelled')
|
||
return redirect('operating_theatre:case_list')
|
||
|
||
|
||
@login_required
|
||
@require_http_methods(["POST"])
|
||
def postpone_case(request, pk):
|
||
"""
|
||
Postpone a surgical case.
|
||
"""
|
||
case = get_object_or_404(
|
||
SurgicalCase,
|
||
pk=pk,
|
||
or_block__operating_room__tenant=request.user.tenant
|
||
)
|
||
|
||
# Cannot postpone completed or in-progress cases
|
||
if case.status in ['COMPLETED', 'IN_PROGRESS']:
|
||
messages.error(request, f'Cannot postpone a {case.get_status_display().lower()} case')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
reason = request.POST.get('reason', '')
|
||
|
||
case.status = 'POSTPONED'
|
||
if reason:
|
||
if case.clinical_notes:
|
||
case.clinical_notes += f"\n\nPostponement Reason ({timezone.now().strftime('%Y-%m-%d %H:%M')}): {reason}"
|
||
else:
|
||
case.clinical_notes = f"Postponement Reason: {reason}"
|
||
case.save()
|
||
|
||
messages.info(request, f'Case {case.case_number} postponed')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
|
||
@login_required
|
||
def reschedule_case(request, pk):
|
||
"""
|
||
Reschedule a surgical case.
|
||
"""
|
||
case = get_object_or_404(
|
||
SurgicalCase,
|
||
pk=pk,
|
||
or_block__operating_room__tenant=request.user.tenant
|
||
)
|
||
|
||
# Cannot reschedule completed or in-progress cases
|
||
if case.status in ['COMPLETED', 'IN_PROGRESS']:
|
||
messages.error(request, f'Cannot reschedule a {case.get_status_display().lower()} case')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
|
||
if request.method == 'POST':
|
||
# Get new OR block
|
||
or_block_id = request.POST.get('or_block')
|
||
scheduled_start = request.POST.get('scheduled_start')
|
||
|
||
if or_block_id and scheduled_start:
|
||
try:
|
||
new_or_block = ORBlock.objects.get(
|
||
pk=or_block_id,
|
||
operating_room__tenant=request.user.tenant
|
||
)
|
||
|
||
case.or_block = new_or_block
|
||
case.scheduled_start = scheduled_start
|
||
case.status = 'SCHEDULED'
|
||
|
||
# Add note about rescheduling
|
||
reschedule_note = f"\n\nRescheduled on {timezone.now().strftime('%Y-%m-%d %H:%M')} to {scheduled_start}"
|
||
if case.clinical_notes:
|
||
case.clinical_notes += reschedule_note
|
||
else:
|
||
case.clinical_notes = reschedule_note.strip()
|
||
|
||
case.save()
|
||
|
||
messages.success(request, f'Case {case.case_number} rescheduled successfully')
|
||
return redirect('operating_theatre:case_detail', pk=case.pk)
|
||
except ORBlock.DoesNotExist:
|
||
messages.error(request, 'Invalid OR block selected')
|
||
else:
|
||
messages.error(request, 'Please provide both OR block and scheduled start time')
|
||
|
||
# Get available OR blocks for rescheduling
|
||
available_blocks = ORBlock.objects.filter(
|
||
operating_room__tenant=request.user.tenant,
|
||
operating_room__is_active=True,
|
||
date__gte=timezone.now().date(),
|
||
status='SCHEDULED'
|
||
).select_related('operating_room').order_by('date', 'start_time')
|
||
|
||
context = {
|
||
'case': case,
|
||
'available_blocks': available_blocks,
|
||
}
|
||
|
||
return render(request, 'operating_theatre/case_reschedule.html', context)
|