agdar/appointments/session_service.py
Marwan Alwali a4665842c9 update
2025-11-23 10:58:07 +03:00

656 lines
22 KiB
Python

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