""" Session service for group and individual session management. This module contains business logic for creating, managing, and tracking sessions with multiple participants (group sessions) or single participants (individual sessions). """ import logging import random from datetime import datetime, timedelta, date, time from typing import Dict, List, Optional, Tuple from django.db import transaction from django.db.models import Q, Count, F, QuerySet from django.utils import timezone from django.core.exceptions import ValidationError from appointments.models import Session, SessionParticipant, Provider, Room from core.models import Patient, Tenant, Clinic from core.services import ConsentService from finance.services import FinancialClearanceService logger = logging.getLogger(__name__) class SessionService: """Service class for Session-related business logic.""" @staticmethod @transaction.atomic def create_group_session( provider: Provider, clinic: Clinic, scheduled_date: date, scheduled_time: time, duration: int, service_type: str, max_capacity: int, room: Optional[Room] = None, **kwargs ) -> Session: """ Create a new group session. Args: provider: Provider instance clinic: Clinic instance scheduled_date: Date of the session scheduled_time: Time of the session duration: Duration in minutes service_type: Type of service max_capacity: Maximum number of patients (1-20) room: Optional room assignment **kwargs: Additional session fields Returns: Session: Created session instance Raises: ValueError: If validation fails or time slot conflicts """ # Validate capacity if not (1 <= max_capacity <= 20): raise ValueError("Capacity must be between 1 and 20 patients") # Check provider availability start_datetime = datetime.combine(scheduled_date, scheduled_time) end_datetime = start_datetime + timedelta(minutes=duration) if not SessionService._check_provider_availability(provider, start_datetime, end_datetime): raise ValueError( f"Provider {provider.user.get_full_name()} is not available at " f"{start_datetime.strftime('%Y-%m-%d %H:%M')}" ) # Check for session conflicts (same provider, overlapping time) conflicts = SessionService._check_session_conflicts( provider=provider, scheduled_date=scheduled_date, scheduled_time=scheduled_time, duration=duration ) if conflicts: raise ValueError( f"Time slot conflicts with existing session(s): " f"{', '.join([s.session_number for s in conflicts])}" ) # Generate session number session_number = SessionService._generate_session_number(provider.tenant) # Determine session type session_type = Session.SessionType.GROUP if max_capacity > 1 else Session.SessionType.INDIVIDUAL # Create session session = Session.objects.create( tenant=provider.tenant, session_number=session_number, session_type=session_type, max_capacity=max_capacity, provider=provider, clinic=clinic, room=room, service_type=service_type, scheduled_date=scheduled_date, scheduled_time=scheduled_time, duration=duration, status=Session.Status.SCHEDULED, **kwargs ) logger.info( f"Group session created: {session.session_number} - " f"{provider.user.get_full_name()} - {scheduled_date} {scheduled_time} - " f"Capacity: {max_capacity}" ) return session @staticmethod @transaction.atomic def add_patient_to_session( session: Session, patient: Patient, **kwargs ) -> SessionParticipant: """ Add a patient to a session. Args: session: Session instance patient: Patient instance **kwargs: Additional participant fields Returns: SessionParticipant: Created participant instance Raises: ValueError: If validation fails """ # Check if session is cancelled if session.status == Session.Status.CANCELLED: raise ValueError("Cannot add patient to cancelled session") # Check capacity if session.is_full: raise ValueError( f"Session is full ({session.current_capacity}/{session.max_capacity})" ) # Check if patient already enrolled if SessionParticipant.objects.filter( session=session, patient=patient, status__in=['BOOKED', 'CONFIRMED', 'ARRIVED', 'ATTENDED'] ).exists(): raise ValueError("Patient is already enrolled in this session") # Check patient availability (not double-booked) if SessionService._check_patient_conflicts(session, patient): raise ValueError( f"Patient {patient.full_name_en} has a conflicting appointment at this time" ) # Generate appointment number for this participation appointment_number = SessionService._generate_appointment_number(session.tenant) # Check finance and consent status finance_cleared, _ = FinancialClearanceService.check_clearance( patient, session.service_type ) consent_verified, _ = ConsentService.verify_consent_for_service( patient, session.service_type ) # Create participation participant = SessionParticipant.objects.create( session=session, patient=patient, appointment_number=appointment_number, status=SessionParticipant.ParticipantStatus.BOOKED, finance_cleared=finance_cleared, consent_verified=consent_verified, **kwargs ) logger.info( f"Patient added to session: {patient.full_name_en} -> {session.session_number} " f"(Appointment: {appointment_number})" ) return participant @staticmethod @transaction.atomic def remove_patient_from_session( participant: SessionParticipant, reason: str, cancelled_by=None ) -> SessionParticipant: """ Remove a patient from a session (cancel participation). Args: participant: SessionParticipant instance reason: Reason for cancellation cancelled_by: User performing the cancellation Returns: SessionParticipant: Updated participant instance """ if participant.status == SessionParticipant.ParticipantStatus.CANCELLED: raise ValueError("Participation is already cancelled") if participant.status == SessionParticipant.ParticipantStatus.ATTENDED: raise ValueError("Cannot cancel participation that has been attended") participant.status = SessionParticipant.ParticipantStatus.CANCELLED participant.cancel_reason = reason participant.cancelled_by = cancelled_by participant.save() logger.info( f"Patient removed from session: {participant.patient.full_name_en} " f"from {participant.session.session_number} - Reason: {reason}" ) return participant @staticmethod def get_available_group_sessions( clinic: Clinic, date_from: date, date_to: date, service_type: Optional[str] = None, min_available_spots: int = 1 ) -> QuerySet: """ Get group sessions with available capacity. Args: clinic: Clinic instance date_from: Start date date_to: End date service_type: Optional service type filter min_available_spots: Minimum available spots required Returns: QuerySet: Available sessions """ sessions = Session.objects.filter( clinic=clinic, session_type=Session.SessionType.GROUP, scheduled_date__gte=date_from, scheduled_date__lte=date_to, status=Session.Status.SCHEDULED ).annotate( enrolled_count=Count( 'participants', filter=Q(participants__status__in=[ 'BOOKED', 'CONFIRMED', 'ARRIVED', 'ATTENDED' ]) ) ).filter( enrolled_count__lte=F('max_capacity') - min_available_spots ).select_related('provider__user', 'clinic', 'room').order_by( 'scheduled_date', 'scheduled_time' ) if service_type: sessions = sessions.filter(service_type=service_type) return sessions @staticmethod def check_session_capacity(session: Session) -> Dict: """ Check session capacity and return detailed information. Args: session: Session instance Returns: dict: Capacity information """ return { 'session_number': session.session_number, 'max_capacity': session.max_capacity, 'current_capacity': session.current_capacity, 'available_spots': session.available_spots, 'is_full': session.is_full, 'capacity_percentage': session.capacity_percentage, 'participants': list(session.get_participants_list().values( 'patient__mrn', 'patient__first_name_en', 'patient__last_name_en', 'status', 'appointment_number' )) } @staticmethod @transaction.atomic def confirm_participant( participant: SessionParticipant, confirmation_method: str = 'IN_PERSON' ) -> SessionParticipant: """ Confirm a participant's booking. Args: participant: SessionParticipant instance confirmation_method: Method of confirmation Returns: SessionParticipant: Updated participant """ if participant.status != SessionParticipant.ParticipantStatus.BOOKED: raise ValueError( f"Can only confirm BOOKED participants. Current status: {participant.status}" ) participant.status = SessionParticipant.ParticipantStatus.CONFIRMED participant.confirmation_sent_at = timezone.now() participant.save() logger.info( f"Participant confirmed: {participant.patient.full_name_en} - " f"{participant.appointment_number}" ) return participant @staticmethod @transaction.atomic def check_in_participant( participant: SessionParticipant, checked_in_by=None ) -> SessionParticipant: """ Check in a participant (mark as arrived). Args: participant: SessionParticipant instance checked_in_by: User performing check-in Returns: SessionParticipant: Updated participant Raises: ValueError: If prerequisites not met """ # Validate status if participant.status != SessionParticipant.ParticipantStatus.CONFIRMED: raise ValueError( f"Can only check in CONFIRMED participants. Current status: {participant.status}" ) # Check financial clearance finance_cleared, finance_message = FinancialClearanceService.check_clearance( participant.patient, participant.session.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( participant.patient, participant.session.service_type ) if not consent_verified: raise ValueError(f"Consent verification required: {consent_message}") # Update participant participant.status = SessionParticipant.ParticipantStatus.ARRIVED participant.arrival_at = timezone.now() participant.finance_cleared = True participant.consent_verified = True participant.save() logger.info( f"Participant checked in: {participant.patient.full_name_en} - " f"{participant.appointment_number}" ) return participant @staticmethod @transaction.atomic def mark_participant_attended( participant: SessionParticipant ) -> SessionParticipant: """ Mark participant as attended (completed session). Args: participant: SessionParticipant instance Returns: SessionParticipant: Updated participant """ if participant.status != SessionParticipant.ParticipantStatus.ARRIVED: raise ValueError( f"Can only mark ARRIVED participants as attended. Current status: {participant.status}" ) participant.status = SessionParticipant.ParticipantStatus.ATTENDED participant.attended_at = timezone.now() participant.save() logger.info( f"Participant marked as attended: {participant.patient.full_name_en} - " f"{participant.appointment_number}" ) return participant @staticmethod @transaction.atomic def mark_participant_no_show( participant: SessionParticipant, reason: str, notes: str = '' ) -> SessionParticipant: """ Mark participant as no-show. Args: participant: SessionParticipant instance reason: No-show reason notes: Additional notes Returns: SessionParticipant: Updated participant """ if participant.status not in [ SessionParticipant.ParticipantStatus.CONFIRMED, SessionParticipant.ParticipantStatus.BOOKED ]: raise ValueError( f"Can only mark BOOKED/CONFIRMED participants as no-show. " f"Current status: {participant.status}" ) participant.status = SessionParticipant.ParticipantStatus.NO_SHOW participant.no_show_reason = reason participant.no_show_notes = notes participant.save() logger.info( f"Participant marked as no-show: {participant.patient.full_name_en} - " f"{participant.appointment_number} - Reason: {reason}" ) return participant @staticmethod @transaction.atomic def start_session(session: Session) -> Session: """ Start a session (mark as in progress). Args: session: Session instance Returns: Session: Updated session """ if session.status != Session.Status.SCHEDULED: raise ValueError( f"Can only start SCHEDULED sessions. Current status: {session.status}" ) session.status = Session.Status.IN_PROGRESS session.start_at = timezone.now() session.save() logger.info(f"Session started: {session.session_number}") return session @staticmethod @transaction.atomic def complete_session(session: Session) -> Session: """ Complete a session. Args: session: Session instance Returns: Session: Updated session """ if session.status != Session.Status.IN_PROGRESS: raise ValueError( f"Can only complete IN_PROGRESS sessions. Current status: {session.status}" ) session.status = Session.Status.COMPLETED session.end_at = timezone.now() session.save() logger.info(f"Session completed: {session.session_number}") return session @staticmethod @transaction.atomic def cancel_session( session: Session, reason: str, cancelled_by=None ) -> Session: """ Cancel a session and all its participants. Args: session: Session instance reason: Cancellation reason cancelled_by: User cancelling the session Returns: Session: Updated session """ if session.status in [Session.Status.COMPLETED, Session.Status.CANCELLED]: raise ValueError( f"Cannot cancel session with status: {session.status}" ) # Cancel session session.status = Session.Status.CANCELLED session.cancel_reason = reason session.cancelled_by = cancelled_by session.save() # Cancel all active participants active_participants = session.participants.filter( status__in=['BOOKED', 'CONFIRMED', 'ARRIVED'] ) for participant in active_participants: participant.status = SessionParticipant.ParticipantStatus.CANCELLED participant.cancel_reason = f"Session cancelled: {reason}" participant.cancelled_by = cancelled_by participant.save() logger.info( f"Session cancelled: {session.session_number} - " f"Reason: {reason} - Participants affected: {active_participants.count()}" ) return session # Private helper methods @staticmethod def _check_provider_availability( provider: Provider, start_datetime: datetime, end_datetime: datetime ) -> bool: """Check if provider is available for the given time slot.""" from appointments.services import AppointmentService return AppointmentService.check_availability(provider, start_datetime, end_datetime) @staticmethod def _check_session_conflicts( provider: Provider, scheduled_date: date, scheduled_time: time, duration: int, exclude_session_id: Optional[str] = None ) -> List[Session]: """Check for conflicting sessions.""" end_time = ( datetime.combine(scheduled_date, scheduled_time) + timedelta(minutes=duration) ).time() conflicts = Session.objects.filter( provider=provider, scheduled_date=scheduled_date, status__in=[Session.Status.SCHEDULED, Session.Status.IN_PROGRESS] ).filter( Q(scheduled_time__lt=end_time) & Q(scheduled_time__gte=scheduled_time) ) if exclude_session_id: conflicts = conflicts.exclude(id=exclude_session_id) return list(conflicts) @staticmethod def _check_patient_conflicts(session: Session, patient: Patient) -> bool: """Check if patient has conflicting appointments.""" start_datetime = datetime.combine(session.scheduled_date, session.scheduled_time) end_datetime = start_datetime + timedelta(minutes=session.duration) # Check other session participations conflicting_participations = SessionParticipant.objects.filter( patient=patient, session__scheduled_date=session.scheduled_date, status__in=['BOOKED', 'CONFIRMED', 'ARRIVED', 'ATTENDED'] ).exclude( session=session ).filter( Q(session__scheduled_time__lt=end_datetime.time()) & Q(session__scheduled_time__gte=session.scheduled_time) ) return conflicting_participations.exists() @staticmethod def _generate_session_number(tenant: Tenant) -> str: """Generate unique session number.""" year = timezone.now().year for _ in range(10): random_num = random.randint(10000, 99999) number = f"SES-{tenant.code}-{year}-{random_num}" if not Session.objects.filter(session_number=number).exists(): return number # Fallback timestamp = int(timezone.now().timestamp()) return f"SES-{tenant.code}-{year}-{timestamp}" @staticmethod def _generate_appointment_number(tenant: Tenant) -> str: """Generate unique appointment number for participant.""" year = timezone.now().year for _ in range(10): random_num = random.randint(10000, 99999) number = f"APT-{tenant.code}-{year}-{random_num}" # Check both SessionParticipant and Appointment models if not SessionParticipant.objects.filter(appointment_number=number).exists(): from appointments.models import Appointment if not Appointment.objects.filter(appointment_number=number).exists(): return number # Fallback timestamp = int(timezone.now().timestamp()) return f"APT-{tenant.code}-{year}-{timestamp}"