agdar/appointments/models.py
Marwan Alwali 7e014ee160 update
2025-11-16 14:56:32 +03:00

980 lines
29 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.core.validators import MinValueValidator, MaxValueValidator
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")
)
# No-Show Tracking
class NoShowReason(models.TextChoices):
PATIENT_FORGOT = 'PATIENT_FORGOT', _('Patient Forgot')
PATIENT_SICK = 'PATIENT_SICK', _('Patient Sick')
TRANSPORTATION = 'TRANSPORTATION', _('Transportation Issue')
EMERGENCY = 'EMERGENCY', _('Emergency')
NO_CONTACT = 'NO_CONTACT', _('Could Not Contact')
LATE_CANCELLATION = 'LATE_CANCELLATION', _('Late Cancellation')
OTHER = 'OTHER', _('Other')
no_show_reason = models.CharField(
max_length=30,
choices=NoShowReason.choices,
blank=True,
verbose_name=_("No-Show Reason")
)
no_show_notes = models.TextField(
blank=True,
verbose_name=_("No-Show Notes"),
help_text=_("Additional details about the no-show")
)
# 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")
)
# Link to new Session model (for backward compatibility and migration)
session = models.ForeignKey(
'Session',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='legacy_appointments',
verbose_name=_("Session"),
help_text=_("Link to session model for group session support")
)
# Package Integration (links to finance.PackagePurchase)
package_purchase = models.ForeignKey(
'finance.PackagePurchase',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='appointments',
verbose_name=_("Package Purchase"),
help_text=_("Link to package purchase if this appointment is part of a package")
)
session_number_in_package = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name=_("Session Number in Package"),
help_text=_("Session number within the package (1, 2, 3, ...)")
)
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')
def get_service_type_color(self):
"""
Get color for service type (for calendar color-coding).
Different from status color - this is for the type of service.
"""
# Map common service types to colors
service_type_lower = self.service_type.lower()
if 'consultation' in service_type_lower or 'initial' in service_type_lower:
return '#007bff' # Blue
elif 'assessment' in service_type_lower or 'evaluation' in service_type_lower:
return '#6f42c1' # Purple
elif 'intervention' in service_type_lower or 'therapy' in service_type_lower:
return '#28a745' # Green
elif 'follow' in service_type_lower or 'review' in service_type_lower:
return '#17a2b8' # Cyan
elif 'free' in service_type_lower:
return '#6c757d' # Gray
else:
# Color by clinic if available
if self.clinic:
clinic_colors = {
'OT': '#fd7e14', # Orange
'ABA': '#e83e8c', # Pink
'SLP': '#20c997', # Teal
'MEDICAL': '#dc3545', # Red
'NURSING': '#ffc107', # Yellow
}
return clinic_colors.get(self.clinic.specialty, '#6c757d')
return '#6c757d' # Default gray
def get_calendar_event_data(self):
"""
Get data formatted for calendar display (FullCalendar format).
"""
return {
'id': str(self.id),
'title': f"{self.patient.full_name_en} - {self.service_type}",
'start': f"{self.scheduled_date}T{self.scheduled_time}",
'end': f"{self.scheduled_date}T{(datetime.combine(self.scheduled_date, self.scheduled_time) + timedelta(minutes=self.duration)).time()}",
'backgroundColor': self.get_service_type_color(),
'borderColor': self.get_service_type_color(),
'textColor': '#ffffff',
'extendedProps': {
'patient_mrn': self.patient.mrn,
'patient_name': self.patient.full_name_en,
'provider': self.provider.user.get_full_name(),
'clinic': self.clinic.name_en,
'room': self.room.room_number if self.room else None,
'status': self.status,
'status_display': self.get_status_display(),
'service_type': self.service_type,
'duration': self.duration,
}
}
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
class Session(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
"""
Represents a scheduled session (individual or group).
A session is a scheduled time slot with a provider that can accommodate
one or more patients depending on the session type and capacity.
For individual sessions: max_capacity = 1
For group sessions: max_capacity = 1-20
"""
class SessionType(models.TextChoices):
INDIVIDUAL = 'INDIVIDUAL', _('Individual Session')
GROUP = 'GROUP', _('Group Session')
class Status(models.TextChoices):
SCHEDULED = 'SCHEDULED', _('Scheduled')
IN_PROGRESS = 'IN_PROGRESS', _('In Progress')
COMPLETED = 'COMPLETED', _('Completed')
CANCELLED = 'CANCELLED', _('Cancelled')
# Session Identification
session_number = models.CharField(
max_length=20,
unique=True,
editable=False,
verbose_name=_("Session Number")
)
# Session Type & Capacity
session_type = models.CharField(
max_length=20,
choices=SessionType.choices,
default=SessionType.INDIVIDUAL,
verbose_name=_("Session Type")
)
max_capacity = models.PositiveIntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(20)],
verbose_name=_("Maximum Capacity"),
help_text=_("Maximum number of patients (1-20)")
)
# Core Relationships
provider = models.ForeignKey(
Provider,
on_delete=models.CASCADE,
related_name='sessions',
verbose_name=_("Provider")
)
clinic = models.ForeignKey(
'core.Clinic',
on_delete=models.CASCADE,
related_name='sessions',
verbose_name=_("Clinic")
)
room = models.ForeignKey(
Room,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='sessions',
verbose_name=_("Room")
)
# Scheduling
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
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.SCHEDULED,
verbose_name=_("Status")
)
# Timestamps
start_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Start Time")
)
end_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("End Time")
)
# Notes (shared for group sessions)
group_notes = models.TextField(
blank=True,
verbose_name=_("Group Notes"),
help_text=_("Shared notes for the entire session")
)
# 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_sessions',
verbose_name=_("Cancelled By")
)
history = HistoricalRecords()
class Meta:
verbose_name = _("Session")
verbose_name_plural = _("Sessions")
ordering = ['-scheduled_date', '-scheduled_time']
indexes = [
models.Index(fields=['session_number']),
models.Index(fields=['provider', 'scheduled_date']),
models.Index(fields=['clinic', 'scheduled_date']),
models.Index(fields=['status', 'scheduled_date']),
models.Index(fields=['tenant', 'scheduled_date']),
models.Index(fields=['session_type', 'scheduled_date']),
]
def __str__(self):
return f"Session #{self.session_number} - {self.get_session_type_display()} - {self.scheduled_date}"
@property
def current_capacity(self):
"""Number of patients currently enrolled."""
return self.participants.filter(
status__in=['BOOKED', 'CONFIRMED', 'ARRIVED', 'ATTENDED']
).count()
@property
def available_spots(self):
"""Number of available spots remaining."""
return self.max_capacity - self.current_capacity
@property
def is_full(self):
"""Check if session is at capacity."""
return self.current_capacity >= self.max_capacity
@property
def capacity_percentage(self):
"""Get capacity as percentage."""
if self.max_capacity == 0:
return 0
return int((self.current_capacity / self.max_capacity) * 100)
def get_participants_list(self):
"""Get list of enrolled patients."""
return self.participants.filter(
status__in=['BOOKED', 'CONFIRMED', 'ARRIVED', 'ATTENDED']
).select_related('patient')
class SessionParticipant(UUIDPrimaryKeyMixin, TimeStampedMixin):
"""
Represents a patient's participation in a session.
Tracks individual status, notes, and timestamps for each patient
enrolled in a session. Each participant gets a unique appointment
number for billing and tracking purposes.
"""
class ParticipantStatus(models.TextChoices):
BOOKED = 'BOOKED', _('Booked')
CONFIRMED = 'CONFIRMED', _('Confirmed')
CANCELLED = 'CANCELLED', _('Cancelled')
NO_SHOW = 'NO_SHOW', _('No Show')
ARRIVED = 'ARRIVED', _('Arrived')
ATTENDED = 'ATTENDED', _('Attended')
# Relationships
session = models.ForeignKey(
Session,
on_delete=models.CASCADE,
related_name='participants',
verbose_name=_("Session")
)
patient = models.ForeignKey(
'core.Patient',
on_delete=models.CASCADE,
related_name='session_participations',
verbose_name=_("Patient")
)
# Individual appointment number (for billing/tracking)
appointment_number = models.CharField(
max_length=20,
unique=True,
editable=False,
verbose_name=_("Appointment Number")
)
# Status
status = models.CharField(
max_length=20,
choices=ParticipantStatus.choices,
default=ParticipantStatus.BOOKED,
verbose_name=_("Status")
)
# Individual timestamps
confirmation_sent_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Confirmation Sent At")
)
arrival_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Arrival Time")
)
attended_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Attended At")
)
# Individual notes (patient-specific)
individual_notes = models.TextField(
blank=True,
verbose_name=_("Individual Notes"),
help_text=_("Notes specific to this patient")
)
# Prerequisites (per patient)
finance_cleared = models.BooleanField(
default=False,
verbose_name=_("Finance Cleared")
)
consent_verified = models.BooleanField(
default=False,
verbose_name=_("Consent Verified")
)
# 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_participations',
verbose_name=_("Cancelled By")
)
# No-show tracking
no_show_reason = models.CharField(
max_length=30,
choices=Appointment.NoShowReason.choices,
blank=True,
verbose_name=_("No-Show Reason")
)
no_show_notes = models.TextField(
blank=True,
verbose_name=_("No-Show Notes"),
help_text=_("Additional details about the no-show")
)
history = HistoricalRecords()
class Meta:
verbose_name = _("Session Participant")
verbose_name_plural = _("Session Participants")
unique_together = [['session', 'patient']]
ordering = ['created_at']
indexes = [
models.Index(fields=['appointment_number']),
models.Index(fields=['session', 'status']),
models.Index(fields=['patient', 'status']),
models.Index(fields=['status']),
]
def __str__(self):
return f"{self.patient.full_name_en} - {self.session.session_number} ({self.get_status_display()})"
@property
def can_check_in(self):
"""Check if participant can be checked in."""
return (
self.status == self.ParticipantStatus.CONFIRMED and
self.finance_cleared and
self.consent_verified
)
def get_status_color(self):
"""Get Bootstrap color class for status display."""
status_colors = {
'BOOKED': 'theme',
'CONFIRMED': 'blue',
'CANCELLED': 'info',
'NO_SHOW': 'secondary',
'ARRIVED': 'green',
'ATTENDED': 'lightgreen',
}
return status_colors.get(self.status, 'secondary')