""" Healthcare Appointment Service Handles appointment scheduling, management, and healthcare operations """ from django.db import transaction, models from django.utils import timezone from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from datetime import datetime, timedelta from core.models.tenant import Tenant from ..models.patient import Patient from ..models.appointment import ( Appointment, AppointmentResource, AppointmentNote ) User = get_user_model() class AppointmentService: """ Service class for managing healthcare appointments """ @transaction.atomic def create_appointment(self, tenant: Tenant, appointment_data: dict, created_by=None) -> Appointment: """ Create a new appointment with validation Args: tenant: The tenant organization appointment_data: Appointment information dictionary created_by: User creating the appointment Returns: Appointment: Created appointment instance Raises: ValidationError: If appointment data is invalid """ try: # Generate unique appointment ID appointment_id = self._generate_appointment_id(tenant) # Extract and validate resources and notes resources = appointment_data.pop('resources', []) notes = appointment_data.pop('notes', []) # Validate appointment time self._validate_appointment_time(appointment_data) # Create appointment appointment = Appointment.objects.create( tenant=tenant, appointment_id=appointment_id, created_by=created_by, **appointment_data ) # Create resources for resource_data in resources: AppointmentResource.objects.create( appointment=appointment, **resource_data ) # Create notes for note_data in notes: AppointmentNote.objects.create( appointment=appointment, created_by=created_by, **note_data ) return appointment except Exception as e: raise ValidationError(f"Failed to create appointment: {str(e)}") def update_appointment(self, appointment: Appointment, appointment_data: dict, updated_by=None) -> Appointment: """ Update appointment information Args: appointment: Appointment instance to update appointment_data: Updated appointment information updated_by: User updating the appointment Returns: Appointment: Updated appointment instance """ try: # Validate appointment time if being updated if 'appointment_time' in appointment_data or 'appointment_date' in appointment_data: temp_data = appointment_data.copy() if 'appointment_time' not in temp_data: temp_data['appointment_time'] = appointment.appointment_time if 'appointment_date' not in temp_data: temp_data['appointment_date'] = appointment.appointment_date self._validate_appointment_time(temp_data) # Handle resources updates if 'resources' in appointment_data: self._update_resources(appointment, appointment_data.pop('resources')) # Handle notes updates if 'notes' in appointment_data: self._update_notes(appointment, appointment_data.pop('notes'), updated_by) # Update appointment fields for field, value in appointment_data.items(): if hasattr(appointment, field): setattr(appointment, field, value) appointment.updated_by = updated_by appointment.save() return appointment except Exception as e: raise ValidationError(f"Failed to update appointment: {str(e)}") def get_appointment_by_id(self, tenant: Tenant, appointment_id: str) -> Appointment: """ Get appointment by ID within tenant Args: tenant: Tenant organization appointment_id: Appointment identifier Returns: Appointment: Appointment instance Raises: Appointment.DoesNotExist: If appointment not found """ return Appointment.objects.get( tenant=tenant, appointment_id=appointment_id ) def get_patient_appointments(self, patient: Patient, start_date=None, end_date=None, status=None): """ Get appointments for a specific patient Args: patient: Patient instance start_date: Start date filter end_date: End date filter status: Status filter Returns: QuerySet: Filtered appointments """ queryset = Appointment.objects.filter(patient=patient) if start_date: queryset = queryset.filter(appointment_date__gte=start_date) if end_date: queryset = queryset.filter(appointment_date__lte=end_date) if status: queryset = queryset.filter(status=status) return queryset.order_by('appointment_date', 'appointment_time') def get_doctor_appointments(self, doctor: User, start_date=None, end_date=None, status=None): """ Get appointments for a specific doctor Args: doctor: Doctor user instance start_date: Start date filter end_date: End date filter status: Status filter Returns: QuerySet: Filtered appointments """ queryset = Appointment.objects.filter(doctor=doctor) if start_date: queryset = queryset.filter(appointment_date__gte=start_date) if end_date: queryset = queryset.filter(appointment_date__lte=end_date) if status: queryset = queryset.filter(status=status) return queryset.order_by('appointment_date', 'appointment_time') def get_available_time_slots(self, tenant: Tenant, doctor: User, date: datetime.date): """ Get available time slots for a doctor on a specific date Args: tenant: Tenant organization doctor: Doctor user instance date: Date to check availability Returns: list: Available time slots """ # Get existing appointments for the day existing_appointments = Appointment.objects.filter( tenant=tenant, doctor=doctor, appointment_date=date, status__in=['scheduled', 'confirmed', 'in_progress'] ) # Define working hours (9 AM to 5 PM) working_hours_start = datetime.time(9, 0) working_hours_end = datetime.time(17, 0) slot_duration = 30 # 30-minute slots available_slots = [] current_time = datetime.combine(date, working_hours_start) end_time = datetime.combine(date, working_hours_end) while current_time < end_time: slot_end = current_time + timedelta(minutes=slot_duration) # Check if slot is available is_available = True for appointment in existing_appointments: appt_start = datetime.combine(date, appointment.appointment_time) appt_end = appt_start + timedelta(minutes=appointment.duration) if not (slot_end <= appt_start or current_time >= appt_end): is_available = False break if is_available: available_slots.append({ 'start_time': current_time.time(), 'end_time': slot_end.time(), 'duration': slot_duration }) current_time = slot_end return available_slots def check_appointment_conflicts(self, tenant: Tenant, doctor: User, appointment_date: datetime.date, appointment_time: datetime.time, duration: int, exclude_appointment=None): """ Check for appointment conflicts Args: tenant: Tenant organization doctor: Doctor user instance appointment_date: Appointment date appointment_time: Appointment time duration: Appointment duration in minutes exclude_appointment: Appointment to exclude from conflict check Returns: list: List of conflicting appointments """ start_datetime = datetime.combine(appointment_date, appointment_time) end_datetime = start_datetime + timedelta(minutes=duration) # Get overlapping appointments conflicting_appointments = Appointment.objects.filter( tenant=tenant, doctor=doctor, appointment_date=appointment_date, status__in=['scheduled', 'confirmed', 'in_progress'] ) if exclude_appointment: conflicting_appointments = conflicting_appointments.exclude(id=exclude_appointment.id) conflicts = [] for appointment in conflicting_appointments: appt_start = datetime.combine(appointment.appointment_date, appointment.appointment_time) appt_end = appt_start + timedelta(minutes=appointment.duration) # Check for overlap if not (end_datetime <= appt_start or start_datetime >= appt_end): conflicts.append(appointment) return conflicts def schedule_appointment(self, tenant: Tenant, patient: Patient, doctor: User, appointment_date: datetime.date, appointment_time: datetime.time, duration: int = 30, appointment_type: str = 'consultation', reason_for_visit: str = '', created_by=None) -> Appointment: """ Schedule a new appointment with conflict checking Args: tenant: Tenant organization patient: Patient instance doctor: Doctor user instance appointment_date: Appointment date appointment_time: Appointment time duration: Appointment duration in minutes appointment_type: Type of appointment reason_for_visit: Reason for visit created_by: User creating the appointment Returns: Appointment: Created appointment Raises: ValidationError: If there are scheduling conflicts """ # Check for conflicts conflicts = self.check_appointment_conflicts( tenant, doctor, appointment_date, appointment_time, duration ) if conflicts: conflict_times = [f"{conf.appointment_time} ({conf.duration}min)" for conf in conflicts] raise ValidationError( f"Scheduling conflict with appointments at: {', '.join(conflict_times)}" ) # Create appointment appointment_data = { 'patient': patient, 'doctor': doctor, 'appointment_date': appointment_date, 'appointment_time': appointment_time, 'duration': duration, 'appointment_type': appointment_type, 'reason_for_visit': reason_for_visit, 'status': 'scheduled' } return self.create_appointment(tenant, appointment_data, created_by) def reschedule_appointment(self, appointment: Appointment, new_date: datetime.date, new_time: datetime.time, rescheduled_by=None): """ Reschedule an existing appointment Args: appointment: Appointment to reschedule new_date: New appointment date new_time: New appointment time rescheduled_by: User rescheduling the appointment Returns: Appointment: Updated appointment Raises: ValidationError: If there are scheduling conflicts """ # Check for conflicts conflicts = self.check_appointment_conflicts( appointment.tenant, appointment.doctor, new_date, new_time, appointment.duration, exclude_appointment=appointment ) if conflicts: conflict_times = [f"{conf.appointment_time} ({conf.duration}min)" for conf in conflicts] raise ValidationError( f"Scheduling conflict with appointments at: {', '.join(conflict_times)}" ) # Create a new appointment record old_appointment_data = { 'patient': appointment.patient, 'doctor': appointment.doctor, 'appointment_date': new_date, 'appointment_time': new_time, 'duration': appointment.duration, 'appointment_type': appointment.appointment_type, 'reason_for_visit': appointment.reason_for_visit, 'status': 'scheduled', 'notes': f'Rescheduled from {appointment.appointment_date} at {appointment.appointment_time}', 'rescheduled_from': appointment } new_appointment = self.create_appointment( appointment.tenant, old_appointment_data, rescheduled_by ) # Cancel the old appointment appointment.cancel_appointment('Rescheduled', rescheduled_by) return new_appointment def cancel_appointment(self, appointment: Appointment, reason: str, cancelled_by=None): """ Cancel an appointment Args: appointment: Appointment to cancel reason: Cancellation reason cancelled_by: User cancelling the appointment """ if not appointment.can_be_cancelled(): raise ValidationError("This appointment cannot be cancelled") appointment.cancel_appointment(reason, cancelled_by) def send_appointment_reminders(self, tenant: Tenant, hours_before: int = 24): """ Send appointment reminders Args: tenant: Tenant organization hours_before: Hours before appointment to send reminder Returns: dict: Reminder sending statistics """ reminder_time = timezone.now() + timedelta(hours=hours_before) # Get appointments that need reminders appointments = Appointment.objects.filter( tenant=tenant, appointment_date=reminder_time.date(), status__in=['scheduled', 'confirmed'], reminder_sent=False ).filter( models.Q(appointment_time__hour=reminder_time.hour) | models.Q(appointment_time__hour=reminder_time.hour - 1) ) sent_count = 0 failed_count = 0 for appointment in appointments: try: # Send reminder (integrate with email/SMS service) appointment.send_reminder() sent_count += 1 except Exception as e: failed_count += 1 # Log error print(f"Failed to send reminder for appointment {appointment.appointment_id}: {e}") return { 'total_appointments': appointments.count(), 'sent': sent_count, 'failed': failed_count } def get_appointment_statistics(self, tenant: Tenant, start_date=None, end_date=None): """ Get appointment statistics for a tenant Args: tenant: Tenant organization start_date: Start date for statistics end_date: End date for statistics Returns: dict: Appointment statistics """ queryset = Appointment.objects.filter(tenant=tenant) if start_date: queryset = queryset.filter(appointment_date__gte=start_date) if end_date: queryset = queryset.filter(appointment_date__lte=end_date) # Basic counts total_appointments = queryset.count() completed_appointments = queryset.filter(status='completed').count() cancelled_appointments = queryset.filter(status='cancelled').count() no_show_appointments = queryset.filter(status='no_show').count() # Status distribution status_distribution = {} for status_choice in Appointment.STATUS_CHOICES: status_code = status_choice[0] count = queryset.filter(status=status_code).count() status_distribution[status_code] = { 'name': status_choice[1], 'count': count, 'percentage': (count / total_appointments * 100) if total_appointments > 0 else 0 } # Type distribution type_distribution = {} for type_choice in Appointment.APPOINTMENT_TYPE_CHOICES: type_code = type_choice[0] count = queryset.filter(appointment_type=type_code).count() type_distribution[type_code] = { 'name': type_choice[1], 'count': count, 'percentage': (count / total_appointments * 100) if total_appointments > 0 else 0 } # Doctor workload doctor_workload = {} doctors = User.objects.filter( id__in=queryset.values_list('doctor', flat=True) ).distinct() for doctor in doctors: doctor_appointments = queryset.filter(doctor=doctor) doctor_workload[doctor.id] = { 'name': f"{doctor.first_name} {doctor.last_name}", 'total_appointments': doctor_appointments.count(), 'completed_appointments': doctor_appointments.filter(status='completed').count(), 'average_duration': doctor_appointments.aggregate( avg_duration=models.Avg('duration') )['avg_duration'] or 0 } # Daily trends daily_trends = {} if start_date and end_date: current_date = start_date while current_date <= end_date: day_count = queryset.filter(appointment_date=current_date).count() daily_trends[current_date.strftime('%Y-%m-%d')] = day_count current_date += timedelta(days=1) return { 'total_appointments': total_appointments, 'completed_appointments': completed_appointments, 'cancelled_appointments': cancelled_appointments, 'no_show_appointments': no_show_appointments, 'completion_rate': (completed_appointments / total_appointments * 100) if total_appointments > 0 else 0, 'cancellation_rate': (cancelled_appointments / total_appointments * 100) if total_appointments > 0 else 0, 'no_show_rate': (no_show_appointments / total_appointments * 100) if total_appointments > 0 else 0, 'status_distribution': status_distribution, 'type_distribution': type_distribution, 'doctor_workload': doctor_workload, 'daily_trends': daily_trends } def check_in_patient(self, appointment: Appointment, checked_in_by=None): """ Check in patient for appointment Args: appointment: Appointment instance checked_in_by: User checking in the patient """ appointment.check_in(checked_in_by) def start_appointment(self, appointment: Appointment, started_by=None): """ Start appointment Args: appointment: Appointment instance started_by: User starting the appointment """ appointment.start_appointment(started_by) def complete_appointment(self, appointment: Appointment, completed_by=None, diagnosis: str = '', treatment_plan: str = '', notes: str = ''): """ Complete appointment Args: appointment: Appointment instance completed_by: User completing the appointment diagnosis: Diagnosis information treatment_plan: Treatment plan notes: Additional notes """ # Update appointment with completion details if diagnosis: appointment.diagnosis = diagnosis if treatment_plan: appointment.treatment_plan = treatment_plan if notes: appointment.notes = notes appointment.complete_appointment(completed_by) def add_appointment_note(self, appointment: Appointment, note_type: str, title: str, content: str, created_by=None, is_confidential=False): """ Add note to appointment Args: appointment: Appointment instance note_type: Type of note title: Note title content: Note content created_by: User creating the note is_confidential: Whether note is confidential """ AppointmentNote.objects.create( appointment=appointment, note_type=note_type, title=title, content=content, is_confidential=is_confidential, created_by=created_by ) def _generate_appointment_id(self, tenant: Tenant) -> str: """ Generate unique appointment ID for tenant Args: tenant: Tenant organization Returns: str: Unique appointment ID """ # Use tenant slug + date + sequence tenant_slug = tenant.slug.upper() today = timezone.now().strftime('%Y%m%d') # Get today's appointment count today_count = Appointment.objects.filter( tenant=tenant, appointment_date=timezone.now().date() ).count() sequence = today_count + 1 return f"{tenant_slug}-{today}-{sequence:03d}" def _validate_appointment_time(self, appointment_data: dict): """ Validate appointment time Args: appointment_data: Appointment data dictionary Raises: ValidationError: If appointment time is invalid """ appointment_date = appointment_data.get('appointment_date') appointment_time = appointment_data.get('appointment_time') if not appointment_date or not appointment_time: raise ValidationError("Appointment date and time are required") # Check if appointment is in the past appointment_datetime = datetime.combine(appointment_date, appointment_time) if appointment_datetime < timezone.now(): raise ValidationError("Appointment cannot be scheduled in the past") # Check if appointment is outside working hours (9 AM to 6 PM) if appointment_time.hour < 9 or appointment_time.hour >= 18: raise ValidationError("Appointment must be scheduled between 9 AM and 6 PM") def _update_resources(self, appointment: Appointment, resources_data: list): """ Update resources for appointment Args: appointment: Appointment instance resources_data: List of resource data """ # Remove existing resources not in the update existing_ids = [resource.get('id') for resource in resources_data if 'id' in resource] appointment.resources.exclude(id__in=existing_ids).delete() # Update or create resources for resource_data in resources_data: resource_id = resource_data.pop('id', None) if resource_id: try: resource = appointment.resources.get(id=resource_id) for field, value in resource_data.items(): setattr(resource, field, value) resource.save() except AppointmentResource.DoesNotExist: AppointmentResource.objects.create( appointment=appointment, **resource_data ) else: AppointmentResource.objects.create( appointment=appointment, **resource_data ) def _update_notes(self, appointment: Appointment, notes_data: list, created_by=None): """ Update notes for appointment Args: appointment: Appointment instance notes_data: List of note data created_by: User creating notes """ # Add new notes (notes are not updated, only added) for note_data in notes_data: if 'id' not in note_data: # Only create new notes AppointmentNote.objects.create( appointment=appointment, created_by=created_by, **note_data )