""" Appointments business logic services. This module contains service classes that encapsulate business logic for appointment booking, scheduling, and management. """ import logging from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple from django.db import transaction from django.db.models import Q from django.utils import timezone from appointments.models import Appointment, Provider, Room, Schedule from appointments.state_machine import AppointmentStateMachine, StateTransitionValidator from core.models import Patient, Tenant from core.services import ConsentService from finance.services import FinancialClearanceService logger = logging.getLogger(__name__) class AppointmentService: """Service class for Appointment-related business logic.""" @staticmethod @transaction.atomic def book_appointment( patient: Patient, provider: Provider, clinic, start_time: datetime, duration_minutes: int, appointment_type: str, **kwargs ) -> Appointment: """ Book a new appointment with availability validation. Args: patient: Patient instance provider: Provider instance clinic: Clinic instance start_time: Appointment start time duration_minutes: Duration in minutes appointment_type: Type of appointment **kwargs: Additional appointment fields Returns: Appointment: Created appointment instance Raises: ValueError: If time slot is not available """ # Calculate end time end_time = start_time + timedelta(minutes=duration_minutes) # Check provider availability if not AppointmentService.check_availability(provider, start_time, end_time): raise ValueError( f"Provider {provider.get_full_name()} is not available at " f"{start_time.strftime('%Y-%m-%d %H:%M')}" ) # Check for conflicts conflicts = AppointmentService.check_conflicts( provider=provider, start_time=start_time, end_time=end_time, exclude_appointment_id=None ) if conflicts: raise ValueError( f"Time slot conflicts with existing appointment(s): " f"{', '.join([str(a.id) for a in conflicts])}" ) # Create appointment appointment = Appointment.objects.create( tenant=patient.tenant, patient=patient, provider=provider, clinic=clinic, start_time=start_time, end_time=end_time, duration_minutes=duration_minutes, appointment_type=appointment_type, status='BOOKED', **kwargs ) logger.info( f"Appointment booked: {appointment.id} for patient {patient.mrn} " f"with {provider.get_full_name()} on {start_time}" ) return appointment @staticmethod def check_availability( provider: Provider, start_time: datetime, end_time: datetime ) -> bool: """ Check if provider is available for the given time slot. Args: provider: Provider instance start_time: Start time to check end_time: End time to check Returns: bool: True if provider is available """ # Get provider's schedule for the day # weekday() returns 0=Monday, 1=Tuesday, etc. # But Schedule.DayOfWeek uses 0=Sunday, 1=Monday, etc. # So we need to convert: Python's weekday() + 1, with Sunday wrapping to 0 day_of_week_int = (start_time.weekday() + 1) % 7 schedules = Schedule.objects.filter( provider=provider, day_of_week=day_of_week_int, is_active=True ) if not schedules.exists(): day_names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] logger.warning(f"No schedule found for {provider.user.get_full_name()} on {day_names[day_of_week_int]}") return False # Check if time falls within any schedule for schedule in schedules: # Combine date with schedule times schedule_start = datetime.combine( start_time.date(), schedule.start_time ) schedule_end = datetime.combine( start_time.date(), schedule.end_time ) # Make timezone aware if needed if timezone.is_aware(start_time): schedule_start = timezone.make_aware(schedule_start) schedule_end = timezone.make_aware(schedule_end) # Check if appointment falls within schedule if schedule_start <= start_time and end_time <= schedule_end: return True return False @staticmethod def check_conflicts( provider: Provider, start_time: datetime, end_time: datetime, exclude_appointment_id: Optional[str] = None ) -> List[Appointment]: """ Check for conflicting appointments. Args: provider: Provider instance start_time: Start time to check end_time: End time to check exclude_appointment_id: Appointment ID to exclude from check Returns: list: List of conflicting appointments """ conflicts = Appointment.objects.filter( provider=provider, status__in=['BOOKED', 'CONFIRMED', 'ARRIVED', 'IN_PROGRESS'] ).filter( Q(start_time__lt=end_time, end_time__gt=start_time) ) if exclude_appointment_id: conflicts = conflicts.exclude(id=exclude_appointment_id) return list(conflicts) @staticmethod @transaction.atomic def confirm_appointment(appointment: Appointment, confirmed_by=None) -> Appointment: """ Confirm an appointment with state machine validation. Args: appointment: Appointment instance confirmed_by: User confirming the appointment Returns: Appointment: Confirmed appointment instance Raises: ValueError: If appointment cannot be confirmed """ # Validate state transition is_valid, message = StateTransitionValidator.validate_and_transition( appointment, 'CONFIRMED' ) if not is_valid: raise ValueError(message) appointment.status = 'CONFIRMED' appointment.confirmation_sent_at = timezone.now() if confirmed_by: # Store confirmed_by if field exists if hasattr(appointment, 'confirmed_by'): appointment.confirmed_by = confirmed_by appointment.save() logger.info(f"Appointment confirmed: {appointment.id}") return appointment @staticmethod @transaction.atomic def mark_arrival(appointment: Appointment, arrived_by=None) -> Appointment: """ Mark patient as arrived with state machine validation and prerequisite checks. Performs financial clearance and consent verification before allowing check-in. Args: appointment: Appointment instance arrived_by: User marking the arrival Returns: Appointment: Updated appointment instance Raises: ValueError: If prerequisites not met or appointment cannot be marked as arrived """ # Validate state transition is_valid, message = StateTransitionValidator.validate_and_transition( appointment, 'ARRIVED' ) if not is_valid: raise ValueError(message) # Check financial clearance finance_cleared, finance_message = FinancialClearanceService.check_clearance( appointment.patient, appointment.service_type ) if not finance_cleared: raise ValueError(f"Financial clearance required: {finance_message}") # Check consent verification consent_verified, consent_message = ConsentService.verify_consent_for_service( appointment.patient, appointment.service_type ) if not consent_verified: raise ValueError(f"Consent verification required: {consent_message}") # All checks passed - mark as arrived appointment.status = 'ARRIVED' appointment.arrival_at = timezone.now() appointment.finance_cleared = True appointment.consent_verified = True appointment.save() # Alert provider from core.tasks import create_notification_task if appointment.provider: create_notification_task.delay( user_id=str(appointment.provider.user.id), title="Patient Arrived", message=f"Patient {appointment.patient.full_name_en} has arrived for their appointment", notification_type='INFO', related_object_type='appointment', related_object_id=str(appointment.id), ) logger.info( f"Patient arrived for appointment {appointment.id}. " f"Finance cleared: {finance_cleared}, Consent verified: {consent_verified}" ) return appointment @staticmethod def check_arrival_prerequisites(appointment: Appointment) -> dict: """ Check if all prerequisites are met for patient arrival. Args: appointment: Appointment instance Returns: dict: Status of all prerequisites """ # Check financial clearance finance_cleared, finance_message = FinancialClearanceService.check_clearance( appointment.patient, appointment.service_type ) # Check consent verification consent_verified, consent_message = ConsentService.verify_consent_for_service( appointment.patient, appointment.service_type ) # Get missing consents if any missing_consents = [] if not consent_verified: missing_consents = ConsentService.get_missing_consents( appointment.patient, appointment.service_type ) # Get outstanding invoices if any outstanding_invoices = [] if not finance_cleared: outstanding_invoices = FinancialClearanceService.get_outstanding_invoices( appointment.patient ) return { 'can_check_in': finance_cleared and consent_verified, 'financial': { 'cleared': finance_cleared, 'message': finance_message, 'outstanding_invoices': outstanding_invoices, }, 'consent': { 'verified': consent_verified, 'message': consent_message, 'missing_consents': missing_consents, }, } @staticmethod @transaction.atomic def reschedule_appointment( appointment: Appointment, new_start_time: datetime, new_duration_minutes: Optional[int] = None, reason: str = None, rescheduled_by=None ) -> Appointment: """ Reschedule an appointment to a new time with state machine validation. Args: appointment: Appointment instance new_start_time: New start time new_duration_minutes: New duration (optional) reason: Reason for rescheduling rescheduled_by: User performing the reschedule Returns: Appointment: Rescheduled appointment instance Raises: ValueError: If new time slot is not available or state transition invalid """ # Validate state transition if not AppointmentStateMachine.can_reschedule(appointment.status): raise ValueError( f"Cannot reschedule appointment with status {appointment.status}. " f"Rescheduling only allowed from: {', '.join(AppointmentStateMachine.RESCHEDULABLE_STATES)}" ) # Use existing duration if not provided duration = new_duration_minutes or appointment.duration_minutes new_end_time = new_start_time + timedelta(minutes=duration) # Check availability if not AppointmentService.check_availability( appointment.provider, new_start_time, new_end_time ): raise ValueError( f"Provider not available at {new_start_time.strftime('%Y-%m-%d %H:%M')}" ) # Check conflicts (excluding current appointment) conflicts = AppointmentService.check_conflicts( provider=appointment.provider, start_time=new_start_time, end_time=new_end_time, exclude_appointment_id=str(appointment.id) ) if conflicts: raise ValueError("Time slot conflicts with existing appointment(s)") # Update appointment old_start_time = appointment.scheduled_date old_time = appointment.scheduled_time appointment.scheduled_date = new_start_time.date() appointment.scheduled_time = new_start_time.time() appointment.duration = duration appointment.status = 'RESCHEDULED' appointment.reschedule_reason = reason or "Rescheduled by request" appointment.reschedule_count += 1 if rescheduled_by and hasattr(appointment, 'rescheduled_by'): appointment.rescheduled_by = rescheduled_by appointment.save() logger.info( f"Appointment rescheduled: {appointment.id} from {old_start_time} {old_time} to {new_start_time}" ) return appointment @staticmethod @transaction.atomic def cancel_appointment( appointment: Appointment, reason: str, cancelled_by=None ) -> Appointment: """ Cancel an appointment with state machine validation. Args: appointment: Appointment instance reason: Cancellation reason cancelled_by: User cancelling the appointment Returns: Appointment: Cancelled appointment instance Raises: ValueError: If appointment cannot be cancelled """ # Validate cancellation is allowed if not AppointmentStateMachine.can_cancel(appointment.status): raise ValueError( f"Cannot cancel appointment with status {appointment.status}. " f"Cancellation only allowed from: {', '.join(AppointmentStateMachine.CANCELLABLE_STATES)}" ) appointment.status = 'CANCELLED' appointment.cancel_reason = reason if cancelled_by: appointment.cancelled_by = cancelled_by appointment.save() logger.info(f"Appointment cancelled: {appointment.id} - Reason: {reason}") return appointment @staticmethod @transaction.atomic def start_appointment(appointment: Appointment, started_by=None) -> Appointment: """ Start an appointment (mark as IN_PROGRESS). Args: appointment: Appointment instance started_by: User starting the appointment Returns: Appointment: Updated appointment instance Raises: ValueError: If appointment cannot be started """ # Validate state transition is_valid, message = StateTransitionValidator.validate_and_transition( appointment, 'IN_PROGRESS' ) if not is_valid: raise ValueError(message) appointment.status = 'IN_PROGRESS' appointment.start_at = timezone.now() appointment.save() logger.info(f"Appointment started: {appointment.id}") return appointment @staticmethod @transaction.atomic def complete_appointment(appointment: Appointment, completed_by=None) -> Appointment: """ Complete an appointment. Args: appointment: Appointment instance completed_by: User completing the appointment Returns: Appointment: Updated appointment instance Raises: ValueError: If appointment cannot be completed """ # Validate state transition is_valid, message = StateTransitionValidator.validate_and_transition( appointment, 'COMPLETED' ) if not is_valid: raise ValueError(message) appointment.status = 'COMPLETED' appointment.end_at = timezone.now() appointment.save() logger.info(f"Appointment completed: {appointment.id}") return appointment @staticmethod def get_calendar_slots( provider: Provider, date: datetime.date, slot_duration_minutes: int = 30 ) -> List[Dict]: """ Get available time slots for a provider on a specific date. Args: provider: Provider instance date: Date to get slots for slot_duration_minutes: Duration of each slot in minutes Returns: list: List of time slot dictionaries with availability status """ # Convert date to day_of_week integer (0=Sunday, 1=Monday, etc.) day_of_week_int = (date.weekday() + 1) % 7 # Get provider's schedule for the day schedules = Schedule.objects.filter( provider=provider, day_of_week=day_of_week_int, is_active=True ) if not schedules.exists(): return [] # Get existing appointments for the day appointments = Appointment.objects.filter( provider=provider, start_time__date=date, status__in=['BOOKED', 'CONFIRMED', 'ARRIVED', 'IN_PROGRESS'] ) slots = [] for schedule in schedules: # Generate slots for this schedule current_time = datetime.combine(date, schedule.start_time) end_time = datetime.combine(date, schedule.end_time) # Make timezone aware if needed if timezone.is_aware(timezone.now()): current_time = timezone.make_aware(current_time) end_time = timezone.make_aware(end_time) while current_time + timedelta(minutes=slot_duration_minutes) <= end_time: slot_end = current_time + timedelta(minutes=slot_duration_minutes) # Check if slot is available is_available = True for appointment in appointments: if (current_time < appointment.end_time and slot_end > appointment.start_time): is_available = False break # Check if slot is in the past if current_time < timezone.now(): is_available = False slots.append({ 'start_time': current_time, 'end_time': slot_end, 'is_available': is_available, 'duration_minutes': slot_duration_minutes }) current_time = slot_end return slots class ScheduleService: """Service class for Schedule-related business logic.""" @staticmethod @transaction.atomic def create_schedule( provider: Provider, day_of_week: str, start_time: datetime.time, end_time: datetime.time, **kwargs ) -> Schedule: """ Create a provider schedule. Args: provider: Provider instance day_of_week: Day of week (MONDAY, TUESDAY, etc.) start_time: Schedule start time end_time: Schedule end time **kwargs: Additional schedule fields Returns: Schedule: Created schedule instance Raises: ValueError: If schedule conflicts with existing schedule """ # Check for conflicts conflicts = ScheduleService.check_conflicts( provider=provider, day_of_week=day_of_week, start_time=start_time, end_time=end_time ) if conflicts: raise ValueError( f"Schedule conflicts with existing schedule(s) on {day_of_week}" ) # Create schedule schedule = Schedule.objects.create( tenant=provider.tenant, provider=provider, day_of_week=day_of_week, start_time=start_time, end_time=end_time, **kwargs ) logger.info( f"Schedule created for {provider.get_full_name()} on {day_of_week} " f"from {start_time} to {end_time}" ) return schedule @staticmethod def check_conflicts( provider: Provider, day_of_week: str, start_time: datetime.time, end_time: datetime.time, exclude_schedule_id: Optional[str] = None ) -> List[Schedule]: """ Check for conflicting schedules. Args: provider: Provider instance day_of_week: Day of week start_time: Start time to check end_time: End time to check exclude_schedule_id: Schedule ID to exclude from check Returns: list: List of conflicting schedules """ conflicts = Schedule.objects.filter( provider=provider, day_of_week=day_of_week, is_active=True ).filter( Q(start_time__lt=end_time, end_time__gt=start_time) ) if exclude_schedule_id: conflicts = conflicts.exclude(id=exclude_schedule_id) return list(conflicts) @staticmethod def get_available_slots( provider: Provider, start_date: datetime.date, end_date: datetime.date, slot_duration_minutes: int = 30 ) -> Dict[str, List[Dict]]: """ Get available time slots for a provider across a date range. Args: provider: Provider instance start_date: Start date end_date: End date slot_duration_minutes: Duration of each slot Returns: dict: Dictionary mapping dates to lists of available slots """ slots_by_date = {} current_date = start_date while current_date <= end_date: slots = AppointmentService.get_calendar_slots( provider=provider, date=current_date, slot_duration_minutes=slot_duration_minutes ) # Filter to only available slots available_slots = [slot for slot in slots if slot['is_available']] if available_slots: slots_by_date[str(current_date)] = available_slots current_date += timedelta(days=1) return slots_by_date