agdar/appointments/views.py
Marwan Alwali edb53e4264 update
2025-11-02 23:20:56 +03:00

1167 lines
41 KiB
Python

"""
Appointments views for the Tenhal Multidisciplinary Healthcare Platform.
This module contains views for appointment management including:
- Appointment CRUD operations
- State machine transitions (confirm, reschedule, cancel, arrive, start, complete)
- Calendar views
- Provider schedules
- Appointment reminders
"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q, Count
from django.http import JsonResponse, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.contrib import messages
from core.mixins import (
TenantFilterMixin,
RolePermissionMixin,
AuditLogMixin,
HTMXResponseMixin,
SuccessMessageMixin,
PaginationMixin,
)
from core.models import *
from .models import *
from .forms import *
class AppointmentListView(LoginRequiredMixin, TenantFilterMixin, PaginationMixin,
HTMXResponseMixin, ListView):
"""
Appointment list view with filtering and search.
Features:
- Filter by status, clinic, provider, date range
- Search by patient name/MRN
- Role-based filtering (providers see only their appointments)
- Export to CSV
"""
model = Appointment
template_name = 'appointments/appointment_list.html'
htmx_template_name = 'appointments/partials/appointment_list_partial.html'
context_object_name = 'appointments'
paginate_by = 25
def get_queryset(self):
"""Get filtered queryset based on role and filters."""
queryset = super().get_queryset()
user = self.request.user
# Role-based filtering
if user.role in [User.Role.DOCTOR, User.Role.NURSE, User.Role.OT,
User.Role.SLP, User.Role.ABA]:
# Clinical staff see only their appointments
queryset = queryset.filter(provider__user=user)
# Apply search
search_query = self.request.GET.get('search', '').strip()
if search_query:
queryset = queryset.filter(
Q(patient__first_name_en__icontains=search_query) |
Q(patient__last_name_en__icontains=search_query) |
Q(patient__mrn__icontains=search_query) |
Q(appointment_number__icontains=search_query)
)
# Apply filters
status = self.request.GET.get('status')
if status:
queryset = queryset.filter(status=status)
clinic_id = self.request.GET.get('clinic')
if clinic_id:
queryset = queryset.filter(clinic_id=clinic_id)
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(appointment_date__gte=date_from)
date_to = self.request.GET.get('date_to')
if date_to:
queryset = queryset.filter(appointment_date__lte=date_to)
# Default ordering
return queryset.select_related(
'patient', 'provider', 'clinic', 'room', 'cancelled_by',
).order_by('-scheduled_date', '-scheduled_time')
def get_context_data(self, **kwargs):
"""Add filter options to context."""
context = super().get_context_data(**kwargs)
# Add search form
context['search_form'] = AppointmentSearchForm(self.request.GET)
# Add filter options
context['clinics'] = Clinic.objects.filter(tenant=self.request.user.tenant)
context['providers'] = User.objects.filter(
tenant=self.request.user.tenant,
role__in=[User.Role.DOCTOR, User.Role.NURSE, User.Role.OT,
User.Role.SLP, User.Role.ABA]
)
context['status_choices'] = Appointment.Status.choices
# Add current filters
context['current_filters'] = {
'search': self.request.GET.get('search', ''),
'status': self.request.GET.get('status', ''),
'clinic': self.request.GET.get('clinic', ''),
'provider': self.request.GET.get('provider', ''),
'date_from': self.request.GET.get('date_from', ''),
'date_to': self.request.GET.get('date_to', ''),
}
return context
class AppointmentCalendarView(LoginRequiredMixin, TenantFilterMixin, ListView):
"""
Calendar view for appointments.
Features:
- Weekly/monthly calendar grid
- Filter by clinic/provider
- Drag-and-drop rescheduling (HTMX)
- Color-coded by status
"""
model = Appointment
template_name = 'appointments/appointment_calendar.html'
context_object_name = 'appointments'
def get_queryset(self):
"""Get appointments for the selected date range."""
queryset = super().get_queryset()
user = self.request.user
# Role-based filtering
if user.role in [User.Role.DOCTOR, User.Role.NURSE, User.Role.OT,
User.Role.SLP, User.Role.ABA, User.Role.ADMIN]:
queryset = queryset.filter(provider__user=user)
# Get date range (default: current week)
view_type = self.request.GET.get('view', 'week') # week or month
date_str = self.request.GET.get('date')
if date_str:
from datetime import datetime
base_date = datetime.strptime(date_str, '%Y-%m-%d').date()
else:
base_date = timezone.now().date()
if view_type == 'week':
# Get week range
start_date = base_date - timezone.timedelta(days=base_date.weekday())
end_date = start_date + timezone.timedelta(days=6)
else: # month
# Get month range
start_date = base_date.replace(day=1)
if base_date.month == 12:
end_date = base_date.replace(year=base_date.year + 1, month=1, day=1)
else:
end_date = base_date.replace(month=base_date.month + 1, day=1)
end_date = end_date - timezone.timedelta(days=1)
queryset = queryset.filter(
scheduled_date__gte=start_date,
scheduled_date__lte=end_date
)
# Apply filters
clinic_id = self.request.GET.get('clinic')
if clinic_id:
queryset = queryset.filter(clinic_id=clinic_id)
provider_id = self.request.GET.get('provider')
if provider_id:
queryset = queryset.filter(provider_id=provider_id)
return queryset.select_related('patient', 'provider', 'clinic', 'service')
def get_context_data(self, **kwargs):
"""Add calendar data to context."""
context = super().get_context_data(**kwargs)
# Add filter options
context['clinics'] = Clinic.objects.filter(tenant=self.request.user.tenant)
context['providers'] = User.objects.filter(
tenant=self.request.user.tenant,
role__in=[User.Role.DOCTOR, User.Role.NURSE, User.Role.OT,
User.Role.SLP, User.Role.ABA, User.Role.ADMIN]
)
# Get unique service types for filter
context['services'] = Appointment.objects.filter(
tenant=self.request.user.tenant
).values_list('service_type', flat=True).distinct().order_by('service_type')
# Add view type and date
context['view_type'] = self.request.GET.get('view', 'week')
context['current_date'] = self.request.GET.get('date', timezone.now().date())
# Calendar customization settings
context['calendar_settings'] = {
'initial_view': 'dayGridMonth',
'slot_duration': '00:30:00',
'slot_min_time': '08:00:00',
'slot_max_time': '18:00:00',
'weekends': True,
'all_day_slot': False,
'event_time_format': 'h:mm a',
'slot_label_format': 'h:mm a',
}
# Event status colors
context['status_colors'] = {
'BOOKED': '#0d6efd',
'CONFIRMED': '#198754',
'ARRIVED': '#ffc107',
'IN_PROGRESS': '#fd7e14',
'COMPLETED': '#6c757d',
'CANCELLED': '#dc3545',
'NO_SHOW': '#6c757d',
'RESCHEDULED': '#0dcaf0',
}
return context
class AppointmentDetailView(LoginRequiredMixin, TenantFilterMixin, DetailView):
"""
Appointment detail view.
Features:
- Full appointment details
- Patient information
- Status history
- Available actions based on current status
- Related clinical documents
"""
model = Appointment
template_name = 'appointments/appointment_detail.html'
context_object_name = 'appointment'
def get_context_data(self, **kwargs):
"""Add related data and available actions."""
context = super().get_context_data(**kwargs)
appointment = self.object
# Get available actions based on current status
context['available_actions'] = self._get_available_actions(appointment)
# Get status history (if using simple_history)
if hasattr(appointment, 'history'):
context['status_history'] = appointment.history.all()[:10]
# Get related clinical documents
context['clinical_documents'] = self._get_clinical_documents(appointment)
# Check if user can modify
context['can_modify'] = self._can_user_modify(appointment)
return context
def _get_available_actions(self, appointment):
"""Get list of available actions based on current status."""
actions = []
status = appointment.status
if status == Appointment.Status.BOOKED:
actions.extend(['confirm', 'reschedule', 'cancel'])
elif status == Appointment.Status.CONFIRMED:
actions.extend(['arrive', 'reschedule', 'cancel'])
elif status == Appointment.Status.ARRIVED:
actions.extend(['start', 'cancel'])
elif status == Appointment.Status.IN_PROGRESS:
actions.extend(['complete'])
elif status == Appointment.Status.RESCHEDULED:
actions.extend(['confirm', 'cancel'])
return actions
def _get_clinical_documents(self, appointment):
"""Get clinical documents related to this appointment."""
documents = {}
patient = appointment.patient
# Import models
from nursing.models import NursingEncounter
from medical.models import MedicalConsultation, MedicalFollowUp
from aba.models import ABAConsult
from ot.models import OTConsult, OTSession
from slp.models import SLPConsult, SLPAssessment, SLPIntervention
# Get documents linked to this appointment
documents['nursing'] = NursingEncounter.objects.filter(
appointment=appointment
).first()
documents['medical_consult'] = MedicalConsultation.objects.filter(
appointment=appointment
).first()
documents['medical_followup'] = MedicalFollowUp.objects.filter(
appointment=appointment
).first()
documents['aba'] = ABAConsult.objects.filter(
appointment=appointment
).first()
documents['ot_consult'] = OTConsult.objects.filter(
appointment=appointment
).first()
documents['ot_session'] = OTSession.objects.filter(
appointment=appointment
).first()
documents['slp_consult'] = SLPConsult.objects.filter(
appointment=appointment
).first()
documents['slp_assessment'] = SLPAssessment.objects.filter(
appointment=appointment
).first()
documents['slp_intervention'] = SLPIntervention.objects.filter(
appointment=appointment
).first()
return documents
def _can_user_modify(self, appointment):
"""Check if current user can modify appointment."""
user = self.request.user
# Admin and FrontDesk can always modify
if user.role in [User.Role.ADMIN, User.Role.FRONT_DESK]:
return True
# Provider can modify their own appointments
if appointment.provider == user:
return True
return False
class AppointmentCreateView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin,
SuccessMessageMixin, CreateView):
"""
Appointment creation view.
Features:
- Auto-generate appointment number
- Check provider availability
- Check patient consent
- Send confirmation notification
- Auto-populate patient from ?patient= URL parameter
"""
model = Appointment
form_class = AppointmentForm
template_name = 'appointments/appointment_form.html'
success_message = _("Appointment created successfully! Number: {appointment_number}")
allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK]
def get_initial(self):
"""Set initial form values, including patient from URL parameter."""
initial = super().get_initial()
# Check for patient parameter in URL
patient_id = self.request.GET.get('patient')
if patient_id:
try:
# Verify patient exists and belongs to tenant
from core.models import Patient
patient = Patient.objects.get(
id=patient_id,
tenant=self.request.user.tenant
)
initial['patient'] = patient
except (Patient.DoesNotExist, ValueError):
# Invalid patient ID, ignore
pass
return initial
def get_success_url(self):
"""Redirect to appointment detail."""
return reverse_lazy('appointments:appointment_detail', kwargs={'pk': self.object.pk})
def form_valid(self, form):
"""Set tenant, generate number, and send notification."""
# Set tenant
form.instance.tenant = self.request.user.tenant
# Generate appointment number
if not form.instance.appointment_number:
form.instance.appointment_number = self._generate_appointment_number()
# Set initial status
form.instance.status = Appointment.Status.BOOKED
# Save appointment
response = super().form_valid(form)
# Send confirmation notification (async in production)
self._send_confirmation_notification()
# Update success message
self.success_message = self.success_message.format(
appointment_number=self.object.appointment_number
)
return response
def _generate_appointment_number(self):
"""Generate unique appointment number."""
import random
tenant = self.request.user.tenant
year = timezone.now().year
for _ in range(10):
random_num = random.randint(10000, 99999)
number = f"APT-{tenant.code}-{year}-{random_num}"
if not Appointment.objects.filter(appointment_number=number).exists():
return number
# Fallback
timestamp = int(timezone.now().timestamp())
return f"APT-{tenant.code}-{year}-{timestamp}"
def _send_confirmation_notification(self):
"""Send appointment confirmation notification."""
# TODO: Implement notification sending
# This would use the notifications app to send SMS/WhatsApp/Email
pass
def get_context_data(self, **kwargs):
"""Add form title to context."""
context = super().get_context_data(**kwargs)
context['form_title'] = _('Create New Appointment')
context['submit_text'] = _('Create Appointment')
return context
class AppointmentUpdateView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin,
AuditLogMixin, SuccessMessageMixin, UpdateView):
"""
Appointment update view.
Features:
- Update appointment details
- Cannot change status (use state transition views)
- Audit trail
"""
model = Appointment
form_class = AppointmentForm
template_name = 'appointments/appointment_form.html'
success_message = _("Appointment updated successfully!")
allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK, User.Role.DOCTOR,
User.Role.NURSE, User.Role.OT, User.Role.SLP, User.Role.ABA]
def get_success_url(self):
"""Redirect to appointment detail."""
return reverse_lazy('appointments:appointment_detail', kwargs={'pk': self.object.pk})
def get_form(self, form_class=None):
"""Disable status field."""
form = super().get_form(form_class)
# Make status read-only (use state transition views instead)
if 'status' in form.fields:
form.fields['status'].disabled = True
form.fields['status'].help_text = 'Use action buttons to change status'
return form
def get_context_data(self, **kwargs):
"""Add form title to context."""
context = super().get_context_data(**kwargs)
context['form_title'] = _('Update Appointment: %(number)s') % {'number': self.object.appointment_number}
context['submit_text'] = _('Update Appointment')
return context
# State Machine Transition Views
class AppointmentConfirmView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, View):
"""
Confirm appointment (BOOKED → CONFIRMED).
Features:
- Update status to CONFIRMED
- Set confirmation timestamp
- Send confirmation notification
"""
allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK]
def post(self, request, pk):
"""Confirm appointment."""
appointment = get_object_or_404(
Appointment,
pk=pk,
tenant=request.user.tenant
)
# Check if transition is valid
if appointment.status != Appointment.Status.BOOKED:
messages.error(request, _('Appointment cannot be confirmed from current status.'))
return redirect('appointments:appointment_detail', pk=pk)
# Update status
appointment.status = Appointment.Status.CONFIRMED
appointment.confirmation_at = timezone.now()
appointment.save()
# Send notification
self._send_notification(appointment, 'confirmed')
messages.success(request, _('Appointment confirmed successfully!'))
return redirect('appointments:appointment_detail', pk=pk)
def _send_notification(self, appointment, event_type):
"""Send notification for appointment event."""
# TODO: Implement notification
pass
class AppointmentRescheduleView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin,
SuccessMessageMixin, UpdateView):
"""
Reschedule appointment.
Features:
- Change date/time
- Record reschedule reason
- Update status to RESCHEDULED
- Send notification
"""
model = Appointment
form_class = RescheduleForm
template_name = 'appointments/appointment_reschedule.html'
success_message = _("Appointment rescheduled successfully!")
allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK]
def get_success_url(self):
"""Redirect to appointment detail."""
return reverse_lazy('appointments:appointment_detail', kwargs={'pk': self.object.pk})
def form_valid(self, form):
"""Update status and send notification."""
# Update status
form.instance.status = Appointment.Status.RESCHEDULED
form.instance.reschedule_at = timezone.now()
# Save
response = super().form_valid(form)
# Send notification
self._send_notification(self.object, 'rescheduled')
return response
def _send_notification(self, appointment, event_type):
"""Send notification."""
# TODO: Implement notification
pass
class AppointmentCancelView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, View):
"""
Cancel appointment.
Features:
- Update status to CANCELLED
- Record cancellation reason
- Send notification
"""
allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK, User.Role.DOCTOR,
User.Role.NURSE, User.Role.OT, User.Role.SLP, User.Role.ABA]
def post(self, request, pk):
"""Cancel appointment."""
appointment = get_object_or_404(
Appointment,
pk=pk,
tenant=request.user.tenant
)
# Get cancellation reason
cancel_reason = request.POST.get('cancel_reason', '')
# Update status
appointment.status = Appointment.Status.CANCELLED
appointment.cancel_reason = cancel_reason
appointment.cancel_at = timezone.now()
appointment.save()
# Send notification
self._send_notification(appointment, 'cancelled')
messages.success(request, _('Appointment cancelled successfully!'))
return redirect('appointments:appointment_detail', pk=pk)
def _send_notification(self, appointment, event_type):
"""Send notification."""
# TODO: Implement notification
pass
class AppointmentArriveView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, View):
"""
Mark patient as arrived (CONFIRMED → ARRIVED).
Features:
- Update status to ARRIVED
- Set arrival timestamp
- Trigger check-in workflow
"""
allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK]
def post(self, request, pk):
"""Mark patient as arrived."""
appointment = get_object_or_404(
Appointment,
pk=pk,
tenant=request.user.tenant
)
# Check if transition is valid
if appointment.status != Appointment.Status.CONFIRMED:
messages.error(request, _('Patient can only arrive for confirmed appointments.'))
return redirect('appointments:appointment_detail', pk=pk)
# Update status
appointment.status = Appointment.Status.ARRIVED
appointment.arrival_at = timezone.now()
appointment.save()
messages.success(request, _('Patient marked as arrived!'))
return redirect('appointments:appointment_detail', pk=pk)
class AppointmentStartView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, View):
"""
Start appointment (ARRIVED → IN_PROGRESS).
Features:
- Update status to IN_PROGRESS
- Set start timestamp
- Redirect to appropriate clinical form
"""
allowed_roles = [User.Role.DOCTOR, User.Role.NURSE, User.Role.OT,
User.Role.SLP, User.Role.ABA]
def post(self, request, pk):
"""Start appointment."""
appointment = get_object_or_404(
Appointment,
pk=pk,
tenant=request.user.tenant,
provider=request.user # Only provider can start their appointment
)
# Check if transition is valid
if appointment.status != Appointment.Status.ARRIVED:
messages.error(request, _('Appointment can only be started after patient arrives.'))
return redirect('appointments:appointment_detail', pk=pk)
# Update status
appointment.status = Appointment.Status.IN_PROGRESS
appointment.start_at = timezone.now()
appointment.save()
messages.success(request, _('Appointment started!'))
# Redirect to appropriate clinical form based on clinic
return self._redirect_to_clinical_form(appointment)
def _redirect_to_clinical_form(self, appointment):
"""Redirect to appropriate clinical form based on clinic specialty."""
clinic_specialty = appointment.clinic.specialty
if clinic_specialty == Clinic.Specialty.MEDICAL:
return redirect('medical:consultation_create', appointment_id=appointment.pk)
elif clinic_specialty == Clinic.Specialty.NURSING:
return redirect('nursing:encounter_create', appointment_id=appointment.pk)
elif clinic_specialty == Clinic.Specialty.ABA:
return redirect('aba:consult_create', appointment_id=appointment.pk)
elif clinic_specialty == Clinic.Specialty.OT:
return redirect('ot:consult_create', appointment_id=appointment.pk)
elif clinic_specialty == Clinic.Specialty.SLP:
return redirect('slp:consult_create', appointment_id=appointment.pk)
else:
return redirect('appointments:appointment_detail', pk=appointment.pk)
class AppointmentCompleteView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, View):
"""
Complete appointment (IN_PROGRESS → COMPLETED).
Features:
- Update status to COMPLETED
- Set end timestamp
- Trigger post-appointment workflow (invoice, follow-up)
"""
allowed_roles = [User.Role.DOCTOR, User.Role.NURSE, User.Role.OT,
User.Role.SLP, User.Role.ABA]
def post(self, request, pk):
"""Complete appointment."""
appointment = get_object_or_404(
Appointment,
pk=pk,
tenant=request.user.tenant,
provider=request.user
)
# Check if transition is valid
if appointment.status != Appointment.Status.IN_PROGRESS:
messages.error(request, _('Only in-progress appointments can be completed.'))
return redirect('appointments:appointment_detail', pk=pk)
# Update status
appointment.status = Appointment.Status.COMPLETED
appointment.end_at = timezone.now()
appointment.save()
# Trigger post-appointment workflow
self._trigger_post_appointment_workflow(appointment)
messages.success(request, _('Appointment completed successfully!'))
return redirect('appointments:appointment_detail', pk=pk)
def _trigger_post_appointment_workflow(self, appointment):
"""Trigger post-appointment tasks."""
# TODO: Implement post-appointment workflow
# - Create invoice
# - Schedule follow-up
# - Send satisfaction survey
pass
class AppointmentNoShowView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, View):
"""
Mark appointment as no-show.
Features:
- Update status to NO_SHOW
- Record timestamp
- Send notification
"""
allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK]
def post(self, request, pk):
"""Mark as no-show."""
appointment = get_object_or_404(
Appointment,
pk=pk,
tenant=request.user.tenant
)
# Update status
appointment.status = Appointment.Status.NO_SHOW
appointment.no_show_at = timezone.now()
appointment.save()
# Send notification
self._send_notification(appointment, 'no_show')
messages.warning(request, _('Appointment marked as no-show.'))
return redirect('appointments:appointment_detail', pk=pk)
def _send_notification(self, appointment, event_type):
"""Send notification."""
# TODO: Implement notification
pass
# ============================================================================
# Patient Confirmation Views (Phase 5)
# ============================================================================
class ConfirmAppointmentView(View):
"""
Patient-facing appointment confirmation view.
Features:
- Public access (no login required)
- Token-based authentication
- Confirm or decline appointment
- Mobile-friendly interface
- Metadata tracking (IP, user agent)
"""
def get(self, request, token):
"""Display confirmation page."""
from .confirmation_service import ConfirmationService
# Get confirmation by token
confirmation = ConfirmationService.get_confirmation_by_token(token)
if not confirmation:
return self._render_error(
request,
_('Invalid Confirmation Link'),
_('This confirmation link is invalid or has expired. Please contact the clinic.')
)
# Check if already processed
if confirmation.status in ['CONFIRMED', 'DECLINED']:
return self._render_already_processed(request, confirmation)
# Check if expired
if confirmation.is_expired:
return self._render_error(
request,
_('Link Expired'),
_('This confirmation link has expired. Please contact the clinic to reschedule.')
)
# Render confirmation page
context = {
'confirmation': confirmation,
'appointment': confirmation.appointment,
'patient': confirmation.appointment.patient,
'token': token,
}
return render(request, 'appointments/confirm_appointment.html', context)
def post(self, request, token):
"""Process confirmation or decline."""
from .confirmation_service import ConfirmationService
# Get confirmation
confirmation = ConfirmationService.get_confirmation_by_token(token)
if not confirmation:
return JsonResponse({
'success': False,
'error': _('Invalid confirmation link')
}, status=400)
# Get action (confirm or decline)
action = request.POST.get('action')
if action == 'confirm':
# Confirm appointment
success, message = ConfirmationService.confirm_appointment(
confirmation=confirmation,
method='LINK',
ip_address=self._get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
if success:
return self._render_success(request, confirmation, 'confirmed')
else:
return self._render_error(request, _('Confirmation Failed'), message)
elif action == 'decline':
# Get decline reason
reason = request.POST.get('reason', 'Patient declined')
# Decline appointment
success, message = ConfirmationService.decline_appointment(
confirmation=confirmation,
reason=reason,
ip_address=self._get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
if success:
return self._render_success(request, confirmation, 'declined')
else:
return self._render_error(request, _('Decline Failed'), message)
else:
return JsonResponse({
'success': False,
'error': _('Invalid action')
}, status=400)
def _render_error(self, request, title, message):
"""Render error page."""
context = {
'error_title': title,
'error_message': message,
}
return render(request, 'appointments/confirmation_error.html', context)
def _render_success(self, request, confirmation, action):
"""Render success page."""
context = {
'confirmation': confirmation,
'appointment': confirmation.appointment,
'action': action,
}
return render(request, 'appointments/confirmation_success.html', context)
def _render_already_processed(self, request, confirmation):
"""Render already processed page."""
context = {
'confirmation': confirmation,
'appointment': confirmation.appointment,
'status': confirmation.status,
}
return render(request, 'appointments/confirmation_already_processed.html', context)
def _get_client_ip(self, request):
"""Get client IP address."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
# ============================================================================
# Availability API Views
# ============================================================================
class AvailableSlotsView(LoginRequiredMixin, View):
"""
API endpoint to get available time slots for a provider on a specific date.
Features:
- Returns JSON list of available slots
- Checks provider schedule
- Excludes booked appointments
- Supports custom duration
"""
def get(self, request):
"""Get available slots."""
from datetime import datetime
from .availability_service import AvailabilityService
# Get parameters
provider_id = request.GET.get('provider')
date_str = request.GET.get('date')
duration = int(request.GET.get('duration', 30))
# Validate parameters
if not provider_id or not date_str:
return JsonResponse({
'success': False,
'error': _('Provider and date are required')
}, status=400)
try:
# Parse date
date = datetime.strptime(date_str, '%Y-%m-%d').date()
# Get available slots
slots = AvailabilityService.get_available_slots(
provider_id=provider_id,
date=date,
duration=duration
)
return JsonResponse({
'success': True,
'slots': slots,
'provider_id': provider_id,
'date': date_str,
'duration': duration
})
except ValueError as e:
return JsonResponse({
'success': False,
'error': _('Invalid date format: %(error)s') % {'error': str(e)}
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': _('Error getting available slots: %(error)s') % {'error': str(e)}
}, status=500)
class AppointmentQuickViewView(LoginRequiredMixin, TenantFilterMixin, DetailView):
"""
Quick view for appointment details (used in calendar modal).
Returns a partial HTML template for AJAX loading.
"""
model = Appointment
template_name = 'appointments/appointment_quick_view.html'
context_object_name = 'appointment'
def get_queryset(self):
"""Filter by tenant."""
return super().get_queryset().select_related(
'patient', 'clinic', 'provider__user', 'room'
)
class AppointmentEventsView(LoginRequiredMixin, TenantFilterMixin, View):
"""
API endpoint for calendar events.
Returns appointments in FullCalendar-compatible JSON format.
"""
def get(self, request):
"""Get appointments as calendar events."""
from datetime import datetime, timedelta
# Get date range from query params
start_str = request.GET.get('start')
end_str = request.GET.get('end')
if not start_str or not end_str:
return JsonResponse({'error': _('start and end parameters are required')}, status=400)
# Parse dates
try:
start_date = datetime.fromisoformat(start_str.replace('Z', '+00:00')).date()
end_date = datetime.fromisoformat(end_str.replace('Z', '+00:00')).date()
except ValueError:
return JsonResponse({'error': _('Invalid date format')}, status=400)
# Get appointments in date range
queryset = Appointment.objects.filter(
tenant=request.user.tenant,
scheduled_date__gte=start_date,
scheduled_date__lte=end_date
).select_related('patient', 'provider__user', 'clinic', 'room')
# Apply filters
service = request.GET.get('service', '').strip()
if service:
queryset = queryset.filter(service_type__icontains=service)
provider_id = request.GET.get('provider', '').strip()
if provider_id:
queryset = queryset.filter(provider_id=provider_id)
status_filter = request.GET.get('status', '').strip()
if status_filter:
queryset = queryset.filter(status=status_filter)
# Build events list
events = []
for appointment in queryset:
# Calculate end time
start_datetime = datetime.combine(
appointment.scheduled_date,
appointment.scheduled_time
)
end_datetime = start_datetime + timedelta(minutes=appointment.duration)
events.append({
'id': str(appointment.id),
'patient_name': appointment.patient.full_name_en,
'service_name': appointment.service_type,
'provider_name': appointment.provider.user.get_full_name(),
'room_name': appointment.room.name if appointment.room else '',
'start_time': start_datetime.isoformat(),
'end_time': end_datetime.isoformat(),
'status': appointment.status,
})
return JsonResponse(events, safe=False)
class DeclineAppointmentView(View):
"""
Quick decline view (alternative to confirm page).
Features:
- Direct decline link
- Optional reason
- Confirmation message
"""
def get(self, request, token):
"""Display decline confirmation page."""
from .confirmation_service import ConfirmationService
# Get confirmation
confirmation = ConfirmationService.get_confirmation_by_token(token)
if not confirmation:
return render(request, 'appointments/confirmation_error.html', {
'error_title': _('Invalid Link'),
'error_message': _('This link is invalid or has expired.')
})
# Check if already processed
if confirmation.status in ['CONFIRMED', 'DECLINED']:
context = {
'confirmation': confirmation,
'appointment': confirmation.appointment,
'status': confirmation.status,
}
return render(request, 'appointments/confirmation_already_processed.html', context)
# Render decline form
context = {
'confirmation': confirmation,
'appointment': confirmation.appointment,
'token': token,
}
return render(request, 'appointments/decline_appointment.html', context)
def post(self, request, token):
"""Process decline."""
from .confirmation_service import ConfirmationService
# Get confirmation
confirmation = ConfirmationService.get_confirmation_by_token(token)
if not confirmation:
return JsonResponse({
'success': False,
'error': _('Invalid confirmation link')
}, status=400)
# Get reason
reason = request.POST.get('reason', _('Patient declined'))
# Decline appointment
success, message = ConfirmationService.decline_appointment(
confirmation=confirmation,
reason=reason,
ip_address=self._get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
if success:
context = {
'confirmation': confirmation,
'appointment': confirmation.appointment,
'action': 'declined',
}
return render(request, 'appointments/confirmation_success.html', context)
else:
return render(request, 'appointments/confirmation_error.html', {
'error_title': _('Decline Failed'),
'error_message': message
})
def _get_client_ip(self, request):
"""Get client IP address."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip