4210 lines
147 KiB
Python
4210 lines
147 KiB
Python
"""
|
|
Appointments app views for hospital management system with comprehensive CRUD operations.
|
|
"""
|
|
from django.contrib.messages import success
|
|
from django.shortcuts import render, get_object_or_404, redirect
|
|
from django.contrib.auth.decorators import login_required, permission_required
|
|
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
|
from django.template.defaulttags import csrf_token
|
|
from django.utils.dateparse import parse_datetime
|
|
from django.views.decorators.http import require_GET, require_POST
|
|
from django.views.generic import (
|
|
TemplateView, ListView, DetailView, CreateView, UpdateView, DeleteView
|
|
)
|
|
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseForbidden
|
|
from django.contrib import messages
|
|
from django.db.models.functions import Now
|
|
from django.db.models import Q, Count, Avg, Case, When, Value, DurationField, FloatField, F, ExpressionWrapper, IntegerField
|
|
from django.utils import timezone
|
|
from django.urls import reverse_lazy, reverse
|
|
from django.core.paginator import Paginator
|
|
from datetime import timedelta, datetime, time, date
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from hr.models import Schedule, Employee
|
|
from .models import *
|
|
from .forms import *
|
|
from .utils import (
|
|
get_tenant_from_request,
|
|
check_appointment_conflicts,
|
|
send_appointment_notification,
|
|
generate_meeting_url,
|
|
calculate_appointment_duration,
|
|
validate_appointment_time,
|
|
get_available_time_slots,
|
|
format_appointment_summary
|
|
)
|
|
from patients.models import PatientProfile
|
|
from accounts.models import User
|
|
from core.utils import AuditLogger
|
|
|
|
|
|
class AppointmentDashboardView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Appointment dashboard view.
|
|
"""
|
|
template_name = 'appointments/dashboard.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
|
|
if tenant:
|
|
today = timezone.now().date()
|
|
now = timezone.now()
|
|
|
|
# Today's appointments
|
|
context['todays_appointments'] = AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today
|
|
).order_by('scheduled_datetime')[:15]
|
|
|
|
# Active queues
|
|
context['active_queues'] = WaitingQueue.objects.filter(
|
|
tenant=tenant,is_active=True
|
|
).annotate(entry_count=Count('queue_entries')).order_by('-entry_count')
|
|
|
|
# Statistics
|
|
context['stats'] = {
|
|
'total_appointments': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
).count(),
|
|
'total_appointments_today': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today
|
|
).count(),
|
|
'pending_appointments': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today,
|
|
status='PENDING'
|
|
).count(),
|
|
'active_queues_count': WaitingQueue.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
).count(),
|
|
'telemedicine_sessions': TelemedicineSession.objects.filter(
|
|
appointment__tenant=tenant,
|
|
appointment__scheduled_datetime__date=today
|
|
).count(),
|
|
}
|
|
|
|
return context
|
|
|
|
|
|
class AppointmentRequestListView(LoginRequiredMixin, ListView):
|
|
"""
|
|
List appointment requests.
|
|
"""
|
|
model = AppointmentRequest
|
|
template_name = 'appointments/requests/appointment_list.html'
|
|
context_object_name = 'appointments'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return AppointmentRequest.objects.none()
|
|
|
|
queryset = AppointmentRequest.objects.filter(tenant=tenant)
|
|
|
|
# Apply filters
|
|
appointment_type = self.request.GET.get('appointment_type')
|
|
if appointment_type:
|
|
queryset = queryset.filter(appointment_type=appointment_type)
|
|
|
|
status = self.request.GET.get('status')
|
|
if status:
|
|
queryset = queryset.filter(status=status)
|
|
|
|
priority = self.request.GET.get('priority')
|
|
if priority:
|
|
queryset = queryset.filter(priority=priority)
|
|
|
|
provider_id = self.request.GET.get('provider')
|
|
if provider_id:
|
|
queryset = queryset.filter(provider_id=provider_id)
|
|
|
|
department = self.request.GET.get('department')
|
|
if department:
|
|
queryset = queryset.filter(department__icontains=department)
|
|
|
|
date_from = self.request.GET.get('date_from')
|
|
if date_from:
|
|
queryset = queryset.filter(scheduled_datetime__date__gte=date_from)
|
|
|
|
date_to = self.request.GET.get('date_to')
|
|
if date_to:
|
|
queryset = queryset.filter(scheduled_datetime__date__lte=date_to)
|
|
|
|
is_telemedicine = self.request.GET.get('is_telemedicine')
|
|
if is_telemedicine == 'true':
|
|
queryset = queryset.filter(is_telemedicine=True)
|
|
elif is_telemedicine == 'false':
|
|
queryset = queryset.filter(is_telemedicine=False)
|
|
|
|
search = self.request.GET.get('search')
|
|
if search:
|
|
queryset = queryset.filter(
|
|
Q(patient__first_name__icontains=search) |
|
|
Q(patient__last_name__icontains=search) |
|
|
Q(patient__patient_id__icontains=search) |
|
|
Q(provider__first_name__icontains=search) |
|
|
Q(provider__last_name__icontains=search) |
|
|
Q(reason__icontains=search) |
|
|
Q(department__icontains=search)
|
|
)
|
|
|
|
return queryset.order_by('-scheduled_datetime')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['search_form'] = AppointmentSearchForm(
|
|
self.request.GET,
|
|
user=self.request.user
|
|
)
|
|
return context
|
|
|
|
|
|
class AppointmentRequestDetailView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Display appointment request details.
|
|
"""
|
|
model = AppointmentRequest
|
|
template_name = 'appointments/requests/appointment_detail.html'
|
|
context_object_name = 'appointment'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return AppointmentRequest.objects.none()
|
|
return AppointmentRequest.objects.filter(tenant=tenant)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
appointment = self.get_object()
|
|
|
|
# Get related data
|
|
context['queue_entry'] = QueueEntry.objects.filter(
|
|
appointment=appointment
|
|
).first()
|
|
|
|
context['telemedicine_session'] = TelemedicineSession.objects.filter(
|
|
appointment=appointment
|
|
).first()
|
|
|
|
return context
|
|
|
|
|
|
class AppointmentRequestCreateView(LoginRequiredMixin, CreateView):
|
|
"""
|
|
Create new appointment request.
|
|
"""
|
|
model = AppointmentRequest
|
|
form_class = AppointmentRequestForm
|
|
template_name = 'appointments/requests/appointment_form.html'
|
|
permission_required = 'appointments.add_appointmentrequest'
|
|
success_url = reverse_lazy('appointments:appointment_request_list')
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure tenant exists (if you follow multi-tenant pattern)
|
|
self.tenant = self.request.user.tenant
|
|
if not self.tenant:
|
|
return JsonResponse({"error": "No tenant found"}, status=400)
|
|
|
|
# Fetch the patient from URL and ensure it belongs to the tenant
|
|
self.patient = get_object_or_404(
|
|
PatientProfile,
|
|
pk=self.kwargs.get("pk"),
|
|
tenant=self.tenant,
|
|
is_active=True,
|
|
)
|
|
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
kwargs["initial"]["patient"] = self.patient
|
|
return kwargs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx["patient"] = self.patient
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
# Set tenant
|
|
form.instance.tenant = self.tenant
|
|
form.instance.patient = self.patient
|
|
form.instance.status = 'PENDING'
|
|
response = super().form_valid(form)
|
|
|
|
# Log appointment creation
|
|
AuditLogger.log_event(
|
|
tenant=form.instance.tenant,
|
|
event_type='CREATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Create Appointment',
|
|
description=f'Created appointment: {self.object.patient} with {self.object.provider}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Appointment for {self.object.patient} created successfully.')
|
|
return response
|
|
|
|
|
|
class AppointmentRequestUpdateView(LoginRequiredMixin, UpdateView):
|
|
"""
|
|
Update appointment request (limited fields after scheduling).
|
|
"""
|
|
model = AppointmentRequest
|
|
form_class = AppointmentRequestForm
|
|
template_name = 'appointments/requests/appointment_form.html'
|
|
permission_required = 'appointments.change_appointmentrequest'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return AppointmentRequest.objects.none()
|
|
return AppointmentRequest.objects.filter(tenant=tenant)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def get_success_url(self):
|
|
return reverse('appointments:appointment_request_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
|
|
# Log appointment update
|
|
AuditLogger.log_event(
|
|
tenant=self.object.tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Update Appointment',
|
|
description=f'Updated appointment: {self.object.patient} with {self.object.provider}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Appointment for {self.object.patient} updated successfully.')
|
|
return response
|
|
|
|
|
|
class AppointmentRequestDeleteView(LoginRequiredMixin, DeleteView):
|
|
"""
|
|
Cancel appointment request.
|
|
"""
|
|
model = AppointmentRequest
|
|
template_name = 'appointments/requests/appointment_confirm_delete.html'
|
|
permission_required = 'appointments.delete_appointmentrequest'
|
|
success_url = reverse_lazy('appointments:appointment_request_list')
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return AppointmentRequest.objects.none()
|
|
return AppointmentRequest.objects.filter(tenant=tenant)
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
self.object = self.get_object()
|
|
|
|
# Update status to cancelled instead of hard delete
|
|
self.object.status = 'CANCELLED'
|
|
self.object.save()
|
|
|
|
# Log appointment cancellation
|
|
AuditLogger.log_event(
|
|
tenant=self.request.user.tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Cancel Appointment',
|
|
description=f'Cancelled appointment: {self.object.patient} with {self.object.provider}',
|
|
user=request.user,
|
|
content_object=self.object,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Appointment for {self.object.patient} cancelled successfully.')
|
|
return redirect(self.success_url)
|
|
|
|
|
|
class SlotAvailabilityListView(LoginRequiredMixin, ListView):
|
|
"""
|
|
List slot availability.
|
|
"""
|
|
model = SlotAvailability
|
|
template_name = 'appointments/slots/slot_list.html'
|
|
context_object_name = 'slots'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return SlotAvailability.objects.none()
|
|
|
|
queryset = SlotAvailability.objects.filter(provider__tenant=tenant)
|
|
|
|
# Apply filters
|
|
provider_id = self.request.GET.get('provider')
|
|
if provider_id:
|
|
queryset = queryset.filter(provider_id=provider_id)
|
|
|
|
date_from = self.request.GET.get('date_from')
|
|
if date_from:
|
|
queryset = queryset.filter(date__gte=date_from)
|
|
|
|
date_to = self.request.GET.get('date_to')
|
|
if date_to:
|
|
queryset = queryset.filter(date__lte=date_to)
|
|
|
|
availability = self.request.GET.get('availability')
|
|
if availability == 'available':
|
|
queryset = queryset.filter(is_available=True)
|
|
elif availability == 'unavailable':
|
|
queryset = queryset.filter(is_available=False)
|
|
|
|
return queryset.order_by('date', 'start_time')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['search_form'] = SlotSearchForm(
|
|
self.request.GET,
|
|
user=self.request.user
|
|
)
|
|
return context
|
|
|
|
|
|
class SlotAvailabilityDetailView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Display slot availability details.
|
|
"""
|
|
model = SlotAvailability
|
|
template_name = 'appointments/slots/slot_detail.html'
|
|
context_object_name = 'slot'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return SlotAvailability.objects.none()
|
|
return SlotAvailability.objects.filter(provider__tenant=tenant)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
slot = self.get_object()
|
|
|
|
# Get appointments for this slot
|
|
context['appointments'] = AppointmentRequest.objects.filter(
|
|
provider=slot.provider,
|
|
scheduled_datetime__date=slot.date,
|
|
scheduled_datetime__time__gte=slot.start_time,
|
|
scheduled_datetime__time__lt=slot.end_time
|
|
).order_by('scheduled_datetime')
|
|
|
|
return context
|
|
|
|
|
|
class SlotAvailabilityCreateView(LoginRequiredMixin, CreateView):
|
|
"""
|
|
Create new slot availability.
|
|
"""
|
|
model = SlotAvailability
|
|
form_class = SlotAvailabilityForm
|
|
template_name = 'appointments/slots/slot_form.html'
|
|
permission_required = 'appointments.add_slotavailability'
|
|
success_url = reverse_lazy('appointments:slot_availability_list')
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
|
|
# Log slot creation
|
|
AuditLogger.log_event(
|
|
tenant=self.request.user.tenant,
|
|
event_type='CREATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Create Slot Availability',
|
|
description=f'Created slot: {self.object.provider} on {self.object.date}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Slot availability for {self.object.provider} created successfully.')
|
|
return response
|
|
|
|
|
|
class SlotAvailabilityUpdateView(LoginRequiredMixin, UpdateView):
|
|
"""
|
|
Update slot availability.
|
|
"""
|
|
model = SlotAvailability
|
|
form_class = SlotAvailabilityForm
|
|
template_name = 'appointments/slots/slot_form.html'
|
|
permission_required = 'appointments.change_slotavailability'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return SlotAvailability.objects.none()
|
|
return SlotAvailability.objects.filter(provider__tenant=tenant)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def get_success_url(self):
|
|
return reverse('appointments:slot_availability_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
|
|
# Log slot update
|
|
AuditLogger.log_event(
|
|
tenant=self.request.user.tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Update Slot Availability',
|
|
description=f'Updated slot: {self.object.provider} on {self.object.date}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Slot availability for {self.object.provider} updated successfully.')
|
|
return response
|
|
|
|
|
|
class SlotAvailabilityDeleteView(LoginRequiredMixin, DeleteView):
|
|
"""
|
|
Delete slot availability.
|
|
"""
|
|
model = SlotAvailability
|
|
template_name = 'appointments/slots/slot_confirm_delete.html'
|
|
permission_required = 'appointments.delete_slotavailability'
|
|
success_url = reverse_lazy('appointments:slot_availability_list')
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return SlotAvailability.objects.none()
|
|
return SlotAvailability.objects.filter(provider__tenant=tenant)
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
self.object = self.get_object()
|
|
|
|
# Check if slot has appointments
|
|
appointments_count = AppointmentRequest.objects.filter(
|
|
provider=self.object.provider,
|
|
scheduled_datetime__date=self.object.date,
|
|
scheduled_datetime__time__gte=self.object.start_time,
|
|
scheduled_datetime__time__lt=self.object.end_time,
|
|
status__in=['SCHEDULED', 'CONFIRMED']
|
|
).count()
|
|
|
|
if appointments_count > 0:
|
|
messages.error(request, f'Cannot delete slot with {appointments_count} scheduled appointments.')
|
|
return redirect('appointments:slot_availability_detail', pk=self.object.pk)
|
|
|
|
provider_name = str(self.object.provider)
|
|
slot_date = self.object.date
|
|
|
|
# Log slot deletion
|
|
AuditLogger.log_event(
|
|
tenant=getattr(request, 'tenant', None),
|
|
event_type='DELETE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Delete Slot Availability',
|
|
description=f'Deleted slot: {provider_name} on {slot_date}',
|
|
user=request.user,
|
|
content_object=self.object,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Slot availability for {provider_name} on {slot_date} deleted successfully.')
|
|
return super().delete(request, *args, **kwargs)
|
|
|
|
|
|
class WaitingQueueListView(LoginRequiredMixin, ListView):
|
|
"""
|
|
List waiting queues.
|
|
"""
|
|
model = WaitingQueue
|
|
template_name = 'appointments/queue/waiting_queue_list.html'
|
|
context_object_name = 'queues'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return WaitingQueue.objects.none()
|
|
|
|
queryset = WaitingQueue.objects.filter(tenant=tenant)
|
|
|
|
# Apply filters
|
|
queue_type = self.request.GET.get('queue_type')
|
|
if queue_type:
|
|
queryset = queryset.filter(queue_type=queue_type)
|
|
|
|
status = self.request.GET.get('status')
|
|
if status == 'active':
|
|
queryset = queryset.filter(is_active=True)
|
|
elif status == 'inactive':
|
|
queryset = queryset.filter(is_active=False)
|
|
|
|
department = self.request.GET.get('department')
|
|
if department:
|
|
queryset = queryset.filter(department__icontains=department)
|
|
|
|
provider_id = self.request.GET.get('provider')
|
|
if provider_id:
|
|
queryset = queryset.filter(provider_id=provider_id)
|
|
|
|
search = self.request.GET.get('search')
|
|
if search:
|
|
queryset = queryset.filter(
|
|
Q(name__icontains=search) |
|
|
Q(description__icontains=search) |
|
|
Q(department__icontains=search)
|
|
)
|
|
|
|
return queryset.order_by('name')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['search_form'] = QueueSearchForm
|
|
return context
|
|
|
|
|
|
class WaitingQueueDetailView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Display waiting queue details.
|
|
"""
|
|
model = WaitingQueue
|
|
template_name = 'appointments/queue/waiting_queue_detail.html'
|
|
context_object_name = 'queue'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return WaitingQueue.objects.none()
|
|
return WaitingQueue.objects.filter(tenant=tenant)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
queue = self.get_object()
|
|
|
|
# Get queue entries
|
|
context['queue_entries'] = QueueEntry.objects.filter(
|
|
queue=queue
|
|
).order_by('queue_position', 'joined_at')
|
|
|
|
# Calculate statistics
|
|
context['stats'] = {
|
|
'total_entries': QueueEntry.objects.filter(queue=queue).count(),
|
|
'waiting_entries': QueueEntry.objects.filter(queue=queue, status='WAITING').count(),
|
|
'in_progress_entries': QueueEntry.objects.filter(queue=queue, status='IN_PROGRESS').count(),
|
|
'served_today': QueueEntry.objects.filter(queue=queue, status='COMPLETED').count(),
|
|
'no_show_entries': QueueEntry.objects.filter(queue=queue, status='NO_SHOW').count(),
|
|
# 'average_wait_time': QueueEntry.objects.filter(
|
|
# queue=queue,
|
|
# status='COMPLETED'
|
|
# ).aggregate(avg_wait=Avg('queue_entries__wait_time_minutes'))['avg_wait'] or 0,
|
|
}
|
|
|
|
return context
|
|
|
|
|
|
class WaitingQueueCreateView(LoginRequiredMixin, CreateView):
|
|
"""
|
|
Create new waiting queue.
|
|
"""
|
|
model = WaitingQueue
|
|
form_class = WaitingQueueForm
|
|
template_name = 'appointments/queue/waiting_queue_form.html'
|
|
permission_required = 'appointments.add_waitingqueue'
|
|
success_url = reverse_lazy('appointments:waiting_queue_list')
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
kwargs['tenant'] = self.request.user.tenant
|
|
return kwargs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
# Add days of week for operating hours
|
|
context['days_of_week'] = [
|
|
{'value': 0, 'name': 'Sunday', 'enabled': False},
|
|
{'value': 1, 'name': 'Monday', 'enabled': False},
|
|
{'value': 2, 'name': 'Tuesday', 'enabled': False},
|
|
{'value': 3, 'name': 'Wednesday', 'enabled': False},
|
|
{'value': 4, 'name': 'Thursday', 'enabled': False},
|
|
{'value': 5, 'name': 'Friday', 'enabled': False},
|
|
{'value': 6, 'name': 'Saturday', 'enabled': False},
|
|
|
|
]
|
|
# Add default priority weights
|
|
context['priority_weights'] = {
|
|
'emergency': 10,
|
|
'urgent': 5,
|
|
'elderly': 2,
|
|
'pregnant': 3,
|
|
'pediatric': 2,
|
|
'regular': 1,
|
|
}
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
# Set tenant
|
|
form.instance.tenant = self.request.user.tenant
|
|
|
|
# Process operating hours from POST data
|
|
operating_hours = {}
|
|
for day in range(7):
|
|
enabled = self.request.POST.get(f'operating_hours_{day}_enabled')
|
|
if enabled:
|
|
start_time = self.request.POST.get(f'operating_hours_{day}_start')
|
|
end_time = self.request.POST.get(f'operating_hours_{day}_end')
|
|
if start_time and end_time:
|
|
operating_hours[str(day)] = {
|
|
'enabled': True,
|
|
'start_time': start_time,
|
|
'end_time': end_time
|
|
}
|
|
|
|
form.instance.operating_hours = operating_hours
|
|
|
|
# Process priority weights from POST data
|
|
priority_weights = {
|
|
'emergency': float(self.request.POST.get('priority_weight_emergency', 10)),
|
|
'urgent': float(self.request.POST.get('priority_weight_urgent', 5)),
|
|
'elderly': float(self.request.POST.get('priority_weight_elderly', 2)),
|
|
'pregnant': float(self.request.POST.get('priority_weight_pregnant', 3)),
|
|
'pediatric': float(self.request.POST.get('priority_weight_pediatric', 2)),
|
|
'regular': float(self.request.POST.get('priority_weight_regular', 1)),
|
|
}
|
|
|
|
form.instance.priority_weights = priority_weights
|
|
|
|
response = super().form_valid(form)
|
|
|
|
# Log queue creation
|
|
AuditLogger.log_event(
|
|
tenant=form.instance.tenant,
|
|
event_type='CREATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Create Waiting Queue',
|
|
description=f'Created queue: {self.object.name}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Waiting queue "{self.object.name}" created successfully.')
|
|
return response
|
|
|
|
|
|
class WaitingQueueUpdateView(LoginRequiredMixin, UpdateView):
|
|
"""
|
|
Update waiting queue.
|
|
"""
|
|
model = WaitingQueue
|
|
form_class = WaitingQueueForm
|
|
template_name = 'appointments/queue/waiting_queue_form.html'
|
|
permission_required = 'appointments.change_waitingqueue'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return WaitingQueue.objects.none()
|
|
return WaitingQueue.objects.filter(tenant=tenant)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
kwargs['tenant'] = self.request.user.tenant
|
|
return kwargs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
queue = self.object
|
|
|
|
# Add days of week with existing operating hours
|
|
days_of_week = []
|
|
for day in range(7):
|
|
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
day_data = {
|
|
'value': day,
|
|
'name': day_names[day],
|
|
'enabled': False,
|
|
'start_time': '09:00',
|
|
'end_time': '17:00'
|
|
}
|
|
|
|
# Load existing operating hours if available
|
|
if queue.operating_hours and str(day) in queue.operating_hours:
|
|
hours = queue.operating_hours[str(day)]
|
|
day_data['enabled'] = hours.get('enabled', False)
|
|
day_data['start_time'] = hours.get('start_time', '09:00')
|
|
day_data['end_time'] = hours.get('end_time', '17:00')
|
|
|
|
days_of_week.append(day_data)
|
|
|
|
context['days_of_week'] = days_of_week
|
|
|
|
# Add existing priority weights or defaults
|
|
context['priority_weights'] = queue.priority_weights if queue.priority_weights else {
|
|
'emergency': 10,
|
|
'urgent': 5,
|
|
'elderly': 2,
|
|
'pregnant': 3,
|
|
'pediatric': 2,
|
|
'regular': 1,
|
|
}
|
|
|
|
return context
|
|
|
|
def get_success_url(self):
|
|
return reverse('appointments:waiting_queue_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
# Process operating hours from POST data
|
|
operating_hours = {}
|
|
for day in range(7):
|
|
enabled = self.request.POST.get(f'operating_hours_{day}_enabled')
|
|
if enabled:
|
|
start_time = self.request.POST.get(f'operating_hours_{day}_start')
|
|
end_time = self.request.POST.get(f'operating_hours_{day}_end')
|
|
if start_time and end_time:
|
|
operating_hours[str(day)] = {
|
|
'enabled': True,
|
|
'start_time': start_time,
|
|
'end_time': end_time
|
|
}
|
|
|
|
form.instance.operating_hours = operating_hours
|
|
|
|
# Process priority weights from POST data
|
|
priority_weights = {
|
|
'emergency': float(self.request.POST.get('priority_weight_emergency', 10)),
|
|
'urgent': float(self.request.POST.get('priority_weight_urgent', 5)),
|
|
'elderly': float(self.request.POST.get('priority_weight_elderly', 2)),
|
|
'pregnant': float(self.request.POST.get('priority_weight_pregnant', 3)),
|
|
'pediatric': float(self.request.POST.get('priority_weight_pediatric', 2)),
|
|
'regular': float(self.request.POST.get('priority_weight_regular', 1)),
|
|
}
|
|
|
|
form.instance.priority_weights = priority_weights
|
|
|
|
response = super().form_valid(form)
|
|
|
|
# Log queue update
|
|
AuditLogger.log_event(
|
|
tenant=self.object.tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Update Waiting Queue',
|
|
description=f'Updated queue: {self.object.name}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Waiting queue "{self.object.name}" updated successfully.')
|
|
return response
|
|
|
|
|
|
class WaitingQueueDeleteView(LoginRequiredMixin, DeleteView):
|
|
"""
|
|
Delete waiting queue.
|
|
"""
|
|
model = WaitingQueue
|
|
template_name = 'appointments/queue/waiting_queue_confirm_delete.html'
|
|
permission_required = 'appointments.delete_waitingqueue'
|
|
success_url = reverse_lazy('appointments:waiting_queue_list')
|
|
context_object_name = 'queue'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return WaitingQueue.objects.none()
|
|
return WaitingQueue.objects.filter(tenant=tenant)
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
self.object = self.get_object()
|
|
|
|
# Check if queue has active entries
|
|
active_entries = QueueEntry.objects.filter(
|
|
queue=self.object,
|
|
status__in=['WAITING', 'IN_PROGRESS']
|
|
).count()
|
|
|
|
if active_entries > 0:
|
|
messages.error(request, f'Cannot delete queue with {active_entries} active entries.')
|
|
return redirect('appointments:waiting_queue_detail', pk=self.object.pk)
|
|
|
|
queue_name = self.object.name
|
|
|
|
# Log queue deletion
|
|
AuditLogger.log_event(
|
|
tenant=getattr(request, 'tenant', None),
|
|
event_type='DELETE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Delete Waiting Queue',
|
|
description=f'Deleted queue: {queue_name}',
|
|
user=request.user,
|
|
content_object=self.object,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Waiting queue "{queue_name}" deleted successfully.')
|
|
return super().delete(request, *args, **kwargs)
|
|
|
|
|
|
class QueueEntryListView(LoginRequiredMixin, ListView):
|
|
"""
|
|
List queue entries.
|
|
"""
|
|
model = QueueEntry
|
|
template_name = 'appointments/queue/queue_entry_list.html'
|
|
context_object_name = 'entries'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return QueueEntry.objects.none()
|
|
|
|
queryset = QueueEntry.objects.filter(queue__tenant=tenant)
|
|
|
|
# Apply filters
|
|
queue_id = self.request.GET.get('queue_id')
|
|
if queue_id:
|
|
queryset = queryset.filter(queue_id=queue_id)
|
|
|
|
status = self.request.GET.get('status')
|
|
if status:
|
|
queryset = queryset.filter(status=status)
|
|
|
|
priority = self.request.GET.get('priority')
|
|
if priority:
|
|
queryset = queryset.filter(priority=priority)
|
|
|
|
return queryset.order_by('queue__name', 'queue_position', '-joined_at')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
|
|
if tenant:
|
|
context['queues'] = WaitingQueue.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
).order_by('name')
|
|
context['providers'] = User.objects.filter(
|
|
tenant=tenant,
|
|
role__in = ['PHYSICIAN', 'NURSE','NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT', 'PHARMACIST', 'PHARMACY_TECH', 'LAB_TECH', 'RADIOLOGIST','RAD_TECH', 'THERAPIST',]
|
|
)
|
|
|
|
return context
|
|
|
|
|
|
class QueueEntryDetailView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Display queue entry details.
|
|
"""
|
|
model = QueueEntry
|
|
template_name = 'appointments/queue/queue_entry_detail.html'
|
|
context_object_name = 'entry'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return QueueEntry.objects.none()
|
|
return QueueEntry.objects.filter(queue__tenant=tenant)
|
|
|
|
|
|
class QueueEntryCreateView(LoginRequiredMixin, CreateView):
|
|
"""
|
|
Create new queue entry.
|
|
"""
|
|
model = QueueEntry
|
|
form_class = QueueEntryForm
|
|
template_name = 'appointments/queue/queue_entry_form.html'
|
|
permission_required = 'appointments.add_queueentry'
|
|
success_url = reverse_lazy('appointments:queue_entry_list')
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def form_valid(self, form):
|
|
# Set position
|
|
last_position = QueueEntry.objects.filter(
|
|
queue=form.instance.queue
|
|
).aggregate(max_position=Count('id'))['max_position'] or 0
|
|
form.instance.position = last_position + 1
|
|
|
|
response = super().form_valid(form)
|
|
|
|
# Log queue entry creation
|
|
AuditLogger.log_event(
|
|
tenant=self.request.user.tenant,
|
|
event_type='CREATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Create Queue Entry',
|
|
description=f'Added {self.object.patient} to queue: {self.object.queue.name}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Patient {self.object.patient} added to queue successfully.')
|
|
return response
|
|
|
|
|
|
class QueueEntryUpdateView(LoginRequiredMixin, UpdateView):
|
|
"""
|
|
Update queue entry.
|
|
"""
|
|
model = QueueEntry
|
|
form_class = QueueEntryForm
|
|
template_name = 'appointments/queue/queue_entry_form.html'
|
|
permission_required = 'appointments.change_queueentry'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return QueueEntry.objects.none()
|
|
return QueueEntry.objects.filter(queue__tenant=tenant)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def get_success_url(self):
|
|
return reverse('appointments:queue_entry_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
|
|
# Log queue entry update
|
|
AuditLogger.log_event(
|
|
tenant=self.request.user.tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Update Queue Entry',
|
|
description=f'Updated queue entry for {self.object.patient}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Queue entry for {self.object.patient} updated successfully.')
|
|
return response
|
|
|
|
|
|
class QueueEntryDeleteView(LoginRequiredMixin, DeleteView):
|
|
model = QueueEntry
|
|
context_object_name = 'entry'
|
|
template_name = 'appointments/queue/queue_entry_confirm_delete.html'
|
|
permission_required = 'appointments.delete_queueentry'
|
|
success_url = reverse_lazy('appointments:queue_entry_list')
|
|
|
|
|
|
class TelemedicineSessionListView(LoginRequiredMixin, ListView):
|
|
"""
|
|
List telemedicine sessions.
|
|
"""
|
|
model = TelemedicineSession
|
|
template_name = 'appointments/telemedicine/telemedicine.html'
|
|
context_object_name = 'sessions'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return TelemedicineSession.objects.none()
|
|
|
|
queryset = TelemedicineSession.objects.filter(appointment__tenant=tenant)
|
|
|
|
# Apply filters
|
|
status = self.request.GET.get('status')
|
|
if status:
|
|
queryset = queryset.filter(status=status)
|
|
|
|
platform = self.request.GET.get('platform')
|
|
if platform:
|
|
queryset = queryset.filter(platform=platform)
|
|
|
|
date_from = self.request.GET.get('date_from')
|
|
if date_from:
|
|
queryset = queryset.filter(start_time__date__gte=date_from)
|
|
|
|
date_to = self.request.GET.get('date_to')
|
|
if date_to:
|
|
queryset = queryset.filter(start_time__date__lte=date_to)
|
|
|
|
return queryset.order_by('-start_time')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
|
|
if tenant:
|
|
context.update({
|
|
'status_choices': TelemedicineSession.STATUS_CHOICES,
|
|
'platform_choices': TelemedicineSession.PLATFORM_CHOICES,
|
|
})
|
|
|
|
return context
|
|
|
|
|
|
class TelemedicineSessionDetailView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Display telemedicine session details.
|
|
"""
|
|
model = TelemedicineSession
|
|
template_name = 'appointments/telemedicine/telemedicine_session_detail.html'
|
|
context_object_name = 'session'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return TelemedicineSession.objects.none()
|
|
return TelemedicineSession.objects.filter(appointment__tenant=tenant)
|
|
|
|
|
|
class TelemedicineSessionCreateView(LoginRequiredMixin, CreateView):
|
|
"""
|
|
Create new telemedicine session.
|
|
"""
|
|
model = TelemedicineSession
|
|
form_class = TelemedicineSessionForm
|
|
template_name = 'appointments/telemedicine/telemedicine_session_form.html'
|
|
permission_required = 'appointments.add_telemedicinesession'
|
|
success_url = reverse_lazy('appointments:telemedicine_session_list')
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
|
|
# Log session creation
|
|
AuditLogger.log_event(
|
|
tenant=self.request.user.tenant,
|
|
event_type='CREATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Create Telemedicine Session',
|
|
description=f'Created telemedicine session for appointment: {self.object.appointment}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Telemedicine session created successfully.')
|
|
return response
|
|
|
|
|
|
class TelemedicineSessionUpdateView(LoginRequiredMixin, UpdateView):
|
|
"""
|
|
Update telemedicine session.
|
|
"""
|
|
model = TelemedicineSession
|
|
form_class = TelemedicineSessionForm
|
|
template_name = 'appointments/telemedicine/telemedicine_session_form.html'
|
|
permission_required = 'appointments.change_telemedicinesession'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return TelemedicineSession.objects.none()
|
|
return TelemedicineSession.objects.filter(appointment__tenant=tenant)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def get_success_url(self):
|
|
return reverse('appointments:telemedicine_session_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
|
|
# Log session update
|
|
AuditLogger.log_event(
|
|
tenant=self.request.user.tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Update Telemedicine Session',
|
|
description=f'Updated telemedicine session: {self.object.appointment}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Telemedicine session updated successfully.')
|
|
return response
|
|
|
|
|
|
class AppointmentTemplateListView(LoginRequiredMixin, ListView):
|
|
"""
|
|
List appointment templates.
|
|
"""
|
|
model = AppointmentTemplate
|
|
template_name = 'appointments/templates/appointment_template_list.html'
|
|
context_object_name = 'templates'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return AppointmentTemplate.objects.none()
|
|
|
|
queryset = AppointmentTemplate.objects.filter(tenant=tenant)
|
|
|
|
# Apply filters
|
|
appointment_type = self.request.GET.get('appointment_type')
|
|
if appointment_type:
|
|
queryset = queryset.filter(appointment_type=appointment_type)
|
|
|
|
department = self.request.GET.get('department')
|
|
if department:
|
|
queryset = queryset.filter(department__icontains=department)
|
|
|
|
provider_id = self.request.GET.get('provider')
|
|
if provider_id:
|
|
queryset = queryset.filter(provider_id=provider_id)
|
|
|
|
status = self.request.GET.get('status')
|
|
if status == 'active':
|
|
queryset = queryset.filter(is_active=True)
|
|
elif status == 'inactive':
|
|
queryset = queryset.filter(is_active=False)
|
|
|
|
search = self.request.GET.get('search')
|
|
if search:
|
|
queryset = queryset.filter(
|
|
Q(name__icontains=search) |
|
|
Q(description__icontains=search) |
|
|
Q(department__icontains=search)
|
|
)
|
|
|
|
return queryset.order_by('appointment_type', 'name')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
|
|
if tenant:
|
|
context.update({
|
|
'appointment_types': AppointmentTemplate.objects.filter(
|
|
tenant=tenant
|
|
).values_list('appointment_type', flat=True).distinct(),
|
|
'providers': User.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True,
|
|
employee_profile__role__in=['DOCTOR', 'NURSE', 'SPECIALIST']
|
|
).order_by('last_name', 'first_name'),
|
|
})
|
|
|
|
return context
|
|
|
|
|
|
class AppointmentTemplateDetailView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Display appointment template details.
|
|
"""
|
|
model = AppointmentTemplate
|
|
template_name = 'appointments/templates/appointment_template_detail.html'
|
|
context_object_name = 'template'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return AppointmentTemplate.objects.none()
|
|
template = AppointmentTemplate.objects.filter(tenant=tenant)
|
|
|
|
return template
|
|
|
|
|
|
class AppointmentTemplateCreateView(LoginRequiredMixin, CreateView):
|
|
"""
|
|
Create new appointment template.
|
|
"""
|
|
model = AppointmentTemplate
|
|
form_class = AppointmentTemplateForm
|
|
template_name = 'appointments/templates/appointment_template_form.html'
|
|
permission_required = 'appointments.add_appointmenttemplate'
|
|
success_url = reverse_lazy('appointments:appointment_template_list')
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def form_valid(self, form):
|
|
# Set tenant
|
|
form.instance.tenant = self.request.user.tenant
|
|
response = super().form_valid(form)
|
|
|
|
# Log template creation
|
|
AuditLogger.log_event(
|
|
tenant=form.instance.tenant,
|
|
event_type='CREATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Create Appointment Template',
|
|
description=f'Created template: {self.object.name}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Appointment template "{self.object.name}" created successfully.')
|
|
return response
|
|
|
|
|
|
class AppointmentTemplateUpdateView(LoginRequiredMixin, UpdateView):
|
|
"""
|
|
Update appointment template.
|
|
"""
|
|
model = AppointmentTemplate
|
|
form_class = AppointmentTemplateForm
|
|
template_name = 'appointments/templates/appointment_template_form.html'
|
|
permission_required = 'appointments.change_appointmenttemplate'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return AppointmentTemplate.objects.none()
|
|
return AppointmentTemplate.objects.filter(tenant=tenant)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def get_success_url(self):
|
|
return reverse('appointments:appointment_template_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
|
|
# Log template update
|
|
AuditLogger.log_event(
|
|
tenant=self.object.tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Update Appointment Template',
|
|
description=f'Updated template: {self.object.name}',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(self.request, f'Appointment template "{self.object.name}" updated successfully.')
|
|
return response
|
|
|
|
|
|
class AppointmentTemplateDeleteView(LoginRequiredMixin, DeleteView):
|
|
"""
|
|
Delete appointment template.
|
|
"""
|
|
model = AppointmentTemplate
|
|
template_name = 'appointments/templates/appointment_template_confirm_delete.html'
|
|
permission_required = 'appointments.delete_appointmenttemplate'
|
|
success_url = reverse_lazy('appointments:appointment_template_list')
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return AppointmentTemplate.objects.none()
|
|
return AppointmentTemplate.objects.filter(tenant=tenant)
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
self.object = self.get_object()
|
|
template_name = self.object.name
|
|
|
|
# Log template deletion
|
|
AuditLogger.log_event(
|
|
tenant=getattr(request, 'tenant', None),
|
|
event_type='DELETE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Delete Appointment Template',
|
|
description=f'Deleted template: {template_name}',
|
|
user=request.user,
|
|
content_object=self.object,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Appointment template "{template_name}" deleted successfully.')
|
|
return super().delete(request, *args, **kwargs)
|
|
|
|
|
|
class WaitingListView(LoginRequiredMixin, ListView):
|
|
"""
|
|
List view for waiting list entries.
|
|
"""
|
|
model = WaitingList
|
|
template_name = 'appointments/waiting_list/waiting_list.html'
|
|
context_object_name = 'waiting_list'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
queryset = WaitingList.objects.filter(
|
|
tenant=tenant
|
|
).select_related(
|
|
'patient', 'department', 'provider', 'scheduled_appointment'
|
|
).order_by('priority', 'urgency_score', 'created_at')
|
|
|
|
# Apply filters
|
|
form = WaitingListFilterForm(
|
|
self.request.GET,
|
|
tenant=tenant
|
|
)
|
|
|
|
if form.is_valid():
|
|
if form.cleaned_data.get('department'):
|
|
queryset = queryset.filter(department=form.cleaned_data['department'])
|
|
|
|
if form.cleaned_data.get('specialty'):
|
|
queryset = queryset.filter(specialty=form.cleaned_data['specialty'])
|
|
|
|
if form.cleaned_data.get('priority'):
|
|
queryset = queryset.filter(priority=form.cleaned_data['priority'])
|
|
|
|
if form.cleaned_data.get('status'):
|
|
queryset = queryset.filter(status=form.cleaned_data['status'])
|
|
|
|
if form.cleaned_data.get('provider'):
|
|
queryset = queryset.filter(provider=form.cleaned_data['provider'])
|
|
|
|
if form.cleaned_data.get('date_from'):
|
|
queryset = queryset.filter(created_at__date__gte=form.cleaned_data['date_from'])
|
|
|
|
if form.cleaned_data.get('date_to'):
|
|
queryset = queryset.filter(created_at__date__lte=form.cleaned_data['date_to'])
|
|
|
|
if form.cleaned_data.get('urgency_min'):
|
|
queryset = queryset.filter(urgency_score__gte=form.cleaned_data['urgency_min'])
|
|
|
|
if form.cleaned_data.get('urgency_max'):
|
|
queryset = queryset.filter(urgency_score__lte=form.cleaned_data['urgency_max'])
|
|
|
|
return queryset
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['filter_form'] = WaitingListFilterForm
|
|
context['bulk_action_form'] = WaitingListBulkActionForm
|
|
|
|
# Statistics
|
|
waiting_list = self.get_queryset()
|
|
context['stats'] = {
|
|
'total': waiting_list.count(),
|
|
'active': waiting_list.filter(status='ACTIVE').count(),
|
|
'contacted': waiting_list.filter(status='CONTACTED').count(),
|
|
'urgent': waiting_list.filter(priority__in=['URGENT', 'STAT', 'EMERGENCY']).count(),
|
|
# 'avg_wait_days': waiting_list.aggregate(
|
|
# avg_days=Avg('created_at')
|
|
# )['avg_days'] or 0,
|
|
}
|
|
|
|
return context
|
|
|
|
|
|
class WaitingListDetailView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Detail view for waiting list entry.
|
|
"""
|
|
model = WaitingList
|
|
template_name = 'appointments/waiting_list/waiting_list_detail.html'
|
|
context_object_name = 'entry'
|
|
|
|
def get_queryset(self):
|
|
return WaitingList.objects.filter(
|
|
tenant=getattr(self.request.user, 'tenant', None)
|
|
).select_related(
|
|
'patient', 'department', 'provider', 'scheduled_appointment',
|
|
'created_by', 'removed_by'
|
|
)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
|
|
# Get contact logs
|
|
context['contact_logs'] = self.object.contact_logs.all().order_by('-contact_date')
|
|
|
|
# Contact log form
|
|
context['contact_form'] = WaitingListContactLogForm()
|
|
|
|
# Calculate position and wait time
|
|
self.object.update_position()
|
|
context['estimated_wait_time'] = self.object.estimate_wait_time()
|
|
|
|
return context
|
|
|
|
|
|
class WaitingListCreateView(LoginRequiredMixin, CreateView):
|
|
"""
|
|
Create view for waiting list entry.
|
|
"""
|
|
model = WaitingList
|
|
form_class = WaitingListForm
|
|
template_name = 'appointments/waiting_list/waiting_list_form.html'
|
|
success_url = reverse_lazy('appointments:waiting_list')
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['tenant'] = self.request.user.tenant
|
|
return kwargs
|
|
|
|
def form_valid(self, form):
|
|
form.instance.tenant = self.request.user.tenant
|
|
form.instance.created_by = self.request.user
|
|
|
|
response = super().form_valid(form)
|
|
self.object.update_position()
|
|
self.object.estimated_wait_time = self.object.estimate_wait_time()
|
|
self.object.save(update_fields=['position', 'estimated_wait_time'])
|
|
|
|
messages.success(
|
|
self.request,
|
|
f"Patient {self.object.patient.get_full_name()} has been added to the waiting list."
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
class WaitingListUpdateView(LoginRequiredMixin, UpdateView):
|
|
"""
|
|
Update view for waiting list entry.
|
|
"""
|
|
model = WaitingList
|
|
form_class = WaitingListForm
|
|
template_name = 'appointments/waiting_list/waiting_list_form.html'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
return WaitingList.objects.filter(
|
|
tenant=tenant
|
|
)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['tenant'] = getattr(self.request.user, 'tenant', None)
|
|
return kwargs
|
|
|
|
def get_success_url(self):
|
|
return reverse('appointments:waiting_list_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def form_valid(self, form):
|
|
# Update position if priority or urgency changed
|
|
old_priority = WaitingList.objects.get(pk=self.object.pk).priority
|
|
old_urgency = WaitingList.objects.get(pk=self.object.pk).urgency_score
|
|
|
|
response = super().form_valid(form)
|
|
|
|
if (form.instance.priority != old_priority or
|
|
form.instance.urgency_score != old_urgency):
|
|
self.object.update_position()
|
|
self.object.estimated_wait_time = self.object.estimate_wait_time()
|
|
self.object.save(update_fields=['position', 'estimated_wait_time'])
|
|
|
|
messages.success(self.request, "Waiting list entry has been updated.")
|
|
return response
|
|
|
|
|
|
class WaitingListDeleteView(LoginRequiredMixin, DeleteView):
|
|
"""
|
|
Delete view for waiting list entry.
|
|
"""
|
|
model = WaitingList
|
|
template_name = 'appointments/waiting_list/waiting_list_confirm_delete.html'
|
|
success_url = reverse_lazy('appointments:waiting_list')
|
|
|
|
def get_queryset(self):
|
|
return WaitingList.objects.filter(
|
|
tenant=getattr(self.request.user, 'tenant', None)
|
|
)
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
self.object = self.get_object()
|
|
patient_name = self.object.patient.get_full_name()
|
|
|
|
# Mark as removed instead of deleting
|
|
self.object.status = 'CANCELLED'
|
|
self.object.removal_reason = 'PROVIDER_CANCELLED'
|
|
self.object.removed_at = timezone.now()
|
|
self.object.removed_by = request.user
|
|
self.object.save()
|
|
|
|
messages.success(
|
|
request,
|
|
f"Waiting list entry for {patient_name} has been cancelled."
|
|
)
|
|
|
|
return redirect(self.success_url)
|
|
|
|
|
|
class SchedulingCalendarView(LoginRequiredMixin, ListView):
|
|
"""
|
|
Calendar view for scheduling appointments.
|
|
"""
|
|
model = AppointmentRequest
|
|
template_name = 'appointments/scheduling_calendar.html'
|
|
context_object_name = 'appointments'
|
|
paginate_by = 20
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['appointments'] = AppointmentRequest.objects.filter(
|
|
tenant=self.request.user.tenant,
|
|
status='SCHEDULED'
|
|
).select_related('patient', 'provider').order_by('-scheduled_datetime')
|
|
return context
|
|
|
|
|
|
class TelemedicineView(LoginRequiredMixin, ListView):
|
|
"""
|
|
Telemedicine appointments view.
|
|
"""
|
|
model = TelemedicineSession
|
|
template_name = 'appointments/telemedicine/telemedicine.html'
|
|
context_object_name = 'sessions'
|
|
paginate_by = 20
|
|
|
|
def get_queryset(self):
|
|
return TelemedicineSession.objects.filter(
|
|
appointment__tenant=self.request.user.tenant
|
|
).order_by('-created_at')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['telemedicine_appointments'] = AppointmentRequest.objects.filter(
|
|
tenant=self.request.user.tenant,
|
|
appointment_type='TELEMEDICINE'
|
|
)
|
|
return context
|
|
|
|
|
|
class SmartSchedulingView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Smart scheduling interface view.
|
|
"""
|
|
template_name = 'appointments/scheduling/smart_scheduling.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
|
|
if tenant:
|
|
# Get active providers
|
|
context['providers'] = User.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True,
|
|
employee_profile__role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER']
|
|
).order_by('last_name', 'first_name')
|
|
|
|
# Get appointment types
|
|
context['appointment_types'] = AppointmentTemplate.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
).order_by('specialty', 'name')
|
|
|
|
# Get recent patients
|
|
context['recent_patients'] = PatientProfile.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
).order_by('-updated_at')[:20]
|
|
|
|
return context
|
|
|
|
|
|
class AdvancedQueueManagementView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Advanced queue management interface with real-time updates.
|
|
Main interface for Phase 11 queue management.
|
|
"""
|
|
model = WaitingQueue
|
|
template_name = 'appointments/queue/advanced_queue_management.html'
|
|
context_object_name = 'queue'
|
|
|
|
def get_queryset(self):
|
|
tenant = self.request.user.tenant
|
|
if not tenant:
|
|
return WaitingQueue.objects.none()
|
|
return WaitingQueue.objects.filter(tenant=tenant)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
queue = self.get_object()
|
|
|
|
from appointments.queue import AdvancedQueueEngine
|
|
engine = AdvancedQueueEngine(queue)
|
|
|
|
# Get queue status
|
|
context['queue_status'] = engine.get_queue_status()
|
|
|
|
# Get queue configuration
|
|
from appointments.models import QueueConfiguration
|
|
config, created = QueueConfiguration.objects.get_or_create(queue=queue)
|
|
context['queue_config'] = config
|
|
|
|
# Get available patients for adding to queue
|
|
context['available_patients'] = PatientProfile.objects.filter(
|
|
tenant=self.request.user.tenant,
|
|
is_active=True
|
|
).order_by('last_name', 'first_name')[:50]
|
|
|
|
# Get available providers
|
|
context['available_providers'] = User.objects.filter(
|
|
tenant=self.request.user.tenant,
|
|
is_active=True,
|
|
employee_profile__role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER']
|
|
).order_by('last_name', 'first_name')
|
|
|
|
return context
|
|
|
|
|
|
@login_required
|
|
def add_contact_log(request, pk):
|
|
"""
|
|
Add contact log entry for waiting list.
|
|
"""
|
|
entry = get_object_or_404(
|
|
WaitingList,
|
|
pk=pk,
|
|
tenant=getattr(request.user, 'tenant', None)
|
|
)
|
|
|
|
if request.method == 'POST':
|
|
form = WaitingListContactLogForm(request.POST)
|
|
if form.is_valid():
|
|
contact_log = form.save(commit=False)
|
|
contact_log.waiting_list_entry = entry
|
|
contact_log.contacted_by = request.user
|
|
contact_log.save()
|
|
|
|
# Update waiting list entry
|
|
entry.last_contacted = timezone.now()
|
|
entry.contact_attempts += 1
|
|
|
|
if contact_log.appointment_offered:
|
|
entry.appointments_offered += 1
|
|
entry.last_offer_date = timezone.now()
|
|
|
|
if contact_log.patient_response == 'DECLINED':
|
|
entry.appointments_declined += 1
|
|
elif contact_log.patient_response == 'ACCEPTED':
|
|
entry.status = 'SCHEDULED'
|
|
|
|
if contact_log.contact_outcome == 'SUCCESSFUL':
|
|
entry.status = 'CONTACTED'
|
|
|
|
entry.save()
|
|
|
|
messages.success(request, "Contact log has been added.")
|
|
|
|
if request.headers.get('HX-Request'):
|
|
return render(request, 'appointments/partials/contact_log_list.html', {
|
|
'contact_logs': entry.contact_logs.all().order_by('-contact_date')
|
|
})
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
|
|
return redirect('appointments:waiting_list_detail', pk=pk)
|
|
|
|
|
|
@login_required
|
|
def waiting_list_bulk_action(request):
|
|
"""
|
|
Handle bulk actions on waiting list entries.
|
|
"""
|
|
if request.method == 'POST':
|
|
form = WaitingListBulkActionForm(
|
|
request.POST,
|
|
tenant=getattr(request.user, 'tenant', None)
|
|
)
|
|
|
|
if form.is_valid():
|
|
action = form.cleaned_data['action']
|
|
entry_ids = request.POST.getlist('selected_entries')
|
|
|
|
if not entry_ids:
|
|
messages.error(request, "No entries selected.")
|
|
return redirect('appointments:waiting_list')
|
|
|
|
entries = WaitingList.objects.filter(
|
|
id__in=entry_ids,
|
|
tenant=getattr(request.user, 'tenant', None)
|
|
)
|
|
|
|
if action == 'contact':
|
|
entries.update(
|
|
status='CONTACTED',
|
|
last_contacted=timezone.now(),
|
|
contact_attempts=F('contact_attempts') + 1
|
|
)
|
|
messages.success(request, f"{entries.count()} entries marked as contacted.")
|
|
|
|
elif action == 'cancel':
|
|
entries.update(
|
|
status='CANCELLED',
|
|
removal_reason='PROVIDER_CANCELLED',
|
|
removed_at=timezone.now(),
|
|
removed_by=request.user
|
|
)
|
|
messages.success(request, f"{entries.count()} entries cancelled.")
|
|
|
|
elif action == 'update_priority':
|
|
new_priority = form.cleaned_data.get('new_priority')
|
|
if new_priority:
|
|
entries.update(priority=new_priority)
|
|
# Update positions for affected entries
|
|
for entry in entries:
|
|
entry.update_position()
|
|
messages.success(request, f"{entries.count()} entries priority updated.")
|
|
|
|
elif action == 'transfer_provider':
|
|
transfer_provider = form.cleaned_data.get('transfer_provider')
|
|
if transfer_provider:
|
|
entries.update(provider=transfer_provider)
|
|
messages.success(request, f"{entries.count()} entries transferred.")
|
|
|
|
elif action == 'export':
|
|
# Export functionality would be implemented here
|
|
messages.info(request, "Export functionality coming soon.")
|
|
|
|
return redirect('appointments:waiting_list')
|
|
|
|
|
|
@login_required
|
|
def waiting_list_stats(request):
|
|
"""
|
|
HTMX endpoint for waiting list statistics.
|
|
"""
|
|
tenant = getattr(request.user, 'tenant', None)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant'})
|
|
|
|
waiting_list = WaitingList.objects.filter(tenant=tenant)
|
|
|
|
stats = {
|
|
'total': waiting_list.count(),
|
|
'active': waiting_list.filter(status='ACTIVE').count(),
|
|
'contacted': waiting_list.filter(status='CONTACTED').count(),
|
|
'scheduled': waiting_list.filter(status='SCHEDULED').count(),
|
|
'urgent': waiting_list.filter(priority__in=['URGENT', 'STAT', 'EMERGENCY']).count(),
|
|
'overdue_contact': sum(1 for entry in waiting_list.filter(status='ACTIVE') if entry.is_overdue_contact),
|
|
# 'avg_wait_days': int(waiting_list.aggregate(
|
|
# avg_days=Avg(timezone.now().date() - F('created_at__date'))
|
|
# )['avg_days'] or 0),
|
|
}
|
|
|
|
return JsonResponse(stats)
|
|
|
|
|
|
@login_required
|
|
def appointment_search(request):
|
|
"""
|
|
HTMX view for appointment search.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
search_query = request.GET.get('search', '')
|
|
queryset = AppointmentRequest.objects.filter(tenant=tenant)
|
|
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(patient__first_name__icontains=search_query) |
|
|
Q(patient__last_name__icontains=search_query) |
|
|
Q(provider__first_name__icontains=search_query) |
|
|
Q(provider__last_name__icontains=search_query) |
|
|
Q(reason__icontains=search_query)
|
|
)
|
|
|
|
appointments = queryset.order_by('-scheduled_datetime')[:20]
|
|
|
|
return render(request, 'appointments/partials/appointment_list.html', {
|
|
'appointments': appointments
|
|
})
|
|
|
|
|
|
@login_required
|
|
def appointment_stats(request):
|
|
"""
|
|
HTMX view for appointment statistics.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
today = timezone.now().date()
|
|
|
|
# Calculate appointment statistics
|
|
stats = {
|
|
'total_appointments': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
).count(),
|
|
'total_appointments_today': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today
|
|
).count(),
|
|
'pending_appointments': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today,
|
|
status='PENDING'
|
|
).count(),
|
|
'completed_appointments': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today,
|
|
status='COMPLETED'
|
|
).count(),
|
|
'cancelled_appointments': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today,
|
|
status='CANCELLED'
|
|
).count(),
|
|
'telemedicine_sessions': TelemedicineSession.objects.filter(
|
|
appointment__tenant=tenant,
|
|
appointment__scheduled_datetime__date=today
|
|
).count(),
|
|
'active_queues': WaitingQueue.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
).count(),
|
|
'total_queue_entries': QueueEntry.objects.filter(
|
|
queue__tenant=tenant,
|
|
status='WAITING'
|
|
).count(),
|
|
}
|
|
|
|
return render(request, 'appointments/partials/appointment_stats.html', {'stats': stats})
|
|
|
|
|
|
@login_required
|
|
def available_slots(request):
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return render(request, 'appointments/partials/available_slots.html', status=400)
|
|
|
|
provider_id = request.GET.get('new_provider')
|
|
date_str = request.GET.get('new_date')
|
|
exclude_id = request.GET.get('exclude_appointment')
|
|
|
|
if not provider_id or not date_str:
|
|
return render(request, 'appointments/partials/available_slots.html', status=400)
|
|
|
|
try:
|
|
selected_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
return render(request, 'appointments/partials/available_slots.html', status=400)
|
|
provider = get_object_or_404(User, pk=provider_id)
|
|
slots = SlotAvailability.objects.filter(
|
|
provider=provider,
|
|
provider__tenant=tenant,
|
|
date=selected_date,
|
|
).order_by('start_time')
|
|
|
|
current_excluded = slots.exclude(pk=exclude_id)
|
|
|
|
return render(request, 'appointments/partials/available_slots.html', {'slots': current_excluded}, status=200)
|
|
|
|
|
|
@login_required
|
|
def queue_status(request, queue_id):
|
|
"""
|
|
HTMX view for queue status.
|
|
Shows queue entries plus aggregated stats with DB-side wait calculations.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
|
|
|
queue_entries = (
|
|
QueueEntry.objects
|
|
.filter(queue=queue)
|
|
.annotate(
|
|
wait_duration=Case(
|
|
When(status='WAITING', then=Now() - F('joined_at')),
|
|
When(served_at__isnull=False, then=F('served_at') - F('joined_at')),
|
|
default=Value(None),
|
|
output_field=DurationField(),
|
|
),
|
|
)
|
|
.annotate(
|
|
wait_minutes=Case(
|
|
When(wait_duration__isnull=False,
|
|
then=ExpressionWrapper(
|
|
F('wait_duration') / Value(timedelta(minutes=1)),
|
|
output_field=FloatField()
|
|
)),
|
|
default=Value(None),
|
|
output_field=FloatField(),
|
|
),
|
|
waiting_rank=Case(
|
|
When(status='WAITING', then=F('queue_position')),
|
|
default=Value(None), output_field=IntegerField()
|
|
),
|
|
)
|
|
.select_related('assigned_provider', 'patient', 'appointment')
|
|
.order_by('queue_position', 'updated_at')
|
|
)
|
|
|
|
# Aggregates & stats
|
|
total_entries = queue_entries.count()
|
|
waiting_entries = queue_entries.filter(status='WAITING').count()
|
|
called_entries = queue_entries.filter(status='CALLED').count()
|
|
in_service_entries = queue_entries.filter(status='IN_SERVICE').count()
|
|
completed_entries = queue_entries.filter(status='COMPLETED').count()
|
|
|
|
avg_completed_wait = (
|
|
queue_entries
|
|
.filter(status='COMPLETED')
|
|
.aggregate(avg_wait=Avg('wait_minutes'))
|
|
.get('avg_wait') or 0
|
|
)
|
|
|
|
stats = {
|
|
'total_entries': total_entries,
|
|
'waiting_entries': waiting_entries,
|
|
'called_entries': called_entries,
|
|
'in_service_entries': in_service_entries,
|
|
'completed_entries': completed_entries,
|
|
# Average from COMPLETED cases only (rounded to 1 decimal)
|
|
'average_wait_time_minutes': round(avg_completed_wait, 1),
|
|
# Quick estimate based on queue config
|
|
'estimated_queue_wait_minutes': waiting_entries * queue.average_service_time_minutes,
|
|
}
|
|
|
|
return render(request, 'appointments/partials/queue_status.html', {
|
|
'queue': queue,
|
|
'queue_entries': queue_entries, # each has .wait_minutes
|
|
'stats': stats,
|
|
})
|
|
|
|
|
|
@login_required
|
|
def calendar_appointments(request):
|
|
"""
|
|
HTMX view for calendar appointments.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
date_str = request.GET.get('date')
|
|
provider_id = request.GET.get('provider_id')
|
|
|
|
if not date_str:
|
|
date_str = timezone.now().date().strftime('%Y-%m-%d')
|
|
|
|
try:
|
|
selected_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
selected_date = timezone.now().date()
|
|
|
|
# Get appointments for the selected date
|
|
queryset = AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=selected_date
|
|
)
|
|
|
|
if provider_id:
|
|
queryset = queryset.filter(provider__id=provider_id)
|
|
|
|
appointments = queryset.order_by('scheduled_datetime')
|
|
# providers = queryset.order_by('provider__first_name')
|
|
|
|
|
|
return render(request, 'appointments/partials/calendar_appointments.html', {
|
|
'appointments': appointments,
|
|
'selected_date': selected_date,
|
|
# 'providers': providers
|
|
})
|
|
|
|
|
|
@login_required
|
|
def confirm_appointment(request, pk):
|
|
"""
|
|
Confirm an appointment.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('appointments:appointment_request_list')
|
|
|
|
appointment = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
|
|
|
appointment.status = 'CONFIRMED'
|
|
appointment.save()
|
|
|
|
# Log confirmation
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Confirm Appointment',
|
|
description=f'Confirmed appointment: {appointment.patient} with {appointment.provider}',
|
|
user=request.user,
|
|
content_object=appointment,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Appointment for {appointment.patient} confirmed successfully.')
|
|
return redirect('appointments:appointment_request_detail', pk=pk)
|
|
|
|
|
|
@login_required
|
|
def reschedule_appointment(request, pk):
|
|
tenant = request.user.tenant
|
|
appointment = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
|
providers = User.objects.filter(
|
|
tenant=tenant, employee_profile__role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT']
|
|
).order_by('last_name', 'first_name')
|
|
|
|
if request.method == 'POST':
|
|
new_date_str = request.POST.get('new_date')
|
|
new_time_str = request.POST.get('new_time')
|
|
new_provider_id = request.POST.get('new_provider') or None
|
|
reschedule_reason = request.POST.get('reschedule_reason') or ''
|
|
reschedule_notes = request.POST.get('reschedule_notes') or ''
|
|
notify_patient = bool(request.POST.get('notify_patient'))
|
|
|
|
# Validate and parse
|
|
try:
|
|
new_date = datetime.strptime(new_date_str, '%Y-%m-%d').date()
|
|
new_time = datetime.strptime(new_time_str, '%H:%M').time()
|
|
except (TypeError, ValueError):
|
|
messages.error(request, 'Please provide a valid date and time.')
|
|
return render(request, 'appointments/reschedule_appointment.html', {
|
|
'appointment': appointment,
|
|
'providers': providers,
|
|
'today': timezone.localdate(), # for min attr if you use it
|
|
})
|
|
|
|
appointment.scheduled_date = new_date
|
|
appointment.scheduled_time = new_time
|
|
if new_provider_id:
|
|
appointment.provider_id = new_provider_id
|
|
appointment.status = 'RESCHEDULED'
|
|
appointment.reschedule_reason = reschedule_reason
|
|
appointment.reschedule_notes = reschedule_notes
|
|
appointment.save()
|
|
|
|
# optionally send notifications if notify_patient is True
|
|
|
|
messages.success(request, 'Appointment has been rescheduled.')
|
|
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Reschedule Appointment',
|
|
description=f'Rescheduled appointment: {appointment.patient} with {appointment.provider}',
|
|
user=request.user,
|
|
content_object=appointment,
|
|
request=request
|
|
)
|
|
|
|
return redirect('appointments:appointment_detail', pk=appointment.pk)
|
|
|
|
return render(request, 'appointments/reschedule_appointment.html', {
|
|
'appointment': appointment,
|
|
'providers': providers,
|
|
'today': timezone.localdate(),
|
|
})
|
|
|
|
|
|
@login_required
|
|
def cancel_appointment(request, pk):
|
|
"""
|
|
Complete an appointment.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('appointments:appointment_request_list')
|
|
|
|
appointment = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
|
if appointment.status == 'SCHEDULED':
|
|
appointment.status = 'CANCELLED'
|
|
# appointment.actual_end_time = timezone.now()
|
|
appointment.save()
|
|
|
|
# Log completion
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Cancel Appointment',
|
|
description=f'Cancelled appointment: {appointment.patient} with {appointment.provider}',
|
|
user=request.user,
|
|
content_object=appointment,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Appointment for {appointment.patient} cancelled successfully.')
|
|
return redirect('appointments:appointment_detail', pk=pk)
|
|
|
|
return render(request, 'appointments/cancel_appointment.html', {
|
|
'appointment': appointment,
|
|
})
|
|
|
|
|
|
@login_required
|
|
def start_appointment(request, pk):
|
|
"""
|
|
Start an appointment.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('appointments:appointment_request_list')
|
|
|
|
appointment = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
|
|
|
appointment.status = 'IN_PROGRESS'
|
|
appointment.actual_start_time = timezone.now()
|
|
appointment.save()
|
|
|
|
# Log start
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Start Appointment',
|
|
description=f'Started appointment: {appointment.patient} with {appointment.provider}',
|
|
user=request.user,
|
|
content_object=appointment,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Appointment for {appointment.patient} started successfully.')
|
|
return redirect('appointments:appointment_request_detail', pk=pk)
|
|
|
|
|
|
@login_required
|
|
def complete_appointment(request, pk):
|
|
"""
|
|
Complete an appointment.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('appointments:appointment_request_list')
|
|
|
|
appointment = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
|
|
|
appointment.status = 'COMPLETED'
|
|
appointment.actual_end_time = timezone.now()
|
|
appointment.save()
|
|
|
|
# Log completion
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Complete Appointment',
|
|
description=f'Completed appointment: {appointment.patient} with {appointment.provider}',
|
|
user=request.user,
|
|
content_object=appointment,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Appointment for {appointment.patient} completed successfully.')
|
|
return redirect('appointments:appointment_request_detail', pk=pk)
|
|
|
|
|
|
@login_required
|
|
def next_in_queue(request, queue_id):
|
|
"""
|
|
Call next patient in queue.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
|
|
|
# Get next waiting entry
|
|
next_entry = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING'
|
|
).order_by('queue_position', 'called_at').first()
|
|
|
|
if next_entry:
|
|
next_entry.status = 'IN_SERVICE'
|
|
next_entry.called_at = timezone.now()
|
|
next_entry.save()
|
|
messages.success(request, f"Patient has been called in for appointment.")
|
|
# Log queue progression
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Call Next in Queue',
|
|
description=f'Called next patient: {next_entry.patient} in queue: {queue.name}',
|
|
user=request.user,
|
|
content_object=next_entry,
|
|
request=request
|
|
)
|
|
|
|
return redirect('appointments:waiting_queue_detail', pk=queue.pk)
|
|
else:
|
|
messages.error(request, f"No more patients in queue.")
|
|
return redirect('appointments:waiting_queue_detail', pk=queue.pk)
|
|
|
|
|
|
@login_required
|
|
def check_in_patient(request, appointment_id):
|
|
"""
|
|
Check in a patient for their appointment.
|
|
"""
|
|
appointment = get_object_or_404(AppointmentRequest,
|
|
pk=appointment_id,
|
|
tenant=request.user.tenant
|
|
)
|
|
|
|
appointment.status = 'CHECKED_IN'
|
|
appointment.save()
|
|
|
|
messages.success(request, f"Patient {appointment.patient} has been checked in.")
|
|
return redirect('appointments:waiting_queue_list')
|
|
|
|
|
|
@login_required
|
|
def complete_queue_entry(request, pk):
|
|
"""
|
|
Complete a queue entry.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
queue_entry = get_object_or_404(
|
|
QueueEntry,
|
|
pk=pk,
|
|
queue__tenant=tenant
|
|
)
|
|
|
|
queue_entry.status = 'COMPLETED'
|
|
queue_entry.completed_at = timezone.now()
|
|
|
|
# Calculate actual wait time
|
|
if queue_entry.called_at:
|
|
wait_time = (queue_entry.called_at - queue_entry.joined_at).total_seconds() / 60
|
|
queue_entry.actual_wait_time_minutes = int(wait_time)
|
|
|
|
queue_entry.save()
|
|
messages.success(request, f"Queue entry {queue_entry.pk} completed successfully.")
|
|
|
|
# Log completion
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Complete Queue Entry',
|
|
description=f'Completed queue entry for: {queue_entry.patient}',
|
|
user=request.user,
|
|
content_object=queue_entry,
|
|
request=request
|
|
)
|
|
|
|
return redirect('appointments:waiting_queue_detail', pk=queue_entry.queue.pk)
|
|
|
|
|
|
@login_required
|
|
def start_telemedicine_session(request, pk):
|
|
"""
|
|
Start a telemedicine session.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('appointments:telemedicine_session_list')
|
|
|
|
session = get_object_or_404(
|
|
TelemedicineSession,
|
|
pk=pk,
|
|
appointment__tenant=tenant
|
|
)
|
|
|
|
session.status = 'IN_PROGRESS'
|
|
session.start_time = timezone.now()
|
|
session.save()
|
|
|
|
# Update appointment status
|
|
session.appointment.status = 'IN_PROGRESS'
|
|
session.appointment.save()
|
|
|
|
# Log session start
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Start Telemedicine Session',
|
|
description=f'Started telemedicine session for: {session.appointment}',
|
|
user=request.user,
|
|
content_object=session,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Telemedicine session started successfully.')
|
|
return redirect('appointments:telemedicine_session_detail', pk=session.pk)
|
|
|
|
|
|
@login_required
|
|
def end_telemedicine_session(request, pk):
|
|
"""
|
|
End a telemedicine session.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('appointments:telemedicine_session_list')
|
|
|
|
session = get_object_or_404(
|
|
TelemedicineSession,
|
|
pk=pk,
|
|
appointment__tenant=tenant
|
|
)
|
|
|
|
session.status = 'COMPLETED'
|
|
session.end_time = timezone.now()
|
|
session.save()
|
|
|
|
# Update appointment status
|
|
session.appointment.status = 'COMPLETED'
|
|
session.appointment.save()
|
|
|
|
# Log session end
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='End Telemedicine Session',
|
|
description=f'Ended telemedicine session for: {session.appointment}',
|
|
user=request.user,
|
|
content_object=session,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, 'Telemedicine session ended successfully.')
|
|
return redirect('appointments:telemedicine_session_detail', pk=session.pk)
|
|
|
|
|
|
@login_required
|
|
def cancel_telemedicine_session(request, pk):
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('appointments:telemedicine_session_list')
|
|
|
|
session = get_object_or_404(TelemedicineSession, pk=pk)
|
|
session.status = 'CANCELLED'
|
|
session.save()
|
|
|
|
# Update appointment status
|
|
session.appointment.status = 'CANCELLED'
|
|
session.appointment.save()
|
|
|
|
# Log session start
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Cancel Telemedicine Session',
|
|
description='Cancelled telemedicine session',
|
|
user=request.user,
|
|
content_object=session,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, 'Telemedicine session cancelled successfully.')
|
|
return redirect('appointments:telemedicine_session_detail', pk=session.pk)
|
|
|
|
|
|
@login_required
|
|
def calendar_view(request):
|
|
"""Renders the calendar page"""
|
|
return render(request, "appointments/calendar.html")
|
|
|
|
|
|
def queue_display_view(request, pk):
|
|
"""
|
|
Public queue display view for patients to see their waiting numbers.
|
|
No login required - this is a public display screen.
|
|
"""
|
|
queue = get_object_or_404(WaitingQueue, pk=pk)
|
|
|
|
# Get current patient being served
|
|
current_patient = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='IN_SERVICE'
|
|
).order_by('called_at').first()
|
|
|
|
# Get waiting patients
|
|
waiting_patients = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING'
|
|
).order_by('queue_position', 'joined_at')[:20] # Show max 20 waiting
|
|
|
|
# Calculate statistics
|
|
today = timezone.now().date()
|
|
|
|
stats = {
|
|
'total_waiting': QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING'
|
|
).count(),
|
|
'avg_wait_time': queue.average_service_time_minutes,
|
|
'served_today': QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='COMPLETED',
|
|
served_at__date=today
|
|
).count(),
|
|
}
|
|
|
|
return render(request, 'appointments/queue/queue_display.html', {
|
|
'queue': queue,
|
|
'current_patient': current_patient,
|
|
'waiting_patients': waiting_patients,
|
|
'stats': stats,
|
|
})
|
|
|
|
|
|
def queue_monitor_view(request, pk):
|
|
"""
|
|
Public queue monitor view - large screen display for waiting room.
|
|
No login required - this is a public display screen (TV/monitor).
|
|
Shows current patient being served and next patients in queue.
|
|
Auto-refreshes every 30 seconds and supports WebSocket updates.
|
|
"""
|
|
queue = get_object_or_404(WaitingQueue, pk=pk)
|
|
|
|
# Get current patient being served
|
|
current_patient = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='IN_SERVICE'
|
|
).order_by('called_at').first()
|
|
|
|
# Get waiting patients (next 10 in queue)
|
|
waiting_patients = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING'
|
|
).select_related('patient').order_by('queue_position', 'joined_at')[:10]
|
|
|
|
# Add wait time display for each patient
|
|
for patient in waiting_patients:
|
|
if patient.joined_at:
|
|
wait_duration = timezone.now() - patient.joined_at
|
|
minutes = int(wait_duration.total_seconds() / 60)
|
|
if minutes < 60:
|
|
patient.wait_time_display = f"{minutes} min"
|
|
else:
|
|
hours = minutes // 60
|
|
mins = minutes % 60
|
|
patient.wait_time_display = f"{hours}h {mins}m"
|
|
|
|
# Calculate statistics
|
|
today = timezone.now().date()
|
|
|
|
stats = {
|
|
'total_waiting': QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING'
|
|
).count(),
|
|
'avg_wait_time': queue.average_service_time_minutes,
|
|
'served_today': QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='COMPLETED',
|
|
served_at__date=today
|
|
).count(),
|
|
}
|
|
|
|
return render(request, 'appointments/queue/queue_monitor.html', {
|
|
'queue': queue,
|
|
'current_patient': current_patient,
|
|
'waiting_patients': waiting_patients,
|
|
'stats': stats,
|
|
})
|
|
|
|
|
|
@login_required
|
|
@require_GET
|
|
def calendar_events(request):
|
|
"""
|
|
FullCalendar event feed (GET /calendar/events?start=..&end=..[&provider_id=&status=...])
|
|
FullCalendar sends ISO timestamps; we return a list of event dicts.
|
|
"""
|
|
STATUS_COLORS = {
|
|
"PENDING": {"bg": "#f59c1a", "border": "#d08916"},
|
|
"CONFIRMED": {"bg": "#49b6d6", "border": "#3f9db9"},
|
|
"CHECKED_IN": {"bg": "#348fe2", "border": "#2c79bf"},
|
|
"IN_PROGRESS": {"bg": "#00acac", "border": "#009494"},
|
|
"COMPLETED": {"bg": "#32a932", "border": "#298a29"},
|
|
"CANCELLED": {"bg": "#ff5b57", "border": "#d64d4a"},
|
|
"NO_SHOW": {"bg": "#6c757d", "border": "#5a636b"},
|
|
}
|
|
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return JsonResponse([], safe=False)
|
|
|
|
start = request.GET.get("start")
|
|
end = request.GET.get("end")
|
|
provider_id = request.GET.get("provider_id")
|
|
status = request.GET.get("status")
|
|
|
|
if not start or not end:
|
|
return HttpResponseBadRequest("Missing start/end")
|
|
|
|
# Parse (FullCalendar uses ISO 8601)
|
|
# They can include timezone; parse_datetime handles offsets.
|
|
start_dt = parse_datetime(start)
|
|
end_dt = parse_datetime(end)
|
|
if not start_dt or not end_dt:
|
|
return HttpResponseBadRequest("Invalid start/end")
|
|
|
|
qs = AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__gte=start_dt,
|
|
scheduled_datetime__lt=end_dt,
|
|
).select_related("patient", "provider")
|
|
|
|
if provider_id:
|
|
qs = qs.filter(provider_id=provider_id)
|
|
if status:
|
|
qs = qs.filter(status=status)
|
|
|
|
events = []
|
|
for appt in qs:
|
|
color = STATUS_COLORS.get(appt.status, {"bg": "#495057", "border": "#3e444a"})
|
|
title = f"{appt.patient.get_full_name()} • {appt.get_appointment_type_display()}"
|
|
if appt.is_telemedicine:
|
|
title = "📹 " + title
|
|
|
|
# If you store end time separately, use it; else estimate duration (e.g., 30 min)
|
|
end_time = getattr(appt, "end_datetime", None)
|
|
if not end_time:
|
|
end_time = appt.scheduled_datetime + timedelta(minutes=getattr(appt, "duration_minutes", 30))
|
|
|
|
events.append({
|
|
"id": str(appt.pk),
|
|
"title": title,
|
|
"start": appt.scheduled_datetime.isoformat(),
|
|
"end": end_time.isoformat(),
|
|
"backgroundColor": color["bg"],
|
|
"borderColor": color["border"],
|
|
"textColor": "#fff",
|
|
"extendedProps": {
|
|
"status": appt.status,
|
|
"provider": appt.provider.get_full_name() if appt.provider_id else "",
|
|
"chief_complaint": (appt.chief_complaint or "")[:120],
|
|
"telemedicine": appt.is_telemedicine,
|
|
},
|
|
})
|
|
|
|
return JsonResponse(events, safe=False)
|
|
|
|
|
|
@login_required
|
|
def appointment_detail_card(request, pk):
|
|
tenant = request.user.tenant
|
|
"""HTMX partial with appointment quick details for the sidebar/modal."""
|
|
appt = get_object_or_404(AppointmentRequest.objects.select_related("patient","provider"), pk=pk, tenant=tenant)
|
|
return render(request, "appointments/partials/appointment_detail_card.html", {"appointment": appt})
|
|
|
|
|
|
@login_required
|
|
@permission_required("appointments.change_appointment")
|
|
@require_POST
|
|
def appointment_reschedule(request, pk):
|
|
"""
|
|
Handle drag/drop or resize from FullCalendar.
|
|
Expect JSON: {"start":"...", "end":"..."} ISO strings (local/offset).
|
|
"""
|
|
tenant = request.user.tenant
|
|
appt = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
|
try:
|
|
data = request.POST if request.content_type == "application/x-www-form-urlencoded" else request.json()
|
|
except Exception:
|
|
data = {}
|
|
|
|
start = data.get("start")
|
|
end = data.get("end")
|
|
start_dt = parse_datetime(start) if start else None
|
|
end_dt = parse_datetime(end) if end else None
|
|
if not start_dt or not end_dt:
|
|
return HttpResponseBadRequest("Invalid start/end")
|
|
|
|
appt.scheduled_datetime = start_dt
|
|
if hasattr(appt, "end_datetime"):
|
|
appt.end_datetime = end_dt
|
|
elif hasattr(appt, "duration_minutes"):
|
|
appt.duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
|
|
appt.save(update_fields=["scheduled_datetime"] + (["end_datetime"] if hasattr(appt,"end_datetime") else ["duration_minutes"]))
|
|
|
|
return JsonResponse({"ok": True})
|
|
|
|
|
|
@login_required
|
|
def provider_availability(request):
|
|
"""
|
|
HTMX view for provider availability.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
provider_id = request.GET.get('provider_id')
|
|
date_str = request.GET.get('date')
|
|
|
|
if not provider_id:
|
|
return render(request, 'appointments/partials/provider_availability.html', {
|
|
'availability': None,
|
|
'provider': None,
|
|
'selected_date': None
|
|
})
|
|
|
|
try:
|
|
provider = User.objects.get(id=provider_id, tenant=tenant)
|
|
except User.DoesNotExist:
|
|
return JsonResponse({'error': 'Provider not found'}, status=404)
|
|
|
|
if not date_str:
|
|
date_str = timezone.now().date().strftime('%Y-%m-%d')
|
|
|
|
try:
|
|
selected_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
selected_date = timezone.now().date()
|
|
|
|
# Get availability for the selected date
|
|
start_of_day = timezone.make_aware(datetime.combine(selected_date, datetime.min.time()))
|
|
end_of_day = timezone.make_aware(datetime.combine(selected_date, datetime.max.time()))
|
|
|
|
# Regular availability slots
|
|
availability = SlotAvailability.objects.filter(
|
|
tenant=tenant,
|
|
provider=provider,
|
|
start_time__lt=end_of_day,
|
|
end_time__gt=start_of_day,
|
|
is_available=True
|
|
).order_by('start_time')
|
|
|
|
# Recurring availability (based on day of week)
|
|
day_of_week = selected_date.weekday() # 0-6, Monday is 0
|
|
recurring_availability = SlotAvailability.objects.filter(
|
|
tenant=tenant,
|
|
provider=provider,
|
|
is_recurring=True,
|
|
day_of_week=day_of_week,
|
|
is_available=True
|
|
)
|
|
|
|
# Get appointments for this provider on this date
|
|
appointments = AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
provider=provider,
|
|
scheduled_datetime__date=selected_date,
|
|
status__in=['SCHEDULED', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS']
|
|
).select_related('patient').order_by('scheduled_datetime')
|
|
|
|
return render(request, 'appointments/partials/provider_availability.html', {
|
|
'availability': availability,
|
|
'recurring_availability': recurring_availability,
|
|
'appointments': appointments,
|
|
'provider': provider,
|
|
'selected_date': selected_date
|
|
})
|
|
|
|
|
|
@login_required
|
|
def add_to_queue(request, queue_id):
|
|
"""
|
|
HTMX view for adding a patient to the queue.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
queue = get_object_or_404(WaitingQueue, queue_id=queue_id, tenant=tenant)
|
|
|
|
if request.method == 'POST':
|
|
form = QueueEntryForm(request.POST, user=request.user)
|
|
if form.is_valid():
|
|
entry = form.save(commit=False)
|
|
entry.queue = queue
|
|
entry.save()
|
|
|
|
return redirect('appointments:queue_status', queue_id=queue_id)
|
|
else:
|
|
form = QueueEntryForm(
|
|
user=request.user,
|
|
initial={'queue': queue, 'priority': 'ROUTINE'}
|
|
)
|
|
|
|
return render(request, 'appointments/partials/add_to_queue.html', {
|
|
'form': form,
|
|
'queue': queue
|
|
})
|
|
|
|
|
|
@login_required
|
|
def call_next_patient(request, queue_id):
|
|
"""
|
|
HTMX view for calling the next patient from the queue.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
queue = get_object_or_404(WaitingQueue, queue_id=queue_id, tenant=tenant)
|
|
|
|
# Check if the user has permission
|
|
if not request.user.has_perm('appointments.change_queueentry'):
|
|
return HttpResponseForbidden("Permission denied")
|
|
|
|
# Get the next patient
|
|
next_entry = queue.get_next_patient()
|
|
|
|
if next_entry:
|
|
next_entry.mark_as_called()
|
|
|
|
# Return the updated queue status
|
|
return redirect('appointments:queue_status', queue_id=queue_id)
|
|
else:
|
|
# No patients waiting
|
|
return render(request, 'appointments/partials/no_patients_waiting.html', {
|
|
'queue': queue
|
|
})
|
|
|
|
|
|
@login_required
|
|
def update_entry_status(request, entry_id, status):
|
|
"""
|
|
HTMX view for updating a queue entry status.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
entry = get_object_or_404(QueueEntry, entry_id=entry_id, queue__tenant=tenant)
|
|
|
|
# Check if the user has permission
|
|
if not request.user.has_perm('appointments.change_queueentry'):
|
|
return HttpResponseForbidden("Permission denied")
|
|
|
|
# Update the status
|
|
if status == 'called':
|
|
entry.mark_as_called()
|
|
elif status == 'in_progress':
|
|
entry.mark_as_in_progress()
|
|
elif status == 'completed':
|
|
entry.mark_as_completed()
|
|
elif status == 'no_show':
|
|
entry.mark_as_no_show()
|
|
elif status == 'cancelled':
|
|
entry.mark_as_cancelled()
|
|
elif status == 'removed':
|
|
entry.mark_as_removed()
|
|
|
|
# Return the updated queue status
|
|
return redirect('appointments:queue_status', queue_id=entry.queue.queue_id)
|
|
|
|
|
|
@login_required
|
|
def check_in_appointment(request, pk):
|
|
"""
|
|
View for checking in a patient for an appointment.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
messages.error(request, _("No tenant found. Please contact an administrator."))
|
|
return redirect('core:dashboard')
|
|
|
|
appointment = get_object_or_404(AppointmentRequest, pk=pk, tenant=tenant)
|
|
|
|
# Check if the user has permission
|
|
if not request.user.has_perm('appointments.change_appointmentrequest'):
|
|
messages.error(request, _("You don't have permission to check in patients."))
|
|
return redirect('appointments:appointment_detail', pk=appointment.pk)
|
|
|
|
# Check if the appointment can be checked in
|
|
if appointment.status not in ['SCHEDULED', 'CONFIRMED']:
|
|
messages.error(request, _("This appointment cannot be checked in."))
|
|
return redirect('appointments:appointment_detail', pk=appointment.pk)
|
|
|
|
# Check in the appointment
|
|
appointment.check_in()
|
|
|
|
messages.success(request, _("Patient has been checked in."))
|
|
return redirect('appointments:appointment_detail', pk=appointment.pk)
|
|
|
|
|
|
@login_required
|
|
def create_telemedicine_session(request, appointment_id):
|
|
"""
|
|
HTMX view for creating a telemedicine session for an appointment.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
appointment = get_object_or_404(
|
|
AppointmentRequest,
|
|
appointment_id=appointment_id,
|
|
tenant=tenant
|
|
)
|
|
|
|
# Check if appointment is of type telemedicine
|
|
if appointment.appointment_type != 'TELEMEDICINE':
|
|
return JsonResponse({'error': 'Not a telemedicine appointment'}, status=400)
|
|
|
|
# Check if a session already exists
|
|
if hasattr(appointment, 'telemedicine_session'):
|
|
return JsonResponse({'error': 'Session already exists'}, status=400)
|
|
|
|
if request.method == 'POST':
|
|
form = TelemedicineSessionForm(
|
|
request.POST,
|
|
user=request.user,
|
|
appointment=appointment
|
|
)
|
|
if form.is_valid():
|
|
session = form.save(commit=False)
|
|
session.tenant = tenant
|
|
session.save()
|
|
|
|
# Link session to appointment
|
|
appointment.telemedicine_session = session
|
|
appointment.save()
|
|
|
|
return render(request, 'appointments/partials/telemedicine_session.html', {
|
|
'session': session,
|
|
'appointment': appointment
|
|
})
|
|
else:
|
|
form = TelemedicineSessionForm(
|
|
user=request.user,
|
|
appointment=appointment,
|
|
initial={
|
|
'status': 'SCHEDULED',
|
|
'scheduled_start_time': appointment.scheduled_datetime,
|
|
'scheduled_end_time': appointment.scheduled_datetime + timedelta(
|
|
minutes=appointment.duration_minutes
|
|
),
|
|
'room_name': f"telehealth-{appointment.appointment_id.hex[:8]}",
|
|
'is_recorded': False,
|
|
}
|
|
)
|
|
|
|
return render(request, 'appointments/partials/telemedicine_session_form.html', {
|
|
'form': form,
|
|
'appointment': appointment
|
|
})
|
|
|
|
|
|
@login_required
|
|
def telemedicine_session_status(request, session_id):
|
|
"""
|
|
HTMX view for updating telemedicine session status.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
session = get_object_or_404(
|
|
TelemedicineSession,
|
|
session_id=session_id,
|
|
tenant=tenant
|
|
)
|
|
|
|
# Get the requested action
|
|
action = request.POST.get('action')
|
|
|
|
if action == 'provider_joined':
|
|
session.mark_provider_joined()
|
|
elif action == 'patient_joined':
|
|
session.mark_patient_joined()
|
|
elif action == 'end_session':
|
|
session.end_session()
|
|
elif action == 'cancel_session':
|
|
session.cancel_session()
|
|
|
|
return render(request, 'appointments/partials/telemedicine_session.html', {
|
|
'session': session,
|
|
'appointment': session.appointment
|
|
})
|
|
|
|
|
|
@login_required
|
|
def find_optimal_slots_view(request):
|
|
"""
|
|
HTMX view for finding optimal appointment slots.
|
|
"""
|
|
from appointments.scheduling.smart_scheduler import SmartScheduler
|
|
from datetime import datetime, timedelta
|
|
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
if request.method == 'POST':
|
|
patient_id = request.POST.get('patient_id')
|
|
provider_id = request.POST.get('provider_id')
|
|
appointment_type_id = request.POST.get('appointment_type_id')
|
|
preferred_date = request.POST.get('preferred_date')
|
|
duration_minutes = int(request.POST.get('duration_minutes', 30))
|
|
|
|
# Validate inputs
|
|
if not all([patient_id, provider_id, appointment_type_id, preferred_date]):
|
|
return JsonResponse({'error': 'Missing required fields'}, status=400)
|
|
|
|
try:
|
|
patient = PatientProfile.objects.get(id=patient_id, tenant=tenant)
|
|
provider = User.objects.get(id=provider_id)
|
|
appointment_type = AppointmentTemplate.objects.get(id=appointment_type_id, tenant=tenant)
|
|
|
|
# Parse date and create date range (7 days)
|
|
start_date = datetime.strptime(preferred_date, '%Y-%m-%d')
|
|
date_range = [start_date + timedelta(days=i) for i in range(7)]
|
|
|
|
# Find optimal slots
|
|
scheduler = SmartScheduler(tenant)
|
|
slots = scheduler.find_optimal_slots(
|
|
patient=patient,
|
|
provider=provider,
|
|
appointment_type=appointment_type,
|
|
preferred_dates=date_range,
|
|
duration_minutes=duration_minutes,
|
|
max_results=10
|
|
)
|
|
|
|
return render(request, 'appointments/scheduling/partials/optimal_slots.html', {
|
|
'slots': slots,
|
|
'patient': patient,
|
|
'provider': provider,
|
|
'appointment_type': appointment_type
|
|
})
|
|
|
|
except Exception as e:
|
|
return JsonResponse({'error': str(e)}, status=400)
|
|
|
|
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
|
|
|
|
|
@login_required
|
|
def scheduling_analytics_view(request):
|
|
"""
|
|
View for scheduling analytics dashboard.
|
|
"""
|
|
from appointments.scheduling.utils import SchedulingAnalytics
|
|
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('core:dashboard')
|
|
|
|
# Get date range from request or default to last 30 days
|
|
days = int(request.GET.get('days', 30))
|
|
end_date = timezone.now().date()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
# Get provider from request or show all
|
|
provider_id = request.GET.get('provider_id')
|
|
|
|
if provider_id:
|
|
provider = get_object_or_404(User, id=provider_id)
|
|
providers = [provider]
|
|
else:
|
|
providers = User.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True,
|
|
employee_profile__role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER']
|
|
)
|
|
|
|
# Calculate analytics for each provider
|
|
analytics_data = []
|
|
for provider in providers:
|
|
utilization = SchedulingAnalytics.calculate_provider_utilization(
|
|
provider, start_date, end_date
|
|
)
|
|
no_show_rate = SchedulingAnalytics.calculate_no_show_rate(
|
|
provider, start_date, end_date
|
|
)
|
|
|
|
analytics_data.append({
|
|
'provider': provider,
|
|
'utilization_rate': utilization,
|
|
'no_show_rate': no_show_rate
|
|
})
|
|
|
|
return render(request, 'appointments/scheduling/analytics.html', {
|
|
'analytics_data': analytics_data,
|
|
'start_date': start_date,
|
|
'end_date': end_date,
|
|
'days': days,
|
|
'all_providers': User.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True,
|
|
employee_profile__role__in=['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER']
|
|
).order_by('last_name', 'first_name')
|
|
})
|
|
|
|
|
|
@login_required
|
|
def check_scheduling_conflicts_view(request):
|
|
"""
|
|
HTMX view for checking scheduling conflicts.
|
|
"""
|
|
from appointments.scheduling.utils import ConflictDetector
|
|
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
if request.method == 'POST':
|
|
provider_id = request.POST.get('provider_id')
|
|
start_time = request.POST.get('start_time')
|
|
end_time = request.POST.get('end_time')
|
|
exclude_appointment_id = request.POST.get('exclude_appointment_id')
|
|
|
|
if not all([provider_id, start_time, end_time]):
|
|
return JsonResponse({'error': 'Missing required fields'}, status=400)
|
|
|
|
try:
|
|
provider = User.objects.get(id=provider_id)
|
|
start_dt = datetime.fromisoformat(start_time)
|
|
end_dt = datetime.fromisoformat(end_time)
|
|
|
|
# Check conflicts
|
|
conflicts = ConflictDetector.check_conflicts(
|
|
provider, start_dt, end_dt, exclude_appointment_id
|
|
)
|
|
|
|
if conflicts:
|
|
# Get alternative slots
|
|
duration_minutes = int((end_dt - start_dt).total_seconds() / 60)
|
|
alternatives = ConflictDetector.suggest_alternative_slots(
|
|
provider, start_dt, duration_minutes, count=5
|
|
)
|
|
|
|
return render(request, 'appointments/scheduling/partials/conflicts.html', {
|
|
'has_conflict': True,
|
|
'conflicts': conflicts,
|
|
'alternatives': alternatives,
|
|
'provider': provider
|
|
})
|
|
else:
|
|
return render(request, 'appointments/scheduling/partials/conflicts.html', {
|
|
'has_conflict': False,
|
|
'provider': provider
|
|
})
|
|
|
|
except Exception as e:
|
|
return JsonResponse({'error': str(e)}, status=400)
|
|
|
|
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
|
|
|
|
|
@login_required
|
|
def update_patient_preferences_view(request, patient_id):
|
|
"""
|
|
View to manually update patient scheduling preferences.
|
|
"""
|
|
from appointments.scheduling.utils import SchedulingAnalytics
|
|
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('core:dashboard')
|
|
|
|
patient = get_object_or_404(PatientProfile, id=patient_id, tenant=tenant)
|
|
|
|
# Update preferences based on historical data
|
|
prefs = SchedulingAnalytics.update_patient_scheduling_preferences(patient)
|
|
|
|
if prefs:
|
|
messages.success(
|
|
request,
|
|
f'Scheduling preferences updated for {patient.get_full_name()}. '
|
|
f'Completion rate: {prefs.completion_rate}%, No-show rate: {prefs.average_no_show_rate}%'
|
|
)
|
|
else:
|
|
messages.info(
|
|
request,
|
|
f'No appointment history found for {patient.get_full_name()}.'
|
|
)
|
|
|
|
return redirect('patients:patient_detail', pk=patient_id)
|
|
|
|
|
|
@login_required
|
|
def scheduling_metrics_dashboard(request):
|
|
"""
|
|
Dashboard view for scheduling metrics.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('core:dashboard')
|
|
|
|
# Get date range
|
|
days = int(request.GET.get('days', 30))
|
|
end_date = timezone.now().date()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
# Get metrics for the period
|
|
metrics = SchedulingMetrics.objects.filter(
|
|
tenant=tenant,
|
|
date__gte=start_date,
|
|
date__lte=end_date
|
|
).select_related('provider').order_by('-date')
|
|
|
|
# Calculate aggregate statistics
|
|
total_metrics = metrics.aggregate(
|
|
avg_utilization=Avg('utilization_rate'),
|
|
avg_no_show=Avg('no_show_rate'),
|
|
total_appointments=Count('id')
|
|
)
|
|
|
|
return render(request, 'appointments/scheduling/metrics_dashboard.html', {
|
|
'metrics': metrics[:50], # Limit to 50 most recent
|
|
'total_metrics': total_metrics,
|
|
'start_date': start_date,
|
|
'end_date': end_date,
|
|
'days': days
|
|
})
|
|
|
|
|
|
@login_required
|
|
def queue_status_htmx_view(request, queue_id):
|
|
"""
|
|
HTMX view for real-time queue status updates.
|
|
Returns queue statistics and entry list.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
|
|
|
from appointments.queue import AdvancedQueueEngine
|
|
engine = AdvancedQueueEngine(queue)
|
|
|
|
# Get current status
|
|
status = engine.get_queue_status()
|
|
|
|
return render(request, 'appointments/queue/partials/queue_stats.html', {
|
|
'queue': queue,
|
|
'status': status
|
|
})
|
|
|
|
|
|
@login_required
|
|
def add_to_queue_htmx_view(request, queue_id):
|
|
"""
|
|
HTMX view for adding a patient to the queue.
|
|
Uses AdvancedQueueEngine for intelligent positioning.
|
|
"""
|
|
tenant = request.user.tenant
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
|
|
|
if request.method == 'POST':
|
|
patient_id = request.POST.get('patient_id')
|
|
appointment_id = request.POST.get('appointment_id')
|
|
priority_score = float(request.POST.get('priority_score', 1.0))
|
|
notes = request.POST.get('notes', '')
|
|
|
|
if not patient_id:
|
|
return JsonResponse({'error': 'Patient ID required'}, status=400)
|
|
|
|
try:
|
|
patient = PatientProfile.objects.get(id=patient_id, tenant=tenant)
|
|
appointment = None
|
|
|
|
if appointment_id:
|
|
appointment = AppointmentRequest.objects.get(
|
|
id=appointment_id,
|
|
tenant=tenant
|
|
)
|
|
|
|
# Use AdvancedQueueEngine to add patient
|
|
from appointments.queue import AdvancedQueueEngine
|
|
engine = AdvancedQueueEngine(queue)
|
|
|
|
entry = engine.add_to_queue(
|
|
patient=patient,
|
|
appointment=appointment,
|
|
priority_score=priority_score,
|
|
notes=notes
|
|
)
|
|
|
|
# Log the action
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='CREATE',
|
|
event_category='QUEUE_MANAGEMENT',
|
|
action='Add Patient to Queue',
|
|
description=f'Added {patient.get_full_name()} to queue {queue.name} at position {entry.queue_position}',
|
|
user=request.user,
|
|
content_object=entry,
|
|
request=request
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
f'{patient.get_full_name()} added to queue at position {entry.queue_position}'
|
|
)
|
|
|
|
# Return updated queue list
|
|
return render(request, 'appointments/queue/partials/queue_list.html', {
|
|
'queue': queue,
|
|
'entries': queue.queue_entries.filter(status='WAITING').order_by('queue_position')
|
|
})
|
|
|
|
except Exception as e:
|
|
return JsonResponse({'error': str(e)}, status=400)
|
|
|
|
# GET request - show form
|
|
patients = PatientProfile.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
).order_by('last_name', 'first_name')[:50]
|
|
|
|
return render(request, 'appointments/queue/partials/add_patient_form.html', {
|
|
'queue': queue,
|
|
'patients': patients
|
|
})
|
|
|
|
|
|
@login_required
|
|
def call_next_patient_htmx_view(request, queue_id):
|
|
"""
|
|
HTMX view for calling the next patient in queue.
|
|
Uses AdvancedQueueEngine to get next patient.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
return JsonResponse({'error': 'No tenant found'}, status=400)
|
|
|
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
|
|
|
from appointments.queue import AdvancedQueueEngine
|
|
engine = AdvancedQueueEngine(queue)
|
|
|
|
# Get next patient
|
|
next_patient = engine.get_next_patient()
|
|
|
|
if next_patient:
|
|
# Log the action
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='QUEUE_MANAGEMENT',
|
|
action='Call Next Patient',
|
|
description=f'Called {next_patient.patient.get_full_name()} from queue {queue.name}',
|
|
user=request.user,
|
|
content_object=next_patient,
|
|
request=request
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
f'Called {next_patient.patient.get_full_name()} - Position {next_patient.queue_position}'
|
|
)
|
|
|
|
return render(request, 'appointments/queue/partials/next_patient.html', {
|
|
'queue': queue,
|
|
'entry': next_patient
|
|
})
|
|
else:
|
|
messages.info(request, 'No patients waiting in queue')
|
|
return render(request, 'appointments/queue/partials/next_patient.html', {
|
|
'queue': queue,
|
|
'entry': None
|
|
})
|
|
|
|
|
|
@login_required
|
|
def queue_analytics_view(request, queue_id):
|
|
"""
|
|
Analytics dashboard for queue performance.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('core:dashboard')
|
|
|
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
|
|
|
# Get date range
|
|
days = int(request.GET.get('days', 7))
|
|
end_date = timezone.now().date()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
from appointments.queue import AdvancedQueueEngine
|
|
engine = AdvancedQueueEngine(queue)
|
|
|
|
# Get analytics summary
|
|
analytics = engine.get_analytics_summary(days=days)
|
|
|
|
# Get hourly metrics
|
|
from appointments.models import QueueMetrics
|
|
hourly_metrics = QueueMetrics.objects.filter(
|
|
queue=queue,
|
|
date__gte=start_date,
|
|
date__lte=end_date
|
|
).order_by('date', 'hour')
|
|
|
|
return render(request, 'appointments/queue/queue_analytics.html', {
|
|
'queue': queue,
|
|
'analytics': analytics,
|
|
'hourly_metrics': hourly_metrics,
|
|
'start_date': start_date,
|
|
'end_date': end_date,
|
|
'days': days
|
|
})
|
|
|
|
|
|
@login_required
|
|
def queue_metrics_dashboard_view(request):
|
|
"""
|
|
Dashboard view for all queue metrics across the system.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('core:dashboard')
|
|
|
|
# Get date range
|
|
days = int(request.GET.get('days', 7))
|
|
end_date = timezone.now().date()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
# Get all active queues
|
|
queues = WaitingQueue.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
)
|
|
|
|
# Get metrics for each queue
|
|
from appointments.models import QueueMetrics
|
|
queue_metrics = []
|
|
|
|
for queue in queues:
|
|
metrics = QueueMetrics.objects.filter(
|
|
queue=queue,
|
|
date__gte=start_date,
|
|
date__lte=end_date
|
|
).aggregate(
|
|
avg_wait_time=Avg('average_wait_time_minutes'),
|
|
avg_utilization=Avg('utilization_rate'),
|
|
avg_no_show=Avg('no_show_rate'),
|
|
total_entries=Count('total_entries'),
|
|
total_completed=Count('completed_entries')
|
|
)
|
|
|
|
queue_metrics.append({
|
|
'queue': queue,
|
|
'metrics': metrics
|
|
})
|
|
|
|
return render(request, 'appointments/queue/metrics_dashboard.html', {
|
|
'queue_metrics': queue_metrics,
|
|
'start_date': start_date,
|
|
'end_date': end_date,
|
|
'days': days
|
|
})
|
|
|
|
|
|
@login_required
|
|
def queue_config_view(request, queue_id):
|
|
"""
|
|
View for managing queue configuration.
|
|
"""
|
|
tenant = get_tenant_from_request(request)
|
|
if not tenant:
|
|
messages.error(request, 'No tenant found.')
|
|
return redirect('core:dashboard')
|
|
|
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant)
|
|
|
|
from appointments.models import QueueConfiguration
|
|
config, created = QueueConfiguration.objects.get_or_create(queue=queue)
|
|
|
|
if request.method == 'POST':
|
|
# Update configuration
|
|
config.use_dynamic_positioning = request.POST.get('use_dynamic_positioning') == 'on'
|
|
config.priority_weight = float(request.POST.get('priority_weight', 0.5))
|
|
config.wait_time_weight = float(request.POST.get('wait_time_weight', 0.3))
|
|
config.appointment_time_weight = float(request.POST.get('appointment_time_weight', 0.2))
|
|
config.enable_overflow_queue = request.POST.get('enable_overflow_queue') == 'on'
|
|
config.overflow_threshold = int(request.POST.get('overflow_threshold', 50))
|
|
config.use_historical_data = request.POST.get('use_historical_data') == 'on'
|
|
config.default_service_time_minutes = int(request.POST.get('default_service_time_minutes', 15))
|
|
config.historical_data_days = int(request.POST.get('historical_data_days', 30))
|
|
config.enable_websocket_updates = request.POST.get('enable_websocket_updates') == 'on'
|
|
config.update_interval_seconds = int(request.POST.get('update_interval_seconds', 30))
|
|
config.load_factor_normal_threshold = float(request.POST.get('load_factor_normal_threshold', 0.5))
|
|
config.load_factor_moderate_threshold = float(request.POST.get('load_factor_moderate_threshold', 0.75))
|
|
config.load_factor_high_threshold = float(request.POST.get('load_factor_high_threshold', 0.9))
|
|
config.auto_reposition_enabled = request.POST.get('auto_reposition_enabled') == 'on'
|
|
config.reposition_interval_minutes = int(request.POST.get('reposition_interval_minutes', 15))
|
|
config.notify_on_position_change = request.POST.get('notify_on_position_change') == 'on'
|
|
config.position_change_threshold = int(request.POST.get('position_change_threshold', 3))
|
|
|
|
config.save()
|
|
|
|
# Log configuration update
|
|
AuditLogger.log_event(
|
|
tenant=tenant,
|
|
event_type='UPDATE',
|
|
event_category='QUEUE_MANAGEMENT',
|
|
action='Update Queue Configuration',
|
|
description=f'Updated configuration for queue {queue.name}',
|
|
user=request.user,
|
|
content_object=config,
|
|
request=request
|
|
)
|
|
|
|
messages.success(request, f'Queue configuration updated successfully.')
|
|
return redirect('appointments:advanced_queue_management', pk=queue_id)
|
|
|
|
return render(request, 'appointments/queue/queue_config.html', {
|
|
'queue': queue,
|
|
'config': config
|
|
})
|
|
|
|
|
|
@login_required
|
|
@permission_required('appointments.change_appointmentrequest')
|
|
def no_show_appointment(request, pk):
|
|
"""Mark appointment as no-show with documentation."""
|
|
tenant = request.user.tenant
|
|
appointment = get_object_or_404(
|
|
AppointmentRequest,
|
|
pk=pk,
|
|
tenant=tenant
|
|
)
|
|
|
|
if request.method == 'POST':
|
|
# Process no-show form
|
|
appointment.status = 'NO_SHOW'
|
|
appointment.no_show_time = timezone.now()
|
|
appointment.no_show_documented_by = request.user
|
|
appointment.no_show_notes = request.POST.get('no_show_notes', '')
|
|
appointment.no_show_fee = request.POST.get('no_show_fee', 25.00)
|
|
appointment.save()
|
|
|
|
messages.success(request, 'Appointment marked as no-show.')
|
|
return redirect('appointments:appointment_detail', pk=pk)
|
|
|
|
return render(request, 'appointments/no_show_appointment.html', {
|
|
'appointment': appointment
|
|
})
|
|
|
|
|
|
class ProviderDailyScheduleView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Provider-facing view showing daily schedule with appointments.
|
|
Shows appointments for the logged-in provider for a specific date.
|
|
"""
|
|
template_name = 'appointments/provider/provider_daily_schedule.html'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user is a provider (has employee profile with provider role)
|
|
if not hasattr(request.user, 'employee_profile'):
|
|
messages.error(request, 'You must be a provider to access this page.')
|
|
return redirect('core:dashboard')
|
|
|
|
# Check if user has provider role
|
|
provider_roles = ['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT',
|
|
'PHARMACIST', 'RADIOLOGIST', 'THERAPIST']
|
|
if request.user.employee_profile.role not in provider_roles:
|
|
messages.error(request, 'You must be a provider to access this page.')
|
|
return redirect('core:dashboard')
|
|
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
provider = self.request.user
|
|
|
|
# Get selected date from query params or use today
|
|
date_str = self.request.GET.get('date')
|
|
if date_str:
|
|
try:
|
|
selected_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
selected_date = timezone.now().date()
|
|
else:
|
|
selected_date = timezone.now().date()
|
|
|
|
# Get status filter
|
|
status_filter = self.request.GET.get('status', '')
|
|
|
|
# Get appointments for the provider on selected date
|
|
appointments = AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
provider=provider,
|
|
scheduled_datetime__date=selected_date
|
|
).select_related('patient').order_by('scheduled_datetime')
|
|
|
|
# Apply status filter if provided
|
|
if status_filter:
|
|
appointments = appointments.filter(status=status_filter)
|
|
|
|
# Calculate statistics
|
|
total_appointments = appointments.count()
|
|
pending_appointments = appointments.filter(status='PENDING').count()
|
|
confirmed_appointments = appointments.filter(status='CONFIRMED').count()
|
|
checked_in_appointments = appointments.filter(status='CHECKED_IN').count()
|
|
in_progress_appointments = appointments.filter(status='IN_PROGRESS').count()
|
|
completed_appointments = appointments.filter(status='COMPLETED').count()
|
|
cancelled_appointments = appointments.filter(status='CANCELLED').count()
|
|
no_show_appointments = appointments.filter(status='NO_SHOW').count()
|
|
|
|
# Get queue entries for this provider
|
|
queue_entries = QueueEntry.objects.filter(
|
|
queue__tenant=tenant,
|
|
assigned_provider=provider,
|
|
status__in=['WAITING', 'CALLED', 'IN_SERVICE']
|
|
).select_related('patient', 'queue').order_by('queue_position')
|
|
|
|
# Calculate time slots (8 AM to 6 PM in 30-minute intervals)
|
|
time_slots = []
|
|
start_hour = 8
|
|
end_hour = 18
|
|
for hour in range(start_hour, end_hour):
|
|
for minute in [0, 30]:
|
|
slot_time = time(hour, minute)
|
|
time_slots.append(slot_time)
|
|
|
|
context.update({
|
|
'provider': provider,
|
|
'selected_date': selected_date,
|
|
'appointments': appointments,
|
|
'queue_entries': queue_entries,
|
|
'time_slots': time_slots,
|
|
'status_filter': status_filter,
|
|
'stats': {
|
|
'total': total_appointments,
|
|
'pending': pending_appointments,
|
|
'confirmed': confirmed_appointments,
|
|
'checked_in': checked_in_appointments,
|
|
'in_progress': in_progress_appointments,
|
|
'completed': completed_appointments,
|
|
'cancelled': cancelled_appointments,
|
|
'no_show': no_show_appointments,
|
|
},
|
|
'today': timezone.now().date(),
|
|
'yesterday': timezone.now().date() - timedelta(days=1),
|
|
'tomorrow': timezone.now().date() + timedelta(days=1),
|
|
})
|
|
|
|
return context
|
|
|
|
|
|
class ProviderWeeklyScheduleView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Provider-facing view showing weekly schedule with appointments.
|
|
Shows appointments for the logged-in provider for a week.
|
|
"""
|
|
template_name = 'appointments/provider/provider_weekly_schedule.html'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user is a provider (has employee profile with provider role)
|
|
if not hasattr(request.user, 'employee_profile'):
|
|
messages.error(request, 'You must be a provider to access this page.')
|
|
return redirect('core:dashboard')
|
|
|
|
# Check if user has provider role
|
|
provider_roles = ['PHYSICIAN', 'NURSE', 'NURSE_PRACTITIONER', 'PHYSICIAN_ASSISTANT',
|
|
'PHARMACIST', 'RADIOLOGIST', 'THERAPIST']
|
|
if request.user.employee_profile.role not in provider_roles:
|
|
messages.error(request, 'You must be a provider to access this page.')
|
|
return redirect('core:dashboard')
|
|
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
provider = self.request.user
|
|
|
|
# Get selected week start date from query params or use current week
|
|
week_start_str = self.request.GET.get('week_start')
|
|
if week_start_str:
|
|
try:
|
|
week_start = datetime.strptime(week_start_str, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
week_start = timezone.now().date()
|
|
else:
|
|
week_start = timezone.now().date()
|
|
|
|
# Adjust to start of week (Monday)
|
|
week_start = week_start - timedelta(days=week_start.weekday())
|
|
|
|
# Calculate week end (Sunday)
|
|
week_end = week_start + timedelta(days=6)
|
|
|
|
# Get all appointments for the week
|
|
appointments = AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
provider=provider,
|
|
scheduled_datetime__date__gte=week_start,
|
|
scheduled_datetime__date__lte=week_end
|
|
).select_related('patient').order_by('scheduled_datetime')
|
|
|
|
# Organize appointments by day
|
|
days_data = []
|
|
for day_offset in range(7):
|
|
current_day = week_start + timedelta(days=day_offset)
|
|
day_appointments = appointments.filter(scheduled_datetime__date=current_day)
|
|
|
|
# Calculate day statistics
|
|
day_stats = {
|
|
'total': day_appointments.count(),
|
|
'pending': day_appointments.filter(status='PENDING').count(),
|
|
'confirmed': day_appointments.filter(status='CONFIRMED').count(),
|
|
'checked_in': day_appointments.filter(status='CHECKED_IN').count(),
|
|
'in_progress': day_appointments.filter(status='IN_PROGRESS').count(),
|
|
'completed': day_appointments.filter(status='COMPLETED').count(),
|
|
'cancelled': day_appointments.filter(status='CANCELLED').count(),
|
|
}
|
|
|
|
days_data.append({
|
|
'date': current_day,
|
|
'appointments': day_appointments,
|
|
'stats': day_stats,
|
|
'is_today': current_day == timezone.now().date(),
|
|
})
|
|
|
|
# Calculate week statistics
|
|
week_stats = {
|
|
'total': appointments.count(),
|
|
'pending': appointments.filter(status='PENDING').count(),
|
|
'confirmed': appointments.filter(status='CONFIRMED').count(),
|
|
'checked_in': appointments.filter(status='CHECKED_IN').count(),
|
|
'in_progress': appointments.filter(status='IN_PROGRESS').count(),
|
|
'completed': appointments.filter(status='COMPLETED').count(),
|
|
'cancelled': appointments.filter(status='CANCELLED').count(),
|
|
'no_show': appointments.filter(status='NO_SHOW').count(),
|
|
}
|
|
|
|
context.update({
|
|
'provider': provider,
|
|
'week_start': week_start,
|
|
'week_end': week_end,
|
|
'days_data': days_data,
|
|
'week_stats': week_stats,
|
|
'prev_week': week_start - timedelta(days=7),
|
|
'next_week': week_start + timedelta(days=7),
|
|
'current_week_start': timezone.now().date() - timedelta(days=timezone.now().date().weekday()),
|
|
})
|
|
|
|
return context
|
|
|
|
|
|
class QuickCheckInView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Provider-facing quick check-in interface.
|
|
Fast patient check-in with search and one-click functionality.
|
|
"""
|
|
template_name = 'appointments/provider/quick_check_in.html'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user is a provider or staff member
|
|
if not hasattr(request.user, 'employee_profile'):
|
|
messages.error(request, 'You must be a staff member to access this page.')
|
|
return redirect('core:dashboard')
|
|
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
today = timezone.now().date()
|
|
|
|
# Get search query
|
|
search_query = self.request.GET.get('search', '')
|
|
|
|
# Get today's appointments
|
|
appointments = AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today,
|
|
status__in=['SCHEDULED', 'CONFIRMED']
|
|
).select_related('patient', 'provider').order_by('scheduled_datetime')
|
|
|
|
# Apply search filter if provided
|
|
if search_query:
|
|
appointments = appointments.filter(
|
|
Q(patient__first_name__icontains=search_query) |
|
|
Q(patient__last_name__icontains=search_query) |
|
|
Q(patient__patient_id__icontains=search_query) |
|
|
Q(patient__phone__icontains=search_query)
|
|
)
|
|
|
|
# Get recently checked-in patients (last 10)
|
|
recent_checkins = AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
status='CHECKED_IN',
|
|
checked_in_at__date=today
|
|
).select_related('patient', 'provider').order_by('-checked_in_at')[:10]
|
|
|
|
# Calculate statistics
|
|
stats = {
|
|
'total_today': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today
|
|
).count(),
|
|
'pending_checkin': appointments.count(),
|
|
'checked_in': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today,
|
|
status='CHECKED_IN'
|
|
).count(),
|
|
'in_progress': AppointmentRequest.objects.filter(
|
|
tenant=tenant,
|
|
scheduled_datetime__date=today,
|
|
status='IN_PROGRESS'
|
|
).count(),
|
|
}
|
|
|
|
context.update({
|
|
'appointments': appointments,
|
|
'recent_checkins': recent_checkins,
|
|
'search_query': search_query,
|
|
'stats': stats,
|
|
'today': today,
|
|
})
|
|
|
|
return context
|
|
|
|
|
|
class ProviderQueueDashboardView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Provider-facing simplified queue management dashboard.
|
|
Shows current patient being served and next patients in queue.
|
|
"""
|
|
template_name = 'appointments/provider/provider_queue_dashboard.html'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user is a provider or staff member
|
|
if not hasattr(request.user, 'employee_profile'):
|
|
messages.error(request, 'You must be a staff member to access this page.')
|
|
return redirect('core:dashboard')
|
|
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
tenant = self.request.user.tenant
|
|
provider = self.request.user
|
|
|
|
# Get queue_id from query params or use default
|
|
queue_id = self.request.GET.get('queue_id')
|
|
|
|
if queue_id:
|
|
queue = get_object_or_404(WaitingQueue, id=queue_id, tenant=tenant, is_active=True)
|
|
else:
|
|
# Get first active queue for this provider or general queue
|
|
queue = WaitingQueue.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
).filter(
|
|
Q(providers=provider) | Q(providers__isnull=True)
|
|
).first()
|
|
|
|
if queue:
|
|
# Get current patient being served
|
|
current_patient = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='IN_SERVICE'
|
|
).select_related('patient', 'appointment').first()
|
|
|
|
# Get next patients in queue (next 10)
|
|
waiting_patients = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING'
|
|
).select_related('patient', 'appointment').order_by('queue_position')[:10]
|
|
|
|
# Get recently completed patients (last 5)
|
|
completed_patients = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='COMPLETED',
|
|
served_at__date=timezone.now().date()
|
|
).select_related('patient', 'appointment').order_by('-served_at')[:5]
|
|
|
|
# Calculate statistics
|
|
today = timezone.now().date()
|
|
stats = {
|
|
'total_waiting': QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING'
|
|
).count(),
|
|
'in_service': QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='IN_SERVICE'
|
|
).count(),
|
|
'completed_today': QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='COMPLETED',
|
|
served_at__date=today
|
|
).count(),
|
|
'avg_wait_time': queue.average_service_time_minutes or 15,
|
|
}
|
|
|
|
context.update({
|
|
'queue': queue,
|
|
'current_patient': current_patient,
|
|
'waiting_patients': waiting_patients,
|
|
'completed_patients': completed_patients,
|
|
'stats': stats,
|
|
})
|
|
else:
|
|
context.update({
|
|
'queue': None,
|
|
'current_patient': None,
|
|
'waiting_patients': [],
|
|
'completed_patients': [],
|
|
'stats': {},
|
|
})
|
|
|
|
# Get all active queues for queue selector
|
|
all_queues = WaitingQueue.objects.filter(
|
|
tenant=tenant,
|
|
is_active=True
|
|
).order_by('name')
|
|
|
|
context['all_queues'] = all_queues
|
|
|
|
return context
|
|
|
|
|
|
class PatientAppointmentRequestView(LoginRequiredMixin, CreateView):
|
|
"""
|
|
Patient-facing view for requesting appointments.
|
|
Patients can only request appointments, not directly book them.
|
|
"""
|
|
model = AppointmentRequest
|
|
form_class = AppointmentRequestForm
|
|
template_name = 'appointments/patient/patient_appointment_request.html'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user has a patient profile
|
|
if not hasattr(request.user, 'patient_profile'):
|
|
messages.error(request, 'You must have a patient profile to request appointments.')
|
|
return redirect('core:dashboard')
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_form_kwargs(self):
|
|
kwargs = super().get_form_kwargs()
|
|
kwargs['user'] = self.request.user
|
|
return kwargs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['patient'] = self.request.user.patient_profile
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
# Set patient and tenant
|
|
form.instance.patient = self.request.user.patient_profile
|
|
form.instance.tenant = self.request.user.tenant
|
|
form.instance.status = 'PENDING' # Always pending for patient requests
|
|
|
|
# Generate request ID
|
|
import uuid
|
|
form.instance.request_id = f"APT-{uuid.uuid4().hex[:8].upper()}"
|
|
|
|
response = super().form_valid(form)
|
|
|
|
# Send confirmation email
|
|
from appointments.notifications import AppointmentNotifications
|
|
AppointmentNotifications.send_appointment_confirmation(self.object)
|
|
|
|
# Log appointment request
|
|
AuditLogger.log_event(
|
|
tenant=form.instance.tenant,
|
|
event_type='CREATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Patient Appointment Request',
|
|
description=f'Patient {self.object.patient.get_full_name()} requested appointment',
|
|
user=self.request.user,
|
|
content_object=self.object,
|
|
request=self.request
|
|
)
|
|
|
|
messages.success(
|
|
self.request,
|
|
'Your appointment request has been submitted successfully. '
|
|
'A confirmation email has been sent to your registered email address.'
|
|
)
|
|
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('appointments:patient_appointment_success', kwargs={'pk': self.object.pk})
|
|
|
|
|
|
class PatientAppointmentListView(LoginRequiredMixin, ListView):
|
|
"""
|
|
Patient-facing view to list their own appointments.
|
|
"""
|
|
model = AppointmentRequest
|
|
template_name = 'appointments/patient/patient_appointment_list.html'
|
|
context_object_name = 'appointments'
|
|
paginate_by = 20
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user has a patient profile
|
|
if not hasattr(request.user, 'patient_profile'):
|
|
messages.error(request, 'You must have a patient profile to view appointments.')
|
|
return redirect('core:dashboard')
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_queryset(self):
|
|
# Only show appointments for the logged-in patient
|
|
queryset = AppointmentRequest.objects.filter(
|
|
patient=self.request.user.patient_profile,
|
|
tenant=self.request.user.tenant
|
|
).select_related('provider').order_by('-preferred_date', '-preferred_time')
|
|
|
|
# Apply filters
|
|
status = self.request.GET.get('status')
|
|
if status:
|
|
queryset = queryset.filter(status=status)
|
|
|
|
appointment_type = self.request.GET.get('appointment_type')
|
|
if appointment_type:
|
|
queryset = queryset.filter(appointment_type=appointment_type)
|
|
|
|
date_from = self.request.GET.get('date_from')
|
|
if date_from:
|
|
queryset = queryset.filter(preferred_date__gte=date_from)
|
|
|
|
return queryset
|
|
|
|
|
|
class PatientAppointmentDetailView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Patient-facing view to see details of their appointment.
|
|
"""
|
|
model = AppointmentRequest
|
|
template_name = 'appointments/patient/patient_appointment_detail.html'
|
|
context_object_name = 'appointment'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user has a patient profile
|
|
if not hasattr(request.user, 'patient_profile'):
|
|
messages.error(request, 'You must have a patient profile to view appointments.')
|
|
return redirect('core:dashboard')
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_queryset(self):
|
|
# Only allow patients to view their own appointments
|
|
return AppointmentRequest.objects.filter(
|
|
patient=self.request.user.patient_profile,
|
|
tenant=self.request.user.tenant
|
|
).select_related('provider')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
appointment = self.get_object()
|
|
|
|
# Get telemedicine session if exists
|
|
if appointment.is_telemedicine:
|
|
context['telemedicine_session'] = TelemedicineSession.objects.filter(
|
|
appointment=appointment
|
|
).first()
|
|
|
|
return context
|
|
|
|
|
|
class PatientAppointmentCancelView(LoginRequiredMixin, UpdateView):
|
|
"""
|
|
Patient-facing view to cancel their appointment.
|
|
"""
|
|
model = AppointmentRequest
|
|
template_name = 'appointments/patient/patient_appointment_cancel.html'
|
|
context_object_name = 'appointment'
|
|
fields = [] # No fields to edit, just confirmation
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user has a patient profile
|
|
if not hasattr(request.user, 'patient_profile'):
|
|
messages.error(request, 'You must have a patient profile to cancel appointments.')
|
|
return redirect('core:dashboard')
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_queryset(self):
|
|
# Only allow patients to cancel their own appointments
|
|
return AppointmentRequest.objects.filter(
|
|
patient=self.request.user.patient_profile,
|
|
tenant=self.request.user.tenant,
|
|
status__in=['PENDING', 'SCHEDULED', 'CONFIRMED'] # Only cancellable statuses
|
|
)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
self.object = self.get_object()
|
|
|
|
# Get cancellation details from form
|
|
cancellation_reason = request.POST.get('cancellation_reason', 'PATIENT_CANCELLED')
|
|
cancellation_notes = request.POST.get('cancellation_notes', '')
|
|
|
|
# Update appointment status
|
|
self.object.status = 'CANCELLED'
|
|
self.object.cancelled_at = timezone.now()
|
|
self.object.cancelled_by = request.user
|
|
self.object.cancellation_reason = cancellation_reason
|
|
self.object.cancellation_notes = cancellation_notes
|
|
self.object.save()
|
|
|
|
# Log cancellation
|
|
AuditLogger.log_event(
|
|
tenant=self.object.tenant,
|
|
event_type='UPDATE',
|
|
event_category='APPOINTMENT_MANAGEMENT',
|
|
action='Patient Cancelled Appointment',
|
|
description=f'Patient {self.object.patient.get_full_name()} cancelled appointment',
|
|
user=request.user,
|
|
content_object=self.object,
|
|
request=request
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
'Your appointment has been cancelled successfully. '
|
|
'You can request a new appointment at any time.'
|
|
)
|
|
|
|
return redirect('appointments:patient_appointment_list')
|
|
|
|
|
|
class PatientAppointmentSuccessView(LoginRequiredMixin, DetailView):
|
|
"""
|
|
Success page shown after patient submits appointment request.
|
|
"""
|
|
model = AppointmentRequest
|
|
template_name = 'appointments/patient/patient_appointment_success.html'
|
|
context_object_name = 'appointment'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user has a patient profile
|
|
if not hasattr(request.user, 'patient_profile'):
|
|
messages.error(request, 'You must have a patient profile.')
|
|
return redirect('core:dashboard')
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_queryset(self):
|
|
# Only allow patients to view their own appointments
|
|
return AppointmentRequest.objects.filter(
|
|
patient=self.request.user.patient_profile,
|
|
tenant=self.request.user.tenant
|
|
).select_related('provider')
|
|
|
|
|
|
class PatientQueueStatusView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Patient-facing view to check their queue status.
|
|
Shows current position, estimated wait time, and queue information.
|
|
"""
|
|
template_name = 'appointments/patient/patient_queue_status.html'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
# Ensure user has a patient profile
|
|
if not hasattr(request.user, 'patient_profile'):
|
|
messages.error(request, 'You must have a patient profile to view queue status.')
|
|
return redirect('core:dashboard')
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
patient = self.request.user.patient_profile
|
|
tenant = self.request.user.tenant
|
|
|
|
# Get the patient's active queue entry
|
|
entry = QueueEntry.objects.filter(
|
|
patient=patient,
|
|
queue__tenant=tenant,
|
|
status__in=['WAITING', 'CALLED', 'IN_SERVICE']
|
|
).select_related('queue', 'appointment', 'assigned_provider').first()
|
|
|
|
if entry:
|
|
queue = entry.queue
|
|
|
|
# Calculate total people in queue
|
|
total_in_queue = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING'
|
|
).count()
|
|
|
|
# Calculate people ahead
|
|
people_ahead = QueueEntry.objects.filter(
|
|
queue=queue,
|
|
status='WAITING',
|
|
queue_position__lt=entry.queue_position
|
|
).count()
|
|
|
|
# Calculate progress percentage
|
|
if total_in_queue > 0:
|
|
progress_percentage = int(((total_in_queue - people_ahead) / total_in_queue) * 100)
|
|
else:
|
|
progress_percentage = 100
|
|
|
|
# Estimate wait time
|
|
if entry.status == 'WAITING' and people_ahead > 0:
|
|
avg_service_time = queue.average_service_time_minutes or 15
|
|
estimated_wait_time = people_ahead * avg_service_time
|
|
else:
|
|
estimated_wait_time = 0
|
|
|
|
context.update({
|
|
'entry': entry,
|
|
'queue': queue,
|
|
'total_in_queue': total_in_queue,
|
|
'people_ahead': people_ahead,
|
|
'progress_percentage': progress_percentage,
|
|
'estimated_wait_time': estimated_wait_time,
|
|
})
|
|
else:
|
|
context.update({
|
|
'entry': None,
|
|
'queue': None,
|
|
})
|
|
|
|
return context
|