2769 lines
97 KiB
Python
2769 lines
97 KiB
Python
"""
|
|
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"
|
|
CONTACTED = "contacted", "Contacted"
|
|
CONTACTED_NO_RESPONSE = "contacted_no_response", "Contacted, No Response"
|
|
|
|
|
|
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 ResolutionOutcome(models.TextChoices):
|
|
"""Resolution outcome - who was in wrong/right"""
|
|
|
|
PATIENT = "patient", "Patient"
|
|
HOSPITAL = "hospital", "Hospital"
|
|
OTHER = "other", "Other — please specify"
|
|
|
|
|
|
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()
|
|
|
|
ai_brief_en = models.CharField(
|
|
max_length=100, blank=True, db_index=True, help_text="AI-generated 2-3 word summary in English"
|
|
)
|
|
ai_brief_ar = models.CharField(max_length=100, blank=True, help_text="AI-generated 2-3 word summary in Arabic")
|
|
|
|
# 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=25, 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)
|
|
|
|
# Activation tracking
|
|
activated_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Timestamp when complaint was first activated (moved from OPEN to IN_PROGRESS)",
|
|
)
|
|
|
|
# SLA tracking
|
|
due_at = models.DateTimeField(db_index=True, help_text="SLA deadline")
|
|
is_overdue = models.BooleanField(default=False, db_index=True)
|
|
breached_at = models.DateTimeField(
|
|
null=True, blank=True, db_index=True, help_text="Timestamp when complaint first breached SLA"
|
|
)
|
|
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)
|
|
|
|
# Explanation tracking
|
|
explanation_requested = models.BooleanField(
|
|
default=False, help_text="Whether an explanation has been requested from staff"
|
|
)
|
|
explanation_requested_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When explanation request was first sent to staff"
|
|
)
|
|
explanation_received_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When explanation was received from staff"
|
|
)
|
|
explanation_delay_reason = models.TextField(blank=True, help_text="Reason for delay in receiving staff explanation")
|
|
|
|
# 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"
|
|
)
|
|
resolution_outcome = models.CharField(
|
|
max_length=20,
|
|
choices=ResolutionOutcome.choices,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Who was in wrong/right (Patient / Hospital / Other)",
|
|
)
|
|
resolution_outcome_other = models.TextField(
|
|
blank=True, help_text="Specify if Other was selected for resolution outcome"
|
|
)
|
|
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)
|
|
|
|
# Direct satisfaction tracking (from calls/follow-ups)
|
|
satisfaction = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("satisfied", "Satisfied"),
|
|
("neutral", "Neutral"),
|
|
("dissatisfied", "Dissatisfied"),
|
|
("no_response", "No Response"),
|
|
("escalated", "Escalated"),
|
|
],
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Direct satisfaction feedback from patient follow-up call",
|
|
)
|
|
|
|
# External references
|
|
moh_reference = models.CharField(max_length=100, blank=True, help_text="Ministry of Health reference number")
|
|
moh_reference_date = models.DateField(null=True, blank=True, help_text="MOH reference date")
|
|
chi_reference = models.CharField(
|
|
max_length=100, blank=True, help_text="Council of Health Insurance reference number"
|
|
)
|
|
chi_reference_date = models.DateField(null=True, blank=True, help_text="CHI reference date")
|
|
|
|
# File number (patient MRN)
|
|
file_number = models.CharField(max_length=100, blank=True, db_index=True, help_text="Patient file/MRN number")
|
|
|
|
# Workflow timeline (Step 1 fields)
|
|
form_sent_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When complaint form was sent to the complained department"
|
|
)
|
|
forwarded_to_dept_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When complaint was forwarded to the involved department"
|
|
)
|
|
response_date = models.DateField(null=True, blank=True, help_text="Date when response was received")
|
|
|
|
# Complaint details (Step 1 fields)
|
|
complaint_subject = models.CharField(
|
|
max_length=500, blank=True, help_text="Main complaint subject (from Excel classification)"
|
|
)
|
|
action_taken_by_dept = models.TextField(blank=True, help_text="Action taken by the responsible department")
|
|
action_result = models.TextField(blank=True, help_text="Result of the action/investigation taken")
|
|
recommendation_action_plan = models.TextField(blank=True, help_text="Solutions, suggestions, and action plan")
|
|
delay_reason_closure = models.TextField(
|
|
blank=True, help_text="Reason for not closing the complaint within 72 hours"
|
|
)
|
|
|
|
# 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.breached_at = timezone.now()
|
|
self.save(update_fields=["is_overdue", "breached_at"])
|
|
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 suggested_actions(self):
|
|
"""
|
|
Get AI-generated suggested actions as a list from metadata.
|
|
Returns list of dicts with structure:
|
|
[
|
|
{
|
|
"action": "Action description",
|
|
"priority": "high|medium|low",
|
|
"category": "category_name"
|
|
}
|
|
]
|
|
Falls back to single suggested_action_en if list is empty.
|
|
"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
actions = self.metadata["ai_analysis"].get("suggested_actions", [])
|
|
# If list exists and has items, return it
|
|
if actions and isinstance(actions, list):
|
|
return actions
|
|
# Fallback: convert old single action to list format
|
|
single_action = self.metadata["ai_analysis"].get("suggested_action_en", "")
|
|
if single_action:
|
|
return [{"action": single_action, "priority": "medium", "category": "process_improvement"}]
|
|
return []
|
|
|
|
@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")
|
|
|
|
@property
|
|
def is_activated(self):
|
|
return self.activated_at is not None
|
|
|
|
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=25, blank=True)
|
|
new_status = models.CharField(max_length=25, 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 first reminder X hours before deadline")
|
|
|
|
second_reminder_enabled = models.BooleanField(
|
|
default=True, help_text="Enable sending a second reminder before escalation"
|
|
)
|
|
|
|
second_reminder_hours_before = models.IntegerField(
|
|
default=4, help_text="Send second 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 InquirySLAConfig(UUIDModel, TimeStampedModel):
|
|
"""
|
|
SLA configuration for inquiries per hospital and source.
|
|
|
|
Allows flexible SLA configuration for inquiry response times.
|
|
"""
|
|
|
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="inquiry_sla_configs")
|
|
|
|
source = models.ForeignKey(
|
|
"px_sources.PXSource",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="inquiry_sla_configs",
|
|
help_text="Inquiry source (MOH, CHI, Patient, etc.). Empty = default config",
|
|
)
|
|
|
|
sla_hours = models.IntegerField(help_text="Number of hours until SLA deadline")
|
|
|
|
first_reminder_hours_after = models.IntegerField(
|
|
default=0, help_text="Send 1st reminder X hours after inquiry creation (0 = use reminder_hours_before)"
|
|
)
|
|
|
|
second_reminder_hours_after = models.IntegerField(
|
|
default=0, help_text="Send 2nd reminder X hours after inquiry creation (0 = use second_reminder_hours_before)"
|
|
)
|
|
|
|
escalation_hours_after = models.IntegerField(
|
|
default=0, help_text="Escalate inquiry X hours after creation if unresolved (0 = use overdue logic)"
|
|
)
|
|
|
|
reminder_hours_before = models.IntegerField(default=24, help_text="Send first reminder X hours before deadline")
|
|
|
|
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"
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital", "source"]
|
|
verbose_name = "Inquiry SLA Config"
|
|
verbose_name_plural = "Inquiry SLA Configs"
|
|
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 "Default"
|
|
return f"{self.hospital.name} - {source_display} - {self.sla_hours}h"
|
|
|
|
def get_first_reminder_hours_after(self, inquiry_created_at=None):
|
|
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, inquiry_created_at=None):
|
|
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
|
|
|
|
def get_escalation_hours_after(self, inquiry_created_at=None):
|
|
if self.escalation_hours_after > 0:
|
|
return self.escalation_hours_after
|
|
else:
|
|
return None
|
|
|
|
|
|
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=25,
|
|
choices=[
|
|
("open", "Open"),
|
|
("in_progress", "In Progress"),
|
|
("resolved", "Resolved"),
|
|
("closed", "Closed"),
|
|
("contacted", "Contacted"),
|
|
("contacted_no_response", "Contacted, No Response"),
|
|
],
|
|
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)
|
|
|
|
# Activation tracking
|
|
activated_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Timestamp when inquiry was first activated (moved from OPEN to IN_PROGRESS)",
|
|
)
|
|
|
|
# SLA tracking
|
|
due_at = models.DateTimeField(null=True, blank=True, db_index=True, help_text="SLA deadline")
|
|
is_overdue = models.BooleanField(default=False, db_index=True)
|
|
breached_at = models.DateTimeField(
|
|
null=True, blank=True, db_index=True, help_text="Timestamp when inquiry first breached SLA"
|
|
)
|
|
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)
|
|
|
|
# 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})"
|
|
|
|
def get_sla_config(self):
|
|
"""Get SLA configuration for this inquiry based on hospital and source."""
|
|
try:
|
|
if self.source:
|
|
config = InquirySLAConfig.objects.filter(
|
|
hospital=self.hospital,
|
|
source=self.source,
|
|
is_active=True,
|
|
).first()
|
|
if config:
|
|
return config
|
|
|
|
config = InquirySLAConfig.objects.filter(
|
|
hospital=self.hospital,
|
|
is_active=True,
|
|
).first()
|
|
if config:
|
|
return config
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
return None
|
|
|
|
def check_overdue(self):
|
|
"""Check if inquiry is overdue and update status"""
|
|
if self.status in ["closed", "cancelled"]:
|
|
return False
|
|
|
|
if self.due_at and timezone.now() > self.due_at:
|
|
if not self.is_overdue:
|
|
self.is_overdue = True
|
|
self.breached_at = timezone.now()
|
|
self.save(update_fields=["is_overdue", "breached_at"])
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def is_active_status(self):
|
|
"""
|
|
Check if inquiry is in an active status (can be worked on).
|
|
Active statuses: open, in_progress
|
|
Inactive statuses: resolved, closed, contacted, contacted_no_response
|
|
"""
|
|
return self.status in ["open", "in_progress"]
|
|
|
|
|
|
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=25, blank=True)
|
|
new_status = models.CharField(max_length=25, 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="First reminder sent to staff about overdue explanation"
|
|
)
|
|
|
|
second_reminder_sent_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="Second 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")
|
|
|
|
# Reminder and delay tracking (Step 1 fields)
|
|
forwarded_at = models.DateTimeField(null=True, blank=True, help_text="When complaint was sent to this department")
|
|
first_reminder_sent_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When first reminder was sent to this department"
|
|
)
|
|
second_reminder_sent_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When second reminder was sent to this department"
|
|
)
|
|
delay_reason = models.TextField(blank=True, help_text="Reason for department delay in response")
|
|
delayed_person = models.CharField(max_length=200, blank=True, help_text="Name of person responsible for delay")
|
|
|
|
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"]),
|
|
models.Index(fields=["department", "forwarded_at"]),
|
|
]
|
|
|
|
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
|
|
from datetime import time as datetime_time
|
|
|
|
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()
|
|
|
|
# Ensure work_start_time and work_end_time are time objects
|
|
start_time = self.work_start_time
|
|
end_time = self.work_end_time
|
|
|
|
# Convert string to time if needed (handles string defaults from DB)
|
|
if isinstance(start_time, str):
|
|
parts = start_time.split(":")
|
|
hours, minutes = int(parts[0]), int(parts[1])
|
|
start_time = datetime_time(hours, minutes)
|
|
|
|
if isinstance(end_time, str):
|
|
parts = end_time.split(":")
|
|
hours, minutes = int(parts[0]), int(parts[1])
|
|
end_time = datetime_time(hours, minutes)
|
|
|
|
return start_time <= current_time < 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.reference_number} - {self.filename}"
|
|
|
|
|
|
# ============================================================================
|
|
# COMPLAINT TEMPLATES
|
|
# ============================================================================
|
|
|
|
|
|
class ComplaintTemplate(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Pre-defined templates for common complaints.
|
|
|
|
Allows quick selection of common complaint types with pre-filled
|
|
description, category, severity, and auto-assignment.
|
|
"""
|
|
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.CASCADE,
|
|
related_name="complaint_templates",
|
|
help_text=_("Hospital this template belongs to"),
|
|
)
|
|
|
|
name = models.CharField(max_length=200, help_text=_("Template name (e.g., 'Long Wait Time', 'Rude Staff')"))
|
|
description = models.TextField(help_text=_("Default description template with placeholders"))
|
|
|
|
# Pre-set classification
|
|
category = models.ForeignKey(
|
|
ComplaintCategory,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="templates",
|
|
help_text=_("Default category for this template"),
|
|
)
|
|
|
|
# Default severity/priority
|
|
default_severity = models.CharField(
|
|
max_length=20,
|
|
choices=SeverityChoices.choices,
|
|
default=SeverityChoices.MEDIUM,
|
|
help_text=_("Default severity level"),
|
|
)
|
|
default_priority = models.CharField(
|
|
max_length=20,
|
|
choices=PriorityChoices.choices,
|
|
default=PriorityChoices.MEDIUM,
|
|
help_text=_("Default priority level"),
|
|
)
|
|
|
|
# Auto-assignment
|
|
auto_assign_department = models.ForeignKey(
|
|
"organizations.Department",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="template_assignments",
|
|
help_text=_("Auto-assign to this department when template is used"),
|
|
)
|
|
|
|
# Usage tracking
|
|
usage_count = models.IntegerField(
|
|
default=0, editable=False, help_text=_("Number of times this template has been used")
|
|
)
|
|
|
|
# Placeholders that can be used in description
|
|
# e.g., "Patient waited for {{wait_time}} minutes"
|
|
placeholders = models.JSONField(
|
|
default=list, blank=True, help_text=_("List of placeholder names used in description")
|
|
)
|
|
|
|
is_active = models.BooleanField(
|
|
default=True, db_index=True, help_text=_("Whether this template is available for selection")
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-usage_count", "name"]
|
|
verbose_name = _("Complaint Template")
|
|
verbose_name_plural = _("Complaint Templates")
|
|
unique_together = [["hospital", "name"]]
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
models.Index(fields=["-usage_count"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.name} ({self.usage_count} uses)"
|
|
|
|
def use_template(self):
|
|
"""Increment usage count"""
|
|
self.usage_count += 1
|
|
self.save(update_fields=["usage_count"])
|
|
|
|
def render_description(self, placeholder_values):
|
|
"""
|
|
Render description with placeholder values.
|
|
|
|
Args:
|
|
placeholder_values: Dict of placeholder name -> value
|
|
|
|
Returns:
|
|
Rendered description string
|
|
"""
|
|
description = self.description
|
|
for key, value in placeholder_values.items():
|
|
description = description.replace(f"{{{{{key}}}}}", str(value))
|
|
return description
|
|
|
|
|
|
# ============================================================================
|
|
# COMMUNICATION LOG
|
|
# ============================================================================
|
|
|
|
|
|
class ComplaintCommunicationType(models.TextChoices):
|
|
"""Types of communication"""
|
|
|
|
PHONE_CALL = "phone_call", "Phone Call"
|
|
EMAIL = "email", "Email"
|
|
SMS = "sms", "SMS"
|
|
MEETING = "meeting", "Meeting"
|
|
LETTER = "letter", "Letter"
|
|
OTHER = "other", "Other"
|
|
|
|
|
|
class ComplaintCommunication(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Tracks all communications related to a complaint.
|
|
|
|
Records phone calls, emails, meetings, and other communications
|
|
with complainants, involved staff, or other stakeholders.
|
|
"""
|
|
|
|
complaint = models.ForeignKey(
|
|
Complaint, on_delete=models.CASCADE, related_name="communications", help_text=_("Related complaint")
|
|
)
|
|
|
|
# Communication details
|
|
communication_type = models.CharField(
|
|
max_length=20, choices=ComplaintCommunicationType.choices, help_text=_("Type of communication")
|
|
)
|
|
|
|
direction = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("inbound", "Inbound"),
|
|
("outbound", "Outbound"),
|
|
],
|
|
help_text=_("Direction of communication"),
|
|
)
|
|
|
|
# Participants
|
|
contacted_person = models.CharField(max_length=200, help_text=_("Name of person contacted"))
|
|
contacted_role = models.CharField(
|
|
max_length=100, blank=True, help_text=_("Role/relation (e.g., Complainant, Patient, Staff)")
|
|
)
|
|
contacted_phone = models.CharField(max_length=20, blank=True, help_text=_("Phone number"))
|
|
contacted_email = models.EmailField(blank=True, help_text=_("Email address"))
|
|
|
|
# Communication content
|
|
subject = models.CharField(max_length=500, blank=True, help_text=_("Subject/summary of communication"))
|
|
notes = models.TextField(help_text=_("Details of what was discussed"))
|
|
|
|
# Follow-up
|
|
requires_followup = models.BooleanField(default=False, help_text=_("Whether this communication requires follow-up"))
|
|
followup_date = models.DateField(null=True, blank=True, help_text=_("Date when follow-up is needed"))
|
|
followup_notes = models.TextField(blank=True, help_text=_("Notes from follow-up"))
|
|
|
|
# Attachments (emails, letters, etc.)
|
|
attachment = models.FileField(
|
|
upload_to="complaints/communications/%Y/%m/%d/",
|
|
null=True,
|
|
blank=True,
|
|
help_text=_("Attached document (email export, letter, etc.)"),
|
|
)
|
|
|
|
# Created by
|
|
created_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name="complaint_communications",
|
|
help_text=_("User who logged this communication"),
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
verbose_name = _("Complaint Communication")
|
|
verbose_name_plural = _("Complaint Communications")
|
|
indexes = [
|
|
models.Index(fields=["complaint", "-created_at"]),
|
|
models.Index(fields=["communication_type"]),
|
|
models.Index(fields=["requires_followup", "followup_date"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.complaint.reference_number} - {self.get_communication_type_display()} - {self.contacted_person}"
|
|
|
|
|
|
# ============================================================================
|
|
# ROOT CAUSE ANALYSIS (RCA)
|
|
# ============================================================================
|
|
|
|
|
|
class RootCauseCategory(models.TextChoices):
|
|
"""Root cause categories for RCA"""
|
|
|
|
PEOPLE = "people", "People (Training, Staffing)"
|
|
PROCESS = "process", "Process/Procedure"
|
|
EQUIPMENT = "equipment", "Equipment/Technology"
|
|
ENVIRONMENT = "environment", "Environment/Facility"
|
|
COMMUNICATION = "communication", "Communication"
|
|
POLICY = "policy", "Policy/Protocol"
|
|
PATIENT_FACTOR = "patient_factor", "Patient-Related Factor"
|
|
OTHER = "other", "Other"
|
|
|
|
|
|
class ComplaintRootCauseAnalysis(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Root Cause Analysis (RCA) for complaints.
|
|
|
|
Structured analysis to identify underlying causes and prevent recurrence.
|
|
Linked to complaints that require formal investigation.
|
|
"""
|
|
|
|
complaint = models.OneToOneField(
|
|
Complaint, on_delete=models.CASCADE, related_name="root_cause_analysis", help_text=_("Related complaint")
|
|
)
|
|
|
|
# RCA Team
|
|
team_leader = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="led_rcas",
|
|
help_text=_("RCA team leader"),
|
|
)
|
|
team_members = models.TextField(blank=True, help_text=_("List of RCA team members (one per line)"))
|
|
|
|
# Problem statement
|
|
problem_statement = models.TextField(help_text=_("Clear description of what happened"))
|
|
impact_description = models.TextField(help_text=_("Impact on patient, organization, etc."))
|
|
|
|
# Root cause categories (can select multiple)
|
|
root_cause_categories = models.JSONField(default=list, help_text=_("Selected root cause categories"))
|
|
|
|
# 5 Whys analysis
|
|
why_1 = models.TextField(blank=True, help_text=_("Why did this happen? (Level 1)"))
|
|
why_2 = models.TextField(blank=True, help_text=_("Why? (Level 2)"))
|
|
why_3 = models.TextField(blank=True, help_text=_("Why? (Level 3)"))
|
|
why_4 = models.TextField(blank=True, help_text=_("Why? (Level 4)"))
|
|
why_5 = models.TextField(blank=True, help_text=_("Why? (Level 5)"))
|
|
|
|
# Root cause summary
|
|
root_cause_summary = models.TextField(help_text=_("Summary of identified root causes"))
|
|
|
|
# Contributing factors
|
|
contributing_factors = models.TextField(blank=True, help_text=_("Factors that contributed to the incident"))
|
|
|
|
# Corrective and Preventive Actions (CAPA)
|
|
corrective_actions = models.TextField(help_text=_("Actions to correct the immediate issue"))
|
|
preventive_actions = models.TextField(help_text=_("Actions to prevent recurrence"))
|
|
|
|
# Action tracking
|
|
action_owner = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="owned_rca_actions",
|
|
help_text=_("Person responsible for implementing actions"),
|
|
)
|
|
action_due_date = models.DateField(null=True, blank=True, help_text=_("Due date for implementing actions"))
|
|
action_status = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("not_started", "Not Started"),
|
|
("in_progress", "In Progress"),
|
|
("completed", "Completed"),
|
|
("verified", "Verified Effective"),
|
|
],
|
|
default="not_started",
|
|
help_text=_("Status of corrective actions"),
|
|
)
|
|
|
|
# Effectiveness verification
|
|
effectiveness_verified = models.BooleanField(
|
|
default=False, help_text=_("Whether the effectiveness of actions has been verified")
|
|
)
|
|
effectiveness_date = models.DateField(null=True, blank=True, help_text=_("Date when effectiveness was verified"))
|
|
effectiveness_notes = models.TextField(blank=True, help_text=_("Notes on effectiveness verification"))
|
|
|
|
# RCA Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("draft", "Draft"),
|
|
("in_review", "In Review"),
|
|
("approved", "Approved"),
|
|
("closed", "Closed"),
|
|
],
|
|
default="draft",
|
|
help_text=_("RCA status"),
|
|
)
|
|
|
|
# Approval
|
|
approved_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="approved_rcas",
|
|
help_text=_("User who approved the RCA"),
|
|
)
|
|
approved_at = models.DateTimeField(null=True, blank=True, help_text=_("Date when RCA was approved"))
|
|
|
|
class Meta:
|
|
verbose_name = _("Root Cause Analysis")
|
|
verbose_name_plural = _("Root Cause Analyses")
|
|
indexes = [
|
|
models.Index(fields=["action_status", "action_due_date"]),
|
|
models.Index(fields=["status"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"RCA for {self.complaint.reference_number}"
|
|
|
|
@property
|
|
def is_overdue(self):
|
|
"""Check if action is overdue"""
|
|
from django.utils import timezone
|
|
|
|
if self.action_due_date and self.action_status not in ["completed", "verified"]:
|
|
return timezone.now().date() > self.action_due_date
|
|
return False
|
|
|
|
|
|
class PatientComplaintSession(TimeStampedModel):
|
|
"""
|
|
Token-based session for patients to submit complaints via SMS link.
|
|
|
|
Flow:
|
|
1. Staff creates session -> SMS sent to patient with link
|
|
2. Patient opens link -> sees hospital cards grouped by visits
|
|
3. Patient selects hospital -> sees visit list
|
|
4. Patient selects visit -> fills complaint form
|
|
"""
|
|
|
|
patient = models.ForeignKey("organizations.Patient", on_delete=models.CASCADE, related_name="complaint_sessions")
|
|
token = models.CharField(max_length=64, unique=True, db_index=True)
|
|
expires_at = models.DateTimeField(db_index=True)
|
|
created_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaint_sessions"
|
|
)
|
|
is_active = models.BooleanField(default=True, db_index=True)
|
|
|
|
def is_expired(self):
|
|
from django.utils import timezone
|
|
|
|
return timezone.now() > self.expires_at
|
|
|
|
def generate_link(self, request=None):
|
|
if request:
|
|
return request.build_absolute_uri(f"/complaints/patient/{self.token}/")
|
|
return f"/complaints/patient/{self.token}/"
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.token:
|
|
import secrets
|
|
|
|
self.token = secrets.token_urlsafe(32)
|
|
if not self.expires_at:
|
|
from django.utils import timezone
|
|
from django.conf import settings
|
|
|
|
days = getattr(settings, "COMPLAINT_LINK_EXPIRY_DAYS", 7)
|
|
self.expires_at = timezone.now() + timedelta(days=days)
|
|
super().save(*args, **kwargs)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["patient", "is_active"]),
|
|
]
|