""" Appointments models for the Tenhal Multidisciplinary Healthcare Platform. This module handles appointment scheduling, provider management, and the appointment lifecycle state machine. """ from django.db import models from django.utils.translation import gettext_lazy as _ from simple_history.models import HistoricalRecords from core.models import ( UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin, ) class Provider(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): """ Healthcare provider (doctor, therapist, etc.) with specialties. Links to User model and defines availability. """ user = models.OneToOneField( 'core.User', on_delete=models.CASCADE, related_name='provider_profile', verbose_name=_("User") ) specialties = models.ManyToManyField( 'core.Clinic', related_name='providers', verbose_name=_("Specialties") ) is_available = models.BooleanField( default=True, verbose_name=_("Is Available") ) max_daily_appointments = models.PositiveIntegerField( default=20, verbose_name=_("Max Daily Appointments") ) class Meta: verbose_name = _("Provider") verbose_name_plural = _("Providers") ordering = ['user__last_name', 'user__first_name'] def __str__(self): return f"{self.user.get_full_name()} - {self.user.get_role_display()}" class Room(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): """ Physical rooms/spaces where appointments take place. """ name = models.CharField( max_length=100, verbose_name=_("Name") ) room_number = models.CharField( max_length=20, verbose_name=_("Room Number") ) clinic = models.ForeignKey( 'core.Clinic', on_delete=models.CASCADE, related_name='rooms', verbose_name=_("Clinic") ) is_available = models.BooleanField( default=True, verbose_name=_("Is Available") ) class Meta: verbose_name = _("Room") verbose_name_plural = _("Rooms") ordering = ['clinic', 'room_number'] unique_together = [['tenant', 'room_number']] def __str__(self): return f"{self.room_number} - {self.name}" class Schedule(UUIDPrimaryKeyMixin, TimeStampedMixin): """ Provider availability schedule (weekly recurring). Defines when a provider is available for appointments. """ class DayOfWeek(models.IntegerChoices): SUNDAY = 0, _('Sunday') MONDAY = 1, _('Monday') TUESDAY = 2, _('Tuesday') WEDNESDAY = 3, _('Wednesday') THURSDAY = 4, _('Thursday') FRIDAY = 5, _('Friday') SATURDAY = 6, _('Saturday') provider = models.ForeignKey( Provider, on_delete=models.CASCADE, related_name='schedules', verbose_name=_("Provider") ) day_of_week = models.IntegerField( choices=DayOfWeek.choices, verbose_name=_("Day of Week") ) start_time = models.TimeField( verbose_name=_("Start Time") ) end_time = models.TimeField( verbose_name=_("End Time") ) slot_duration = models.PositiveIntegerField( default=30, help_text=_("Duration of each appointment slot in minutes"), verbose_name=_("Slot Duration (minutes)") ) is_active = models.BooleanField( default=True, verbose_name=_("Is Active") ) class Meta: verbose_name = _("Schedule") verbose_name_plural = _("Schedules") ordering = ['provider', 'day_of_week', 'start_time'] def __str__(self): return f"{self.provider} - {self.get_day_of_week_display()} {self.start_time}-{self.end_time}" class Appointment(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): """ Main appointment model with complete lifecycle state machine. Tracks appointment from booking through completion. """ class Status(models.TextChoices): BOOKED = 'BOOKED', _('Booked') CONFIRMED = 'CONFIRMED', _('Confirmed') RESCHEDULED = 'RESCHEDULED', _('Rescheduled') CANCELLED = 'CANCELLED', _('Cancelled') NO_SHOW = 'NO_SHOW', _('No Show') ARRIVED = 'ARRIVED', _('Arrived') IN_PROGRESS = 'IN_PROGRESS', _('In Progress') COMPLETED = 'COMPLETED', _('Completed') class ConfirmationMethod(models.TextChoices): SMS = 'SMS', _('SMS') WHATSAPP = 'WHATSAPP', _('WhatsApp') EMAIL = 'EMAIL', _('Email') PHONE = 'PHONE', _('Phone Call') IN_PERSON = 'IN_PERSON', _('In Person') # Appointment Identification appointment_number = models.CharField( max_length=20, unique=True, editable=False, verbose_name=_("Appointment Number") ) # Core Relationships patient = models.ForeignKey( 'core.Patient', on_delete=models.CASCADE, related_name='appointments', verbose_name=_("Patient") ) clinic = models.ForeignKey( 'core.Clinic', on_delete=models.CASCADE, related_name='appointments', verbose_name=_("Clinic") ) provider = models.ForeignKey( Provider, on_delete=models.CASCADE, related_name='appointments', verbose_name=_("Provider") ) room = models.ForeignKey( Room, on_delete=models.SET_NULL, null=True, blank=True, related_name='appointments', verbose_name=_("Room") ) # Scheduling Details service_type = models.CharField( max_length=200, verbose_name=_("Service Type") ) scheduled_date = models.DateField( verbose_name=_("Scheduled Date") ) scheduled_time = models.TimeField( verbose_name=_("Scheduled Time") ) duration = models.PositiveIntegerField( default=30, help_text=_("Duration in minutes"), verbose_name=_("Duration") ) # Status & State Machine status = models.CharField( max_length=20, choices=Status.choices, default=Status.BOOKED, verbose_name=_("Status") ) # Confirmation confirmation_sent_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Confirmation Sent At") ) confirmation_method = models.CharField( max_length=20, choices=ConfirmationMethod.choices, blank=True, verbose_name=_("Confirmation Method") ) # Timestamps for State Transitions arrival_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Arrival Time") ) start_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Start Time") ) end_at = models.DateTimeField( null=True, blank=True, verbose_name=_("End Time") ) # Rescheduling reschedule_reason = models.TextField( blank=True, verbose_name=_("Reschedule Reason") ) reschedule_count = models.PositiveIntegerField( default=0, verbose_name=_("Reschedule Count") ) # Cancellation cancel_reason = models.TextField( blank=True, verbose_name=_("Cancellation Reason") ) cancelled_by = models.ForeignKey( 'core.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='cancelled_appointments', verbose_name=_("Cancelled By") ) # Additional Information notes = models.TextField( blank=True, verbose_name=_("Notes") ) # Prerequisites finance_cleared = models.BooleanField( default=False, verbose_name=_("Finance Cleared") ) consent_verified = models.BooleanField( default=False, verbose_name=_("Consent Verified") ) history = HistoricalRecords() class Meta: verbose_name = _("Appointment") verbose_name_plural = _("Appointments") ordering = ['-scheduled_date', '-scheduled_time'] indexes = [ models.Index(fields=['appointment_number']), models.Index(fields=['patient', 'scheduled_date']), models.Index(fields=['provider', 'scheduled_date']), models.Index(fields=['clinic', 'scheduled_date']), models.Index(fields=['status', 'scheduled_date']), models.Index(fields=['tenant', 'scheduled_date']), ] def __str__(self): return f"Appointment #{self.appointment_number} - {self.patient} - {self.scheduled_date}" @property def is_past(self): """Check if appointment date/time has passed.""" from datetime import datetime, date, time from django.utils import timezone now = timezone.now() scheduled_datetime = timezone.make_aware( datetime.combine(self.scheduled_date, self.scheduled_time) ) return scheduled_datetime < now @property def can_confirm(self): """Check if appointment can be confirmed.""" return self.status == self.Status.BOOKED @property def can_reschedule(self): """Check if appointment can be rescheduled.""" return self.status in [self.Status.BOOKED, self.Status.CONFIRMED] @property def can_cancel(self): """Check if appointment can be cancelled.""" return self.status not in [ self.Status.CANCELLED, self.Status.COMPLETED, self.Status.NO_SHOW ] @property def can_arrive(self): """Check if patient can be marked as arrived.""" return self.status == self.Status.CONFIRMED @property def can_start(self): """Check if appointment can be started.""" return self.status == self.Status.ARRIVED @property def can_complete(self): """Check if appointment can be completed.""" return self.status == self.Status.IN_PROGRESS def get_status_color(self): """ Get Bootstrap color class for status display. """ status_colors = { 'BOOKED': 'theme', 'CONFIRMED': 'blue', 'RESCHEDULED': 'warning', 'CANCELLED': 'info', 'NO_SHOW': 'secondary', 'ARRIVED': 'green', 'IN_PROGRESS': 'orange', 'COMPLETED': 'lightgreen', } return status_colors.get(self.status, 'secondary') class AppointmentReminder(UUIDPrimaryKeyMixin, TimeStampedMixin): """ Scheduled reminders for appointments. Tracks when reminders should be sent and their status. """ class ReminderType(models.TextChoices): SMS = 'SMS', _('SMS') WHATSAPP = 'WHATSAPP', _('WhatsApp') EMAIL = 'EMAIL', _('Email') class Status(models.TextChoices): SCHEDULED = 'SCHEDULED', _('Scheduled') SENT = 'SENT', _('Sent') FAILED = 'FAILED', _('Failed') CANCELLED = 'CANCELLED', _('Cancelled') appointment = models.ForeignKey( Appointment, on_delete=models.CASCADE, related_name='reminders', verbose_name=_("Appointment") ) reminder_type = models.CharField( max_length=20, choices=ReminderType.choices, verbose_name=_("Reminder Type") ) scheduled_for = models.DateTimeField( verbose_name=_("Scheduled For") ) sent_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Sent At") ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.SCHEDULED, verbose_name=_("Status") ) message = models.ForeignKey( 'notifications.Message', on_delete=models.SET_NULL, null=True, blank=True, related_name='appointment_reminders', verbose_name=_("Message") ) class Meta: verbose_name = _("Appointment Reminder") verbose_name_plural = _("Appointment Reminders") ordering = ['scheduled_for'] indexes = [ models.Index(fields=['appointment', 'scheduled_for']), models.Index(fields=['status', 'scheduled_for']), ] def __str__(self): return f"{self.get_reminder_type_display()} reminder for {self.appointment}" class AppointmentConfirmation(UUIDPrimaryKeyMixin, TimeStampedMixin): """ Patient confirmation tokens for appointments. Allows patients to confirm appointments via secure link. """ class Status(models.TextChoices): PENDING = 'PENDING', _('Pending') CONFIRMED = 'CONFIRMED', _('Confirmed') DECLINED = 'DECLINED', _('Declined') EXPIRED = 'EXPIRED', _('Expired') class ConfirmationMethod(models.TextChoices): LINK = 'LINK', _('Confirmation Link') SMS = 'SMS', _('SMS Reply') PHONE = 'PHONE', _('Phone Call') IN_PERSON = 'IN_PERSON', _('In Person') appointment = models.ForeignKey( Appointment, on_delete=models.CASCADE, related_name='confirmations', verbose_name=_("Appointment") ) token = models.CharField( max_length=64, unique=True, editable=False, verbose_name=_("Confirmation Token") ) status = models.CharField( max_length=20, choices=Status.choices, default=Status.PENDING, verbose_name=_("Status") ) confirmation_method = models.CharField( max_length=20, choices=ConfirmationMethod.choices, blank=True, verbose_name=_("Confirmation Method") ) confirmed_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Confirmed At") ) confirmed_by_ip = models.GenericIPAddressField( null=True, blank=True, verbose_name=_("Confirmed By IP") ) confirmed_by_user_agent = models.TextField( blank=True, verbose_name=_("Confirmed By User Agent") ) expires_at = models.DateTimeField( verbose_name=_("Expires At") ) sent_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Sent At") ) reminder_count = models.PositiveIntegerField( default=0, verbose_name=_("Reminder Count") ) last_reminder_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Last Reminder At") ) class Meta: verbose_name = _("Appointment Confirmation") verbose_name_plural = _("Appointment Confirmations") ordering = ['-created_at'] indexes = [ models.Index(fields=['appointment', 'status']), models.Index(fields=['token']), models.Index(fields=['status', 'expires_at']), ] def __str__(self): return f"Confirmation for {self.appointment} - {self.get_status_display()}" @property def is_expired(self): """Check if confirmation token has expired.""" from django.utils import timezone return timezone.now() > self.expires_at @property def is_valid(self): """Check if confirmation token is still valid.""" return self.status == 'PENDING' and not self.is_expired def get_confirmation_url(self, request=None): """ Get the full confirmation URL. Args: request: Optional request object to build absolute URL Returns: str: Confirmation URL """ from django.urls import reverse path = reverse('appointments:patient_confirm', kwargs={'token': self.token}) if request: return request.build_absolute_uri(path) # Fallback to relative URL return path