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