""" Complaints models - Complaint management with SLA tracking This module implements the complaint management system that: - Tracks complaints with SLA deadlines - Manages complaint workflow (open → in progress → resolved → closed) - Triggers resolution satisfaction surveys - Creates PX actions for negative resolution satisfaction - Maintains complaint timeline and attachments """ from datetime import timedelta from django.conf import settings from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ from apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel class ComplaintStatus(models.TextChoices): """Complaint status choices""" OPEN = "open", "Open" IN_PROGRESS = "in_progress", "In Progress" PARTIALLY_RESOLVED = "partially_resolved", "Partially Resolved" RESOLVED = "resolved", "Resolved" CLOSED = "closed", "Closed" CANCELLED = "cancelled", "Cancelled" class ResolutionCategory(models.TextChoices): """Resolution category choices""" FULL_ACTION_TAKEN = "full_action_taken", "Full Action Taken" PARTIAL_ACTION_TAKEN = "partial_action_taken", "Partial Action Taken" NO_ACTION_NEEDED = "no_action_needed", "No Action Needed" CANNOT_RESOLVE = "cannot_resolve", "Cannot Resolve" PATIENT_WITHDRAWN = "patient_withdrawn", "Patient Withdrawn" class ComplaintType(models.TextChoices): """Complaint type choices - distinguish between complaints and appreciations""" COMPLAINT = "complaint", "Complaint" APPRECIATION = "appreciation", "Appreciation" class ComplaintSourceType(models.TextChoices): """Complaint source type choices - Internal vs External""" INTERNAL = "internal", "Internal" EXTERNAL = "external", "External" class ComplaintSource(models.TextChoices): """Complaint source choices""" PATIENT = "patient", "Patient" FAMILY = "family", "Family Member" STAFF = "staff", "Staff" SURVEY = "survey", "Survey" SOCIAL_MEDIA = "social_media", "Social Media" CALL_CENTER = "call_center", "Call Center" MOH = "moh", "Ministry of Health" CHI = "chi", "Council of Health Insurance" OTHER = "other", "Other" class ComplaintCategory(UUIDModel, TimeStampedModel): """ Custom complaint categories per hospital with 4-level SHCT taxonomy. Supports hierarchical structure: - Level 1: Domain (CLINICAL, MANAGEMENT, RELATIONSHIPS) - Level 2: Category (Quality, Safety, Communication, etc.) - Level 3: Subcategory (Examination, Patient Journey, etc.) - Level 4: Classification (Examination not performed, etc.) Uses ManyToMany to allow categories to be shared across multiple hospitals. """ class LevelChoices(models.IntegerChoices): DOMAIN = 1, "Domain" CATEGORY = 2, "Category" SUBCATEGORY = 3, "Subcategory" CLASSIFICATION = 4, "Classification" class DomainTypeChoices(models.TextChoices): CLINICAL = "CLINICAL", "Clinical" MANAGEMENT = "MANAGEMENT", "Management" RELATIONSHIPS = "RELATIONSHIPS", "Relationships" hospitals = models.ManyToManyField( "organizations.Hospital", blank=True, related_name="complaint_categories", help_text="Empty list = system-wide category. Add hospitals to share category.", ) code = models.CharField(max_length=50, help_text="Unique code for this category") name_en = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True) description_en = models.TextField(blank=True) description_ar = models.TextField(blank=True) parent = models.ForeignKey( "self", on_delete=models.CASCADE, null=True, blank=True, related_name="subcategories", help_text="Parent category for hierarchical structure", ) level = models.IntegerField( choices=LevelChoices.choices, help_text="Hierarchy level (1=Domain, 2=Category, 3=Subcategory, 4=Classification)" ) domain_type = models.CharField( max_length=20, choices=DomainTypeChoices.choices, blank=True, help_text="Domain type for top-level categories" ) order = models.IntegerField(default=0, help_text="Display order") is_active = models.BooleanField(default=True) class Meta: ordering = ["order", "name_en"] verbose_name_plural = "Complaint Categories" indexes = [ models.Index(fields=["code"]), ] def __str__(self): level_display = self.get_level_display() hospital_count = self.hospitals.count() if hospital_count == 0: hospital_info = "System-wide" elif hospital_count == 1: hospital_info = self.hospitals.first().name else: hospital_info = f"{hospital_count} hospitals" if self.level == self.LevelChoices.CLASSIFICATION and self.parent: parent_path = " > ".join([self.parent.name_en]) return f"{level_display}: {parent_path} > {self.name_en}" else: return f"{level_display}: {self.name_en} ({hospital_info})" class Complaint(UUIDModel, TimeStampedModel): """ Complaint model with SLA tracking. Workflow: 1. OPEN - Complaint received 2. IN_PROGRESS - Being investigated 3. RESOLVED - Solution provided 4. CLOSED - Confirmed closed (triggers resolution satisfaction survey) SLA: - Calculated based on severity and hospital configuration - Reminders sent before due date - Escalation triggered when overdue """ # Patient and encounter information patient = models.ForeignKey( "organizations.Patient", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints" ) # Contact information for anonymous/unregistered submissions contact_name = models.CharField(max_length=200, blank=True) contact_phone = models.CharField(max_length=20, blank=True) contact_email = models.EmailField(blank=True) # Public complaint form fields relation_to_patient = models.CharField( max_length=20, choices=[ ('patient', 'Patient'), ('relative', 'Relative'), ], blank=True, verbose_name="Relation to Patient", help_text="Complainant's relationship to the patient" ) patient_name = models.CharField( max_length=200, blank=True, verbose_name="Patient Name", help_text="Name of the patient involved" ) national_id = models.CharField( max_length=20, blank=True, verbose_name="National ID/Iqama No.", help_text="Saudi National ID or Iqama number" ) incident_date = models.DateField( null=True, blank=True, verbose_name="Incident Date", help_text="Date when the incident occurred" ) staff_name = models.CharField( max_length=200, blank=True, verbose_name="Staff Involved", help_text="Name of staff member involved (if known)" ) expected_result = models.TextField( blank=True, verbose_name="Expected Complaint Result", help_text="What the complainant expects as a resolution" ) # Reference number for tracking reference_number = models.CharField( max_length=50, unique=True, db_index=True, null=True, blank=True, help_text="Unique reference number for patient tracking", ) encounter_id = models.CharField( max_length=100, blank=True, db_index=True, help_text="Related encounter ID if applicable" ) # Organization hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="complaints") department = models.ForeignKey( "organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints" ) staff = models.ForeignKey( "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints" ) # Complaint details title = models.CharField(max_length=500) description = models.TextField() # Classification - 4-level SHCT taxonomy domain = models.ForeignKey( ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_domain", null=True, blank=True, help_text="Level 1: Domain" ) category = models.ForeignKey( ComplaintCategory, on_delete=models.PROTECT, related_name="complaints", null=True, blank=True, help_text="Level 2: Category" ) # Keep CharField for backward compatibility (stores the code) subcategory = models.CharField(max_length=100, blank=True, help_text="Level 3: Subcategory code (legacy)") classification = models.CharField(max_length=100, blank=True, help_text="Level 4: Classification code (legacy)") # New FK fields for proper relationships subcategory_obj = models.ForeignKey( ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_subcategory", null=True, blank=True, help_text="Level 3: Subcategory" ) classification_obj = models.ForeignKey( ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_classification", null=True, blank=True, help_text="Level 4: Classification" ) # Location hierarchy - required fields location = models.ForeignKey( 'organizations.Location', on_delete=models.PROTECT, related_name='complaints', null=True, blank=True, help_text="Location (e.g., Riyadh, Jeddah)" ) main_section = models.ForeignKey( 'organizations.MainSection', on_delete=models.PROTECT, related_name='complaints', null=True, blank=True, help_text="Section/Department" ) subsection = models.ForeignKey( 'organizations.SubSection', on_delete=models.PROTECT, related_name='complaints', null=True, blank=True, help_text="Subsection within the section" ) # Type (complaint vs appreciation) complaint_type = models.CharField( max_length=20, choices=ComplaintType.choices, default=ComplaintType.COMPLAINT, db_index=True, help_text="Type of feedback (complaint vs appreciation)" ) # Source type (Internal vs External) complaint_source_type = models.CharField( max_length=20, choices=ComplaintSourceType.choices, default=ComplaintSourceType.EXTERNAL, db_index=True, help_text="Source type (Internal = staff-generated, External = patient/public-generated)" ) # Priority and severity priority = models.CharField( max_length=20, choices=PriorityChoices.choices, default=PriorityChoices.MEDIUM, db_index=True ) severity = models.CharField( max_length=20, choices=SeverityChoices.choices, default=SeverityChoices.MEDIUM, db_index=True ) # Source source = models.ForeignKey( "px_sources.PXSource", on_delete=models.PROTECT, related_name="complaints", null=True, blank=True, help_text="Source of complaint", ) # Creator tracking created_by = models.ForeignKey( 'accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='created_complaints', help_text="User who created this complaint (SourceUser or Patient)" ) # Status and workflow status = models.CharField( max_length=20, choices=ComplaintStatus.choices, default=ComplaintStatus.OPEN, db_index=True ) # Assignment assigned_to = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_complaints" ) assigned_at = models.DateTimeField(null=True, blank=True) # SLA tracking due_at = models.DateTimeField(db_index=True, help_text="SLA deadline") is_overdue = models.BooleanField(default=False, db_index=True) reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="First SLA reminder timestamp") second_reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="Second SLA reminder timestamp") escalated_at = models.DateTimeField(null=True, blank=True) # Resolution resolution = models.TextField(blank=True) resolution_sent_at = models.DateTimeField(null=True, blank=True) resolution_category = models.CharField( max_length=50, choices=ResolutionCategory.choices, blank=True, db_index=True, help_text="Category of resolution" ) resolved_at = models.DateTimeField(null=True, blank=True) resolved_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_complaints" ) # Closure closed_at = models.DateTimeField(null=True, blank=True) closed_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_complaints" ) # Resolution satisfaction survey resolution_survey = models.ForeignKey( "surveys.SurveyInstance", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaint_resolution" ) resolution_survey_sent_at = models.DateTimeField(null=True, blank=True) # Metadata metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["status", "-created_at"]), models.Index(fields=["hospital", "status", "-created_at"]), models.Index(fields=["is_overdue", "status"]), models.Index(fields=["due_at", "status"]), ] def __str__(self): return f"{self.title} - ({self.status})" def save(self, *args, **kwargs): """Calculate SLA due date on creation, generate reference number, and sync complaint_type from metadata""" # Track status change for signals if self.pk: try: old_instance = Complaint.objects.get(pk=self.pk) self._status_was = old_instance.status except Complaint.DoesNotExist: self._status_was = None # Generate reference number if not set (for all creation methods: form, API, admin) if not self.reference_number: from datetime import datetime import uuid today = datetime.now().strftime("%Y%m%d") random_suffix = str(uuid.uuid4().int)[:6] self.reference_number = f"CMP-{today}-{random_suffix}" if not self.due_at: self.due_at = self.calculate_sla_due_date() # Sync complaint_type from AI metadata if not already set # This ensures that model field stays in sync with AI classification if self.metadata and 'ai_analysis' in self.metadata: ai_complaint_type = self.metadata['ai_analysis'].get('complaint_type', 'complaint') # Only sync if model field is still default 'complaint' # This preserves any manual changes while fixing AI-synced complaints if self.complaint_type == 'complaint' and ai_complaint_type != 'complaint': self.complaint_type = ai_complaint_type super().save(*args, **kwargs) def calculate_sla_due_date(self): """ Calculate SLA due date based on source, severity, and hospital configuration. Priority order: 1. Source-based config (MOH, CHI, Internal) 2. Severity/priority-based config 3. Settings defaults Source-based configs take precedence over severity/priority-based configs. """ # Try source-based SLA config first if self.source: try: sla_config = ComplaintSLAConfig.objects.get( hospital=self.hospital, source=self.source, is_active=True ) sla_hours = sla_config.sla_hours return timezone.now() + timedelta(hours=sla_hours) except ComplaintSLAConfig.DoesNotExist: pass # Fall through to next option # Try severity/priority-based config try: sla_config = ComplaintSLAConfig.objects.get( hospital=self.hospital, source__isnull=True, # Explicitly check for null source severity=self.severity, priority=self.priority, is_active=True ) sla_hours = sla_config.sla_hours return timezone.now() + timedelta(hours=sla_hours) except ComplaintSLAConfig.DoesNotExist: pass # Fall through to next option # Try severity/priority-based config without source filter (backward compatibility) try: sla_config = ComplaintSLAConfig.objects.get( hospital=self.hospital, severity=self.severity, priority=self.priority, is_active=True ) sla_hours = sla_config.sla_hours return timezone.now() + timedelta(hours=sla_hours) except ComplaintSLAConfig.DoesNotExist: pass # Fall back to settings # Fall back to settings defaults sla_hours = settings.SLA_DEFAULTS["complaint"].get( self.severity, settings.SLA_DEFAULTS["complaint"]["medium"] ) return timezone.now() + timedelta(hours=sla_hours) def get_sla_config(self): """ Get the SLA config for this complaint. Returns the source-based or severity/priority-based config that applies to this complaint. Returns None if no config is found (will use defaults). """ # Try source-based SLA config first if self.source: try: return ComplaintSLAConfig.objects.get( hospital=self.hospital, source=self.source, is_active=True ) except ComplaintSLAConfig.DoesNotExist: pass # Fall through to next option # Try severity/priority-based config try: return ComplaintSLAConfig.objects.get( hospital=self.hospital, source__isnull=True, severity=self.severity, priority=self.priority, is_active=True ) except ComplaintSLAConfig.DoesNotExist: pass # Fall through to next option # Try severity/priority-based config without source filter (backward compatibility) try: return ComplaintSLAConfig.objects.get( hospital=self.hospital, severity=self.severity, priority=self.priority, is_active=True ) except ComplaintSLAConfig.DoesNotExist: pass # No config found return None def check_overdue(self): """Check if complaint is overdue and update status""" if self.status in [ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED]: return False if timezone.now() > self.due_at: if not self.is_overdue: self.is_overdue = True self.save(update_fields=["is_overdue"]) return True return False @property def is_active_status(self): """ Check if complaint is in an active status (can be worked on). Active statuses: OPEN, IN_PROGRESS, PARTIALLY_RESOLVED Inactive statuses: RESOLVED, CLOSED, CANCELLED """ return self.status in [ ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.PARTIALLY_RESOLVED ] @property def short_description_en(self): """Get AI-generated short description (English) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("short_description_en", "") return "" @property def short_description_ar(self): """Get AI-generated short description (Arabic) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("short_description_ar", "") return "" @property def short_description(self): """Get AI-generated short description from metadata (deprecated, use short_description_en)""" return self.short_description_en @property def suggested_action_en(self): """Get AI-generated suggested action (English) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("suggested_action_en", "") return "" @property def suggested_action_ar(self): """Get AI-generated suggested action (Arabic) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("suggested_action_ar", "") return "" @property def suggested_action(self): """Get AI-generated suggested action from metadata (deprecated, use suggested_action_en)""" return self.suggested_action_en @property def title_en(self): """Get AI-generated title (English) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("title_en", "") return "" @property def title_ar(self): """Get AI-generated title (Arabic) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("title_ar", "") return "" @property def reasoning_en(self): """Get AI-generated reasoning (English) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("reasoning_en", "") return "" @property def reasoning_ar(self): """Get AI-generated reasoning (Arabic) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("reasoning_ar", "") return "" @property def emotion(self): """Get AI-detected primary emotion from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("emotion", "neutral") return "neutral" @property def emotion_intensity(self): """Get AI-detected emotion intensity (0.0 to 1.0) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("emotion_intensity", 0.0) return 0.0 @property def emotion_confidence(self): """Get AI confidence in emotion detection (0.0 to 1.0) from metadata""" if self.metadata and "ai_analysis" in self.metadata: return self.metadata["ai_analysis"].get("emotion_confidence", 0.0) return 0.0 @property def emotion_confidence_percent(self): """Get AI confidence as percentage (0-100) from metadata""" return self.emotion_confidence * 100 @property def emotion_intensity_percent(self): """Get AI emotion intensity as percentage (0-100) from metadata""" return self.emotion_intensity * 100 @property def get_emotion_display(self): """Get human-readable emotion display""" emotion_map = { "anger": "Anger", "sadness": "Sadness", "confusion": "Confusion", "fear": "Fear", "neutral": "Neutral", } return emotion_map.get(self.emotion, "Neutral") @property def get_emotion_badge_class(self): """Get Bootstrap badge class for emotion""" badge_map = { "anger": "danger", "sadness": "primary", "confusion": "warning", "fear": "info", "neutral": "secondary", } return badge_map.get(self.emotion, "secondary") def get_tracking_url(self): """ Get the public tracking URL for this complaint. Returns the full URL that complainants can use to track their complaint status. """ from django.contrib.sites.shortcuts import get_current_site from django.urls import reverse # Build absolute URL try: site = get_current_site(None) domain = site.domain except: domain = 'localhost:8000' return f"https://{domain}{reverse('complaints:public_complaint_track')}?reference={self.reference_number}" class ComplaintAttachment(UUIDModel, TimeStampedModel): """Complaint attachment (images, documents, etc.)""" complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="attachments") file = models.FileField(upload_to="complaints/%Y/%m/%d/") filename = models.CharField(max_length=500) file_type = models.CharField(max_length=100, blank=True) file_size = models.IntegerField(help_text="File size in bytes") uploaded_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, related_name="complaint_attachments" ) description = models.TextField(blank=True) class Meta: ordering = ["-created_at"] def __str__(self): return f"{self.complaint} - {self.filename}" class ComplaintUpdate(UUIDModel, TimeStampedModel): """ Complaint update/timeline entry. Tracks all updates, status changes, and communications. """ complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="updates") # Update details update_type = models.CharField( max_length=50, choices=[ ("status_change", "Status Change"), ("assignment", "Assignment"), ("note", "Note"), ("resolution", "Resolution"), ("escalation", "Escalation"), ("communication", "Communication"), ], db_index=True, ) message = models.TextField() # User who made the update created_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, related_name="complaint_updates" ) # Status change tracking old_status = models.CharField(max_length=20, blank=True) new_status = models.CharField(max_length=20, blank=True) # Metadata metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["complaint", "-created_at"]), ] def __str__(self): return f"{self.complaint} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}" class ComplaintSLAConfig(UUIDModel, TimeStampedModel): """ SLA configuration for complaints per hospital, source, severity, and priority. Allows flexible SLA configuration instead of hardcoded values. Supports both source-based (MOH, CHI, Internal) and severity/priority-based configurations. """ hospital = models.ForeignKey( "organizations.Hospital", on_delete=models.CASCADE, related_name="complaint_sla_configs" ) source = models.ForeignKey( "px_sources.PXSource", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaint_sla_configs", help_text="Complaint source (MOH, CHI, Patient, etc.). Empty = severity/priority-based config" ) severity = models.CharField( max_length=20, choices=SeverityChoices.choices, null=True, blank=True, help_text="Severity level for this SLA (optional if source is specified)" ) priority = models.CharField( max_length=20, choices=PriorityChoices.choices, null=True, blank=True, help_text="Priority level for this SLA (optional if source is specified)" ) sla_hours = models.IntegerField(help_text="Number of hours until SLA deadline") # Source-based reminder timing (from complaint creation) first_reminder_hours_after = models.IntegerField( default=0, help_text="Send 1st reminder X hours after complaint creation (0 = use reminder_hours_before)" ) second_reminder_hours_after = models.IntegerField( default=0, help_text="Send 2nd reminder X hours after complaint creation (0 = use second_reminder_hours_before)" ) escalation_hours_after = models.IntegerField( default=0, help_text="Escalate complaint X hours after creation if unresolved (0 = use overdue logic)" ) # Legacy reminder timing (before deadline - kept for backward compatibility) reminder_hours_before = models.IntegerField(default=24, help_text="Send first reminder X hours before deadline") # Second reminder configuration second_reminder_enabled = models.BooleanField(default=False, help_text="Enable sending a second reminder") second_reminder_hours_before = models.IntegerField(default=6, help_text="Send second reminder X hours before deadline") # Thank you email configuration thank_you_email_enabled = models.BooleanField(default=False, help_text="Send thank you email when complaint is closed") is_active = models.BooleanField(default=True) class Meta: ordering = ["hospital", "source", "severity", "priority"] unique_together = [["hospital", "source", "severity", "priority"]] indexes = [ models.Index(fields=["hospital", "is_active"]), models.Index(fields=["hospital", "source", "is_active"]), ] def __str__(self): source_display = self.source.name_en if self.source else "Any Source" sev_display = self.severity if self.severity else "Any Severity" pri_display = self.priority if self.priority else "Any Priority" return f"{self.hospital.name} - {source_display} - {sev_display}/{pri_display} - {self.sla_hours}h" def get_first_reminder_hours_after(self, complaint_created_at=None): """ Calculate first reminder timing based on config. Returns hours after creation if configured, else hours before deadline. """ if self.first_reminder_hours_after > 0: return self.first_reminder_hours_after else: return max(0, self.sla_hours - self.reminder_hours_before) def get_second_reminder_hours_after(self, complaint_created_at=None): """ Calculate second reminder timing based on config. Returns hours after creation if configured, else hours before deadline. """ if self.second_reminder_hours_after > 0: return self.second_reminder_hours_after elif self.second_reminder_enabled: return max(0, self.sla_hours - self.second_reminder_hours_before) else: return 0 # No second reminder def get_escalation_hours_after(self, complaint_created_at=None): """ Calculate escalation timing based on config. Returns hours after creation if configured, else use overdue logic (after SLA). """ if self.escalation_hours_after > 0: return self.escalation_hours_after else: return None # Use standard overdue logic class EscalationRule(UUIDModel, TimeStampedModel): """ Configurable escalation rules for complaints. Defines who receives escalated complaints based on conditions. Supports multi-level escalation with configurable hierarchy. """ hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="escalation_rules") name = models.CharField(max_length=200) description = models.TextField(blank=True) # Escalation level (supports multi-level escalation) escalation_level = models.IntegerField( default=1, help_text="Escalation level (1 = first level, 2 = second, etc.)" ) max_escalation_level = models.IntegerField( default=3, help_text="Maximum escalation level before stopping (default: 3)" ) # Trigger conditions trigger_on_overdue = models.BooleanField(default=True, help_text="Trigger when complaint is overdue") trigger_hours_overdue = models.IntegerField(default=0, help_text="Trigger X hours after overdue (0 = immediately)") # Reminder-based escalation reminder_escalation_enabled = models.BooleanField( default=False, help_text="Enable escalation after reminder if no action taken" ) reminder_escalation_hours = models.IntegerField( default=24, help_text="Escalate X hours after reminder if no action" ) # Escalation target escalate_to_role = models.CharField( max_length=50, choices=[ ("department_manager", "Department Manager"), ("hospital_admin", "Hospital Admin"), ("medical_director", "Medical Director"), ("admin_director", "Administrative Director"), ("px_admin", "PX Admin"), ("ceo", "CEO"), ("specific_user", "Specific User"), ], help_text="Role to escalate to", ) escalate_to_user = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="escalation_target_rules", help_text="Specific user if escalate_to_role is 'specific_user'", ) # Conditions severity_filter = models.CharField( max_length=20, choices=SeverityChoices.choices, blank=True, help_text="Only escalate complaints with this severity (blank = all)", ) priority_filter = models.CharField( max_length=20, choices=PriorityChoices.choices, blank=True, help_text="Only escalate complaints with this priority (blank = all)", ) order = models.IntegerField(default=0, help_text="Escalation order (lower = first)") is_active = models.BooleanField(default=True) class Meta: ordering = ["hospital", "order"] indexes = [ models.Index(fields=["hospital", "is_active"]), ] def __str__(self): return f"{self.hospital.name} - {self.name}" class ComplaintThreshold(UUIDModel, TimeStampedModel): """ Configurable thresholds for complaint-related triggers. Defines when to trigger actions based on metrics (e.g., survey scores). """ hospital = models.ForeignKey( "organizations.Hospital", on_delete=models.CASCADE, related_name="complaint_thresholds" ) threshold_type = models.CharField( max_length=50, choices=[ ("resolution_survey_score", "Resolution Survey Score"), ("response_time", "Response Time"), ("resolution_time", "Resolution Time"), ], help_text="Type of threshold", ) threshold_value = models.FloatField(help_text="Threshold value (e.g., 50 for 50% score)") comparison_operator = models.CharField( max_length=10, choices=[ ("lt", "Less Than"), ("lte", "Less Than or Equal"), ("gt", "Greater Than"), ("gte", "Greater Than or Equal"), ("eq", "Equal"), ], default="lt", help_text="How to compare against threshold", ) action_type = models.CharField( max_length=50, choices=[ ("create_px_action", "Create PX Action"), ("send_notification", "Send Notification"), ("escalate", "Escalate"), ], help_text="Action to take when threshold is breached", ) is_active = models.BooleanField(default=True) class Meta: ordering = ["hospital", "threshold_type"] indexes = [ models.Index(fields=["hospital", "is_active"]), models.Index(fields=["threshold_type", "is_active"]), ] def __str__(self): return f"{self.hospital.name} - {self.threshold_type} {self.comparison_operator} {self.threshold_value}" def check_threshold(self, value): """Check if value breaches threshold""" if self.comparison_operator == "lt": return value < self.threshold_value elif self.comparison_operator == "lte": return value <= self.threshold_value elif self.comparison_operator == "gt": return value > self.threshold_value elif self.comparison_operator == "gte": return value >= self.threshold_value elif self.comparison_operator == "eq": return value == self.threshold_value return False class ExplanationSLAConfig(UUIDModel, TimeStampedModel): """ SLA configuration for staff explanation requests. Defines time limits and escalation rules for staff to submit explanations when a complaint is filed against them. """ hospital = models.ForeignKey( "organizations.Hospital", on_delete=models.CASCADE, related_name="explanation_sla_configs" ) # Time limits response_hours = models.IntegerField( default=48, help_text="Hours staff has to submit explanation" ) reminder_hours_before = models.IntegerField( default=12, help_text="Send reminder X hours before deadline" ) # Escalation settings auto_escalate_enabled = models.BooleanField( default=True, help_text="Automatically escalate to manager if no response" ) escalation_hours_overdue = models.IntegerField( default=0, help_text="Escalate X hours after overdue (0 = immediately)" ) max_escalation_levels = models.IntegerField( default=3, help_text="Maximum levels to escalate up staff hierarchy" ) is_active = models.BooleanField(default=True) class Meta: ordering = ["hospital"] verbose_name = "Explanation SLA Config" verbose_name_plural = "Explanation SLA Configs" indexes = [ models.Index(fields=["hospital", "is_active"]), ] def __str__(self): return f"{self.hospital.name} - {self.response_hours}h to respond" class Inquiry(UUIDModel, TimeStampedModel): """ Inquiry model for general questions/requests. Similar to complaints but for non-complaint inquiries. """ # Patient information patient = models.ForeignKey( "organizations.Patient", on_delete=models.CASCADE, null=True, blank=True, related_name="inquiries" ) # Contact information (if patient not in system) contact_name = models.CharField(max_length=200, blank=True) contact_phone = models.CharField(max_length=20, blank=True) contact_email = models.EmailField(blank=True) # Organization hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="inquiries") department = models.ForeignKey( "organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="inquiries" ) # Inquiry details subject = models.CharField(max_length=500) message = models.TextField() # Category category = models.CharField( max_length=100, choices=[ ("appointment", "Appointment"), ("billing", "Billing"), ("medical_records", "Medical Records"), ("general", "General Information"), ("other", "Other"), ], ) # Source source = models.ForeignKey( "px_sources.PXSource", on_delete=models.PROTECT, related_name="inquiries", null=True, blank=True, help_text="Source of inquiry", ) # Status status = models.CharField( max_length=20, choices=[ ("open", "Open"), ("in_progress", "In Progress"), ("resolved", "Resolved"), ("closed", "Closed"), ], default="open", db_index=True, ) # Creator tracking created_by = models.ForeignKey( 'accounts.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='created_inquiries', help_text="User who created this inquiry (SourceUser or Patient)" ) # Assignment assigned_to = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_inquiries" ) assigned_at = models.DateTimeField(null=True, blank=True) # Response response = models.TextField(blank=True) responded_at = models.DateTimeField(null=True, blank=True) responded_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_inquiries" ) # Workflow classification is_straightforward = models.BooleanField( default=True, verbose_name="Is Straightforward", help_text="Direct resolution (no department coordination needed)" ) is_outgoing = models.BooleanField( default=False, verbose_name="Is Outgoing", help_text="Inquiry sent to external department for response" ) outgoing_department = models.ForeignKey( "organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="outgoing_inquiries", help_text="Department that was contacted for this inquiry" ) # Follow-up tracking requires_follow_up = models.BooleanField( default=False, db_index=True, help_text="This inquiry requires follow-up call" ) follow_up_due_at = models.DateTimeField( null=True, blank=True, db_index=True, help_text="Due date for follow-up call to inquirer" ) follow_up_completed_at = models.DateTimeField( null=True, blank=True, help_text="When follow-up call was completed" ) follow_up_completed_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="completed_inquiry_followups", help_text="User who completed the follow-up call" ) follow_up_notes = models.TextField( blank=True, help_text="Notes from follow-up call" ) follow_up_reminder_sent_at = models.DateTimeField( null=True, blank=True, help_text="When reminder was sent for follow-up" ) class Meta: ordering = ["-created_at"] verbose_name_plural = "Inquiries" indexes = [ models.Index(fields=["status", "-created_at"]), models.Index(fields=["hospital", "status"]), ] def __str__(self): return f"{self.subject} ({self.status})" class InquiryUpdate(UUIDModel, TimeStampedModel): """ Inquiry update/timeline entry. Tracks all updates, status changes, and communications for inquiries. """ inquiry = models.ForeignKey(Inquiry, on_delete=models.CASCADE, related_name="updates") # Update details update_type = models.CharField( max_length=50, choices=[ ("status_change", "Status Change"), ("assignment", "Assignment"), ("note", "Note"), ("response", "Response"), ("communication", "Communication"), ], db_index=True, ) message = models.TextField() # User who made the update created_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, related_name="inquiry_updates" ) # Status change tracking old_status = models.CharField(max_length=20, blank=True) new_status = models.CharField(max_length=20, blank=True) # Metadata metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["inquiry", "-created_at"]), ] def __str__(self): return f"{self.inquiry} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}" class InquiryAttachment(UUIDModel, TimeStampedModel): """Inquiry attachment (images, documents, etc.)""" inquiry = models.ForeignKey(Inquiry, on_delete=models.CASCADE, related_name="attachments") file = models.FileField(upload_to="inquiries/%Y/%m/%d/") filename = models.CharField(max_length=500) file_type = models.CharField(max_length=100, blank=True) file_size = models.IntegerField(help_text="File size in bytes") uploaded_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, related_name="inquiry_attachments" ) description = models.TextField(blank=True) class Meta: ordering = ["-created_at"] def __str__(self): return f"{self.inquiry} - {self.filename}" class ComplaintExplanation(UUIDModel, TimeStampedModel): """ Staff/recipient explanation about a complaint. Allows staff members to submit their perspective via token-based link. Each staff member can submit one explanation per complaint. Includes SLA tracking for explanation submission deadlines. """ complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="explanations") staff = models.ForeignKey( "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaint_explanations", help_text="Staff member who submitted the explanation", ) explanation = models.TextField(help_text="Staff's explanation about the complaint") token = models.CharField( max_length=64, unique=True, db_index=True, help_text="Unique access token for explanation submission" ) is_used = models.BooleanField( default=False, db_index=True, help_text="Token expiry tracking - becomes True after submission" ) submitted_via = models.CharField( max_length=20, choices=[ ("email_link", "Email Link"), ("direct", "Direct Entry"), ], default="email_link", help_text="How the explanation was submitted", ) email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When the explanation request email was sent") responded_at = models.DateTimeField(null=True, blank=True, help_text="When the explanation was submitted") # Request details requested_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="requested_complaint_explanations", help_text="User who requested the explanation", ) request_message = models.TextField(blank=True, help_text="Optional message sent with the explanation request") # SLA tracking for explanation requests sla_due_at = models.DateTimeField( null=True, blank=True, db_index=True, help_text="SLA deadline for staff to submit explanation" ) is_overdue = models.BooleanField( default=False, db_index=True, help_text="Explanation request is overdue" ) reminder_sent_at = models.DateTimeField( null=True, blank=True, help_text="Reminder sent to staff about overdue explanation" ) escalated_to_manager = models.ForeignKey( 'self', on_delete=models.SET_NULL, null=True, blank=True, related_name='escalated_from_staff', help_text="Escalated to this explanation (manager's explanation request)" ) escalated_at = models.DateTimeField( null=True, blank=True, help_text="When explanation was escalated to manager" ) # Acceptance review fields class AcceptanceStatus(models.TextChoices): PENDING = "pending", "Pending Review" ACCEPTABLE = "acceptable", "Acceptable" NOT_ACCEPTABLE = "not_acceptable", "Not Acceptable" acceptance_status = models.CharField( max_length=20, choices=AcceptanceStatus.choices, default=AcceptanceStatus.PENDING, help_text="Review status of the explanation" ) accepted_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="reviewed_explanations", help_text="User who reviewed and marked the explanation" ) accepted_at = models.DateTimeField( null=True, blank=True, help_text="When the explanation was reviewed" ) acceptance_notes = models.TextField( blank=True, help_text="Notes about the acceptance decision" ) class Meta: ordering = ["-created_at"] verbose_name = "Complaint Explanation" verbose_name_plural = "Complaint Explanations" indexes = [ models.Index(fields=["complaint", "-created_at"]), models.Index(fields=["token", "is_used"]), ] def __str__(self): staff_name = str(self.staff) if self.staff else "Unknown" return f"{self.complaint} - {staff_name} - {'Submitted' if self.is_used else 'Pending'}" @property def can_submit(self): """Check if explanation can still be submitted""" return not self.is_used @property def attachment_count(self): """Count of explanation attachments""" return self.attachments.count() def get_token(self): """Return the access token""" return self.token class ExplanationAttachment(UUIDModel, TimeStampedModel): """Attachment for complaint explanation""" explanation = models.ForeignKey(ComplaintExplanation, on_delete=models.CASCADE, related_name="attachments") file = models.FileField(upload_to="explanation_attachments/%Y/%m/%d/") filename = models.CharField(max_length=500) file_type = models.CharField(max_length=100, blank=True) file_size = models.IntegerField(help_text="File size in bytes") description = models.TextField(blank=True) class Meta: ordering = ["-created_at"] verbose_name = "Explanation Attachment" verbose_name_plural = "Explanation Attachments" def __str__(self): return f"{self.explanation} - {self.filename}" class ComplaintPRInteraction(UUIDModel, TimeStampedModel): """ PR (Patient Relations) contact with complainant. Tracks when PR staff contact the complainant to take a formal statement and explain the complaint procedure. """ complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="pr_interactions") contact_date = models.DateTimeField( help_text="Date and time of PR contact with complainant" ) contact_method = models.CharField( max_length=20, choices=[ ("phone", "Phone"), ("in_person", "In Person"), ("email", "Email"), ("other", "Other"), ], default="in_person", help_text="Method of contact" ) pr_staff = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="pr_interactions", help_text="PR staff member who made the contact" ) statement_text = models.TextField( blank=True, help_text="Formal statement taken from the complainant" ) procedure_explained = models.BooleanField( default=False, help_text="Whether complaint procedure was explained to the complainant" ) notes = models.TextField( blank=True, help_text="Additional notes from the PR interaction" ) created_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="created_pr_interactions", help_text="User who created this PR interaction record" ) class Meta: ordering = ["-contact_date"] verbose_name = "PR Interaction" verbose_name_plural = "PR Interactions" indexes = [ models.Index(fields=["complaint", "-contact_date"]), ] def __str__(self): method_display = self.get_contact_method_display() return f"{self.complaint} - {method_display} - {self.contact_date.strftime('%Y-%m-%d')}" class ComplaintMeeting(UUIDModel, TimeStampedModel): """ Meeting record for management intervention. Simple record of meetings scheduled/agreed upon between management and the complainant to resolve complaints. """ complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="meetings") meeting_date = models.DateTimeField( help_text="Date and time of the meeting" ) meeting_type = models.CharField( max_length=50, choices=[ ("management_intervention", "Management Intervention"), ("pr_follow_up", "PR Follow-up"), ("department_review", "Department Review"), ("other", "Other"), ], default="management_intervention", help_text="Type of meeting" ) outcome = models.TextField( blank=True, help_text="Meeting outcome and agreed resolution" ) notes = models.TextField( blank=True, help_text="Additional meeting notes" ) created_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="created_meetings", help_text="User who created this meeting record" ) class Meta: ordering = ["-meeting_date"] verbose_name = "Complaint Meeting" verbose_name_plural = "Complaint Meetings" indexes = [ models.Index(fields=["complaint", "-meeting_date"]), ] def __str__(self): type_display = self.get_meeting_type_display() return f"{self.complaint} - {type_display} - {self.meeting_date.strftime('%Y-%m-%d')}" class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel): """ Tracks departments involved in a complaint. Allows multiple departments to be associated with a single complaint with specific roles (primary, secondary/supporting, coordination). """ class RoleChoices(models.TextChoices): PRIMARY = "primary", "Primary Department" SECONDARY = "secondary", "Secondary/Supporting" COORDINATION = "coordination", "Coordination Only" INVESTIGATING = "investigating", "Investigating" complaint = models.ForeignKey( Complaint, on_delete=models.CASCADE, related_name="involved_departments" ) department = models.ForeignKey( "organizations.Department", on_delete=models.CASCADE, related_name="complaint_involvements" ) role = models.CharField( max_length=20, choices=RoleChoices.choices, default=RoleChoices.SECONDARY, help_text="Role of this department in the complaint resolution" ) is_primary = models.BooleanField( default=False, help_text="Mark as the primary responsible department" ) added_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="added_department_involvements" ) notes = models.TextField( blank=True, help_text="Additional notes about this department's involvement" ) # Assignment within this department assigned_to = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="department_assigned_complaints", help_text="User assigned from this department to handle the complaint" ) assigned_at = models.DateTimeField( null=True, blank=True ) # Response tracking response_submitted = models.BooleanField( default=False, help_text="Whether this department has submitted their response" ) response_submitted_at = models.DateTimeField( null=True, blank=True ) response_notes = models.TextField( blank=True, help_text="Department's response/feedback on the complaint" ) class Meta: ordering = ["-is_primary", "-created_at"] verbose_name = "Complaint Involved Department" verbose_name_plural = "Complaint Involved Departments" unique_together = [["complaint", "department"]] indexes = [ models.Index(fields=["complaint", "role"]), models.Index(fields=["department", "response_submitted"]), ] def __str__(self): role_display = self.get_role_display() primary_flag = " [PRIMARY]" if self.is_primary else "" return f"{self.complaint.reference_number} - {self.department.name} ({role_display}){primary_flag}" def save(self, *args, **kwargs): """Ensure only one primary department per complaint""" if self.is_primary: # Clear primary flag from other departments for this complaint ComplaintInvolvedDepartment.objects.filter( complaint=self.complaint, is_primary=True ).exclude(pk=self.pk).update(is_primary=False) super().save(*args, **kwargs) class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel): """ Tracks staff members involved in a complaint. Allows multiple staff to be associated with a single complaint with specific roles (accused, witness, responsible, etc.). """ class RoleChoices(models.TextChoices): ACCUSED = "accused", "Accused/Involved" WITNESS = "witness", "Witness" RESPONSIBLE = "responsible", "Responsible for Resolution" INVESTIGATOR = "investigator", "Investigator" SUPPORT = "support", "Support Staff" COORDINATOR = "coordinator", "Coordinator" complaint = models.ForeignKey( Complaint, on_delete=models.CASCADE, related_name="involved_staff" ) staff = models.ForeignKey( "organizations.Staff", on_delete=models.CASCADE, related_name="complaint_involvements" ) role = models.CharField( max_length=20, choices=RoleChoices.choices, default=RoleChoices.ACCUSED, help_text="Role of this staff member in the complaint" ) added_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="added_staff_involvements" ) notes = models.TextField( blank=True, help_text="Additional notes about this staff member's involvement" ) # Explanation tracking explanation_requested = models.BooleanField( default=False, help_text="Whether an explanation has been requested from this staff" ) explanation_requested_at = models.DateTimeField( null=True, blank=True ) explanation_received = models.BooleanField( default=False, help_text="Whether an explanation has been received" ) explanation_received_at = models.DateTimeField( null=True, blank=True ) explanation = models.TextField( blank=True, help_text="The staff member's explanation" ) class Meta: ordering = ["role", "-created_at"] verbose_name = "Complaint Involved Staff" verbose_name_plural = "Complaint Involved Staff" unique_together = [["complaint", "staff"]] indexes = [ models.Index(fields=["complaint", "role"]), models.Index(fields=["staff", "explanation_received"]), ] def __str__(self): role_display = self.get_role_display() return f"{self.complaint.reference_number} - {self.staff} ({role_display})" class OnCallAdminSchedule(UUIDModel, TimeStampedModel): """ On-call admin schedule configuration for complaint notifications. Manages which PX Admins should be notified outside of working hours. During working hours, ALL PX Admins are notified. Outside working hours, only ON-CALL admins are notified. """ # Working days configuration (stored as list of day numbers: 0=Monday, 6=Sunday) working_days = models.JSONField( default=list, help_text="List of working days (0=Monday, 6=Sunday). Default: [0,1,2,3,4] (Mon-Fri)" ) # Working hours work_start_time = models.TimeField( default="08:00", help_text="Start of working hours (e.g., 08:00)" ) work_end_time = models.TimeField( default="17:00", help_text="End of working hours (e.g., 17:00)" ) # Timezone for the schedule timezone = models.CharField( max_length=50, default="Asia/Riyadh", help_text="Timezone for working hours calculation (e.g., Asia/Riyadh)" ) # Whether this config is active is_active = models.BooleanField( default=True, help_text="Whether this on-call schedule is active" ) # Hospital scope (null = system-wide) hospital = models.ForeignKey( "organizations.Hospital", on_delete=models.CASCADE, null=True, blank=True, related_name="on_call_schedules", help_text="Hospital scope. Leave empty for system-wide configuration." ) class Meta: ordering = ["-created_at"] verbose_name = "On-Call Admin Schedule" verbose_name_plural = "On-Call Admin Schedules" constraints = [ models.UniqueConstraint( fields=['hospital'], condition=models.Q(hospital__isnull=False), name='unique_oncall_per_hospital' ), models.UniqueConstraint( fields=['hospital'], condition=models.Q(hospital__isnull=True), name='unique_system_wide_oncall' ), ] def __str__(self): scope = f"{self.hospital.name}" if self.hospital else "System-wide" start_time = self.work_start_time.strftime('%H:%M') if hasattr(self.work_start_time, 'strftime') else str(self.work_start_time)[:5] end_time = self.work_end_time.strftime('%H:%M') if hasattr(self.work_end_time, 'strftime') else str(self.work_end_time)[:5] return f"On-Call Schedule - {scope} ({start_time}-{end_time})" def get_working_days_list(self): """Get list of working days, with default if empty""" if self.working_days: return self.working_days return [0, 1, 2, 3, 4] # Default: Monday-Friday def is_working_time(self, check_datetime=None): """ Check if the given datetime is within working hours. Args: check_datetime: datetime to check (default: now) Returns: bool: True if within working hours, False otherwise """ import pytz if check_datetime is None: check_datetime = timezone.now() # Convert to schedule timezone tz = pytz.timezone(self.timezone) if timezone.is_aware(check_datetime): local_time = check_datetime.astimezone(tz) else: local_time = check_datetime.replace(tzinfo=tz) # Check if it's a working day working_days = self.get_working_days_list() if local_time.weekday() not in working_days: return False # Check if it's within working hours current_time = local_time.time() return self.work_start_time <= current_time < self.work_end_time class OnCallAdmin(UUIDModel, TimeStampedModel): """ Individual on-call admin assignment. Links PX Admin users to an on-call schedule. """ schedule = models.ForeignKey( OnCallAdminSchedule, on_delete=models.CASCADE, related_name="on_call_admins" ) admin_user = models.ForeignKey( "accounts.User", on_delete=models.CASCADE, related_name="on_call_schedules", help_text="PX Admin user who is on-call", limit_choices_to={'groups__name': 'PX Admin'} ) # Optional: date range for this on-call assignment start_date = models.DateField( null=True, blank=True, help_text="Start date for this on-call assignment (optional)" ) end_date = models.DateField( null=True, blank=True, help_text="End date for this on-call assignment (optional)" ) # Priority/order for notifications (lower = higher priority) notification_priority = models.PositiveIntegerField( default=1, help_text="Priority for notifications (1 = highest)" ) is_active = models.BooleanField( default=True, help_text="Whether this on-call assignment is currently active" ) # Contact preferences for out-of-hours notify_email = models.BooleanField( default=True, help_text="Send email notifications" ) notify_sms = models.BooleanField( default=False, help_text="Send SMS notifications" ) # Custom phone for SMS (optional, uses user's phone if not set) sms_phone = models.CharField( max_length=20, blank=True, help_text="Custom phone number for SMS notifications (optional)" ) class Meta: ordering = ["notification_priority", "-created_at"] verbose_name = "On-Call Admin" verbose_name_plural = "On-Call Admins" unique_together = [["schedule", "admin_user"]] def __str__(self): return f"{self.admin_user.get_full_name() or self.admin_user.email} - On-Call ({self.schedule})" def is_currently_active(self, check_date=None): """ Check if this on-call assignment is active for the given date. Args: check_date: date to check (default: today) Returns: bool: True if active, False otherwise """ if not self.is_active: return False if check_date is None: check_date = timezone.now().date() # Check date range if self.start_date and check_date < self.start_date: return False if self.end_date and check_date > self.end_date: return False return True def get_notification_phone(self): """Get phone number for SMS notifications""" if self.sms_phone: return self.sms_phone if hasattr(self.admin_user, 'phone') and self.admin_user.phone: return self.admin_user.phone return None class ComplaintAdverseAction(UUIDModel, TimeStampedModel): """ Tracks adverse actions or damages to patients related to complaints. This model helps identify and address retaliation or negative treatment that patients may experience after filing a complaint. Examples: - Doctor refusing to see the patient in subsequent visits - Delayed or denied treatment - Verbal abuse or hostile behavior - Increased wait times - Unnecessary procedures - Dismissal from care """ class ActionType(models.TextChoices): """Types of adverse actions""" REFUSED_SERVICE = "refused_service", _("Refused Service") DELAYED_TREATMENT = "delayed_treatment", _("Delayed Treatment") VERBAL_ABUSE = "verbal_abuse", _("Verbal Abuse / Hostility") INCREASED_WAIT = "increased_wait", _("Increased Wait Time") UNNECESSARY_PROCEDURE = "unnecessary_procedure", _("Unnecessary Procedure") DISMISSED_FROM_CARE = "dismissed_from_care", _("Dismissed from Care") POOR_TREATMENT = "poor_treatment", _("Poor Treatment Quality") DISCRIMINATION = "discrimination", _("Discrimination") RETALIATION = "retaliation", _("Retaliation") OTHER = "other", _("Other") class SeverityLevel(models.TextChoices): """Severity levels for adverse actions""" LOW = "low", _("Low - Minor inconvenience") MEDIUM = "medium", _("Medium - Moderate impact") HIGH = "high", _("High - Significant harm") CRITICAL = "critical", _("Critical - Severe harm / Life-threatening") class VerificationStatus(models.TextChoices): """Verification status of the adverse action report""" REPORTED = "reported", _("Reported - Awaiting Review") UNDER_INVESTIGATION = "under_investigation", _("Under Investigation") VERIFIED = "verified", _("Verified") UNFOUNDED = "unfounded", _("Unfounded") RESOLVED = "resolved", _("Resolved") # Link to complaint complaint = models.ForeignKey( Complaint, on_delete=models.CASCADE, related_name="adverse_actions", help_text=_("The complaint this adverse action is related to") ) # Action details action_type = models.CharField( max_length=30, choices=ActionType.choices, default=ActionType.OTHER, help_text=_("Type of adverse action") ) severity = models.CharField( max_length=10, choices=SeverityLevel.choices, default=SeverityLevel.MEDIUM, help_text=_("Severity level of the adverse action") ) description = models.TextField( help_text=_("Detailed description of what happened to the patient") ) # When it occurred incident_date = models.DateTimeField( help_text=_("Date and time when the adverse action occurred") ) # Location/Context location = models.CharField( max_length=200, blank=True, help_text=_("Location where the incident occurred (e.g., Emergency Room, Clinic B)") ) # Staff involved involved_staff = models.ManyToManyField( "organizations.Staff", blank=True, related_name="adverse_actions_involved", help_text=_("Staff members involved in the adverse action") ) # Impact on patient patient_impact = models.TextField( blank=True, help_text=_("Description of the impact on the patient (physical, emotional, financial)") ) # Verification and handling status = models.CharField( max_length=30, choices=VerificationStatus.choices, default=VerificationStatus.REPORTED, help_text=_("Current status of the adverse action report") ) reported_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="reported_adverse_actions", help_text=_("User who reported this adverse action") ) # Investigation investigation_notes = models.TextField( blank=True, help_text=_("Notes from the investigation") ) investigated_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="investigated_adverse_actions", help_text=_("User who investigated this adverse action") ) investigated_at = models.DateTimeField( null=True, blank=True, help_text=_("When the investigation was completed") ) # Resolution resolution = models.TextField( blank=True, help_text=_("How the adverse action was resolved") ) resolved_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_adverse_actions", help_text=_("User who resolved this adverse action") ) resolved_at = models.DateTimeField( null=True, blank=True, help_text=_("When the adverse action was resolved") ) # Metadata is_escalated = models.BooleanField( default=False, help_text=_("Whether this adverse action has been escalated to management") ) escalated_at = models.DateTimeField( null=True, blank=True, help_text=_("When the adverse action was escalated") ) class Meta: ordering = ["-incident_date", "-created_at"] verbose_name = _("Complaint Adverse Action") verbose_name_plural = _("Complaint Adverse Actions") indexes = [ models.Index(fields=["complaint", "-incident_date"]), models.Index(fields=["action_type", "severity"]), models.Index(fields=["status", "-created_at"]), ] def __str__(self): return f"{self.complaint.reference_number} - {self.get_action_type_display()} ({self.get_severity_display()})" @property def is_high_severity(self): """Check if this is a high or critical severity adverse action""" return self.severity in [self.SeverityLevel.HIGH, self.SeverityLevel.CRITICAL] @property def days_since_incident(self): """Calculate days since the incident occurred""" from django.utils import timezone if self.incident_date: return (timezone.now() - self.incident_date).days return None @property def requires_investigation(self): """Check if this adverse action requires investigation""" return self.status in [self.VerificationStatus.REPORTED, self.VerificationStatus.UNDER_INVESTIGATION] class ComplaintAdverseActionAttachment(UUIDModel, TimeStampedModel): """ Attachments for adverse action reports (evidence, documents, etc.) """ adverse_action = models.ForeignKey( ComplaintAdverseAction, on_delete=models.CASCADE, related_name="attachments" ) file = models.FileField( upload_to="complaints/adverse_actions/%Y/%m/%d/", help_text=_("Attachment file (image, document, audio recording, etc.)") ) filename = models.CharField(max_length=255) file_type = models.CharField(max_length=100, blank=True) file_size = models.IntegerField(help_text=_("File size in bytes")) description = models.TextField( blank=True, help_text=_("Description of what this attachment shows") ) uploaded_by = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, related_name="adverse_action_attachments" ) class Meta: ordering = ["-created_at"] verbose_name = _("Adverse Action Attachment") verbose_name_plural = _("Adverse Action Attachments") def __str__(self): return f"{self.adverse_action} - {self.filename}"