agdar/appointments/models.py
2025-11-02 14:35:35 +03:00

554 lines
16 KiB
Python

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