656 lines
22 KiB
Python
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}"
|