1588 lines
52 KiB
Python
1588 lines
52 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 apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel
|
|
|
|
|
|
class ComplaintStatus(models.TextChoices):
|
|
"""Complaint status choices"""
|
|
|
|
OPEN = "open", "Open"
|
|
IN_PROGRESS = "in_progress", "In Progress"
|
|
PARTIALLY_RESOLVED = "partially_resolved", "Partially Resolved"
|
|
RESOLVED = "resolved", "Resolved"
|
|
CLOSED = "closed", "Closed"
|
|
CANCELLED = "cancelled", "Cancelled"
|
|
|
|
|
|
class ResolutionCategory(models.TextChoices):
|
|
"""Resolution category choices"""
|
|
|
|
FULL_ACTION_TAKEN = "full_action_taken", "Full Action Taken"
|
|
PARTIAL_ACTION_TAKEN = "partial_action_taken", "Partial Action Taken"
|
|
NO_ACTION_NEEDED = "no_action_needed", "No Action Needed"
|
|
CANNOT_RESOLVE = "cannot_resolve", "Cannot Resolve"
|
|
PATIENT_WITHDRAWN = "patient_withdrawn", "Patient Withdrawn"
|
|
|
|
|
|
class ComplaintType(models.TextChoices):
|
|
"""Complaint type choices - distinguish between complaints and appreciations"""
|
|
|
|
COMPLAINT = "complaint", "Complaint"
|
|
APPRECIATION = "appreciation", "Appreciation"
|
|
|
|
|
|
class ComplaintSourceType(models.TextChoices):
|
|
"""Complaint source type choices - Internal vs External"""
|
|
|
|
INTERNAL = "internal", "Internal"
|
|
EXTERNAL = "external", "External"
|
|
|
|
|
|
class ComplaintSource(models.TextChoices):
|
|
"""Complaint source choices"""
|
|
|
|
PATIENT = "patient", "Patient"
|
|
FAMILY = "family", "Family Member"
|
|
STAFF = "staff", "Staff"
|
|
SURVEY = "survey", "Survey"
|
|
SOCIAL_MEDIA = "social_media", "Social Media"
|
|
CALL_CENTER = "call_center", "Call Center"
|
|
MOH = "moh", "Ministry of Health"
|
|
CHI = "chi", "Council of Health Insurance"
|
|
OTHER = "other", "Other"
|
|
|
|
|
|
class ComplaintCategory(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Custom complaint categories per hospital with 4-level SHCT taxonomy.
|
|
|
|
Supports hierarchical structure:
|
|
- Level 1: Domain (CLINICAL, MANAGEMENT, RELATIONSHIPS)
|
|
- Level 2: Category (Quality, Safety, Communication, etc.)
|
|
- Level 3: Subcategory (Examination, Patient Journey, etc.)
|
|
- Level 4: Classification (Examination not performed, etc.)
|
|
|
|
Uses ManyToMany to allow categories to be shared across multiple hospitals.
|
|
"""
|
|
|
|
class LevelChoices(models.IntegerChoices):
|
|
DOMAIN = 1, "Domain"
|
|
CATEGORY = 2, "Category"
|
|
SUBCATEGORY = 3, "Subcategory"
|
|
CLASSIFICATION = 4, "Classification"
|
|
|
|
class DomainTypeChoices(models.TextChoices):
|
|
CLINICAL = "CLINICAL", "Clinical"
|
|
MANAGEMENT = "MANAGEMENT", "Management"
|
|
RELATIONSHIPS = "RELATIONSHIPS", "Relationships"
|
|
|
|
hospitals = models.ManyToManyField(
|
|
"organizations.Hospital",
|
|
blank=True,
|
|
related_name="complaint_categories",
|
|
help_text="Empty list = system-wide category. Add hospitals to share category.",
|
|
)
|
|
|
|
code = models.CharField(max_length=50, help_text="Unique code for this category")
|
|
|
|
name_en = models.CharField(max_length=200)
|
|
name_ar = models.CharField(max_length=200, blank=True)
|
|
|
|
description_en = models.TextField(blank=True)
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
parent = models.ForeignKey(
|
|
"self",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="subcategories",
|
|
help_text="Parent category for hierarchical structure",
|
|
)
|
|
|
|
level = models.IntegerField(
|
|
choices=LevelChoices.choices,
|
|
help_text="Hierarchy level (1=Domain, 2=Category, 3=Subcategory, 4=Classification)"
|
|
)
|
|
|
|
domain_type = models.CharField(
|
|
max_length=20,
|
|
choices=DomainTypeChoices.choices,
|
|
blank=True,
|
|
help_text="Domain type for top-level categories"
|
|
)
|
|
|
|
order = models.IntegerField(default=0, help_text="Display order")
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["order", "name_en"]
|
|
verbose_name_plural = "Complaint Categories"
|
|
indexes = [
|
|
models.Index(fields=["code"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
level_display = self.get_level_display()
|
|
hospital_count = self.hospitals.count()
|
|
|
|
if hospital_count == 0:
|
|
hospital_info = "System-wide"
|
|
elif hospital_count == 1:
|
|
hospital_info = self.hospitals.first().name
|
|
else:
|
|
hospital_info = f"{hospital_count} hospitals"
|
|
|
|
if self.level == self.LevelChoices.CLASSIFICATION and self.parent:
|
|
parent_path = " > ".join([self.parent.name_en])
|
|
return f"{level_display}: {parent_path} > {self.name_en}"
|
|
else:
|
|
return f"{level_display}: {self.name_en} ({hospital_info})"
|
|
|
|
|
|
class Complaint(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Complaint model with SLA tracking.
|
|
|
|
Workflow:
|
|
1. OPEN - Complaint received
|
|
2. IN_PROGRESS - Being investigated
|
|
3. RESOLVED - Solution provided
|
|
4. CLOSED - Confirmed closed (triggers resolution satisfaction survey)
|
|
|
|
SLA:
|
|
- Calculated based on severity and hospital configuration
|
|
- Reminders sent before due date
|
|
- Escalation triggered when overdue
|
|
"""
|
|
|
|
# Patient and encounter information
|
|
patient = models.ForeignKey(
|
|
"organizations.Patient", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
|
)
|
|
|
|
# Contact information for anonymous/unregistered submissions
|
|
contact_name = models.CharField(max_length=200, blank=True)
|
|
contact_phone = models.CharField(max_length=20, blank=True)
|
|
contact_email = models.EmailField(blank=True)
|
|
|
|
# Public complaint form fields
|
|
relation_to_patient = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('patient', 'Patient'),
|
|
('relative', 'Relative'),
|
|
],
|
|
blank=True,
|
|
verbose_name="Relation to Patient",
|
|
help_text="Complainant's relationship to the patient"
|
|
)
|
|
|
|
patient_name = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name="Patient Name",
|
|
help_text="Name of the patient involved"
|
|
)
|
|
|
|
national_id = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
verbose_name="National ID/Iqama No.",
|
|
help_text="Saudi National ID or Iqama number"
|
|
)
|
|
|
|
incident_date = models.DateField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name="Incident Date",
|
|
help_text="Date when the incident occurred"
|
|
)
|
|
|
|
staff_name = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name="Staff Involved",
|
|
help_text="Name of staff member involved (if known)"
|
|
)
|
|
|
|
expected_result = models.TextField(
|
|
blank=True,
|
|
verbose_name="Expected Complaint Result",
|
|
help_text="What the complainant expects as a resolution"
|
|
)
|
|
|
|
# Reference number for tracking
|
|
reference_number = models.CharField(
|
|
max_length=50,
|
|
unique=True,
|
|
db_index=True,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Unique reference number for patient tracking",
|
|
)
|
|
|
|
encounter_id = models.CharField(
|
|
max_length=100, blank=True, db_index=True, help_text="Related encounter ID if applicable"
|
|
)
|
|
|
|
# Organization
|
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="complaints")
|
|
department = models.ForeignKey(
|
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
|
)
|
|
staff = models.ForeignKey(
|
|
"organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
|
)
|
|
|
|
# Complaint details
|
|
title = models.CharField(max_length=500)
|
|
description = models.TextField()
|
|
|
|
# Classification - 4-level SHCT taxonomy
|
|
domain = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_domain",
|
|
null=True, blank=True, help_text="Level 1: Domain"
|
|
)
|
|
category = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints",
|
|
null=True, blank=True, help_text="Level 2: Category"
|
|
)
|
|
# Keep CharField for backward compatibility (stores the code)
|
|
subcategory = models.CharField(max_length=100, blank=True, help_text="Level 3: Subcategory code (legacy)")
|
|
classification = models.CharField(max_length=100, blank=True, help_text="Level 4: Classification code (legacy)")
|
|
# New FK fields for proper relationships
|
|
subcategory_obj = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_subcategory",
|
|
null=True, blank=True, help_text="Level 3: Subcategory"
|
|
)
|
|
classification_obj = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_classification",
|
|
null=True, blank=True, help_text="Level 4: Classification"
|
|
)
|
|
|
|
# Location hierarchy - required fields
|
|
location = models.ForeignKey(
|
|
'organizations.Location',
|
|
on_delete=models.PROTECT,
|
|
related_name='complaints',
|
|
null=True,
|
|
blank=True,
|
|
help_text="Location (e.g., Riyadh, Jeddah)"
|
|
)
|
|
main_section = models.ForeignKey(
|
|
'organizations.MainSection',
|
|
on_delete=models.PROTECT,
|
|
related_name='complaints',
|
|
null=True,
|
|
blank=True,
|
|
help_text="Section/Department"
|
|
)
|
|
subsection = models.ForeignKey(
|
|
'organizations.SubSection',
|
|
on_delete=models.PROTECT,
|
|
related_name='complaints',
|
|
null=True,
|
|
blank=True,
|
|
help_text="Subsection within the section"
|
|
)
|
|
|
|
# Type (complaint vs appreciation)
|
|
complaint_type = models.CharField(
|
|
max_length=20,
|
|
choices=ComplaintType.choices,
|
|
default=ComplaintType.COMPLAINT,
|
|
db_index=True,
|
|
help_text="Type of feedback (complaint vs appreciation)"
|
|
)
|
|
|
|
# Source type (Internal vs External)
|
|
complaint_source_type = models.CharField(
|
|
max_length=20,
|
|
choices=ComplaintSourceType.choices,
|
|
default=ComplaintSourceType.EXTERNAL,
|
|
db_index=True,
|
|
help_text="Source type (Internal = staff-generated, External = patient/public-generated)"
|
|
)
|
|
|
|
# Priority and severity
|
|
priority = models.CharField(
|
|
max_length=20, choices=PriorityChoices.choices, default=PriorityChoices.MEDIUM, db_index=True
|
|
)
|
|
severity = models.CharField(
|
|
max_length=20, choices=SeverityChoices.choices, default=SeverityChoices.MEDIUM, db_index=True
|
|
)
|
|
|
|
# Source
|
|
source = models.ForeignKey(
|
|
"px_sources.PXSource",
|
|
on_delete=models.PROTECT,
|
|
related_name="complaints",
|
|
null=True,
|
|
blank=True,
|
|
help_text="Source of complaint",
|
|
)
|
|
|
|
# Creator tracking
|
|
created_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_complaints',
|
|
help_text="User who created this complaint (SourceUser or Patient)"
|
|
)
|
|
|
|
# Status and workflow
|
|
status = models.CharField(
|
|
max_length=20, choices=ComplaintStatus.choices, default=ComplaintStatus.OPEN, db_index=True
|
|
)
|
|
|
|
# Assignment
|
|
assigned_to = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_complaints"
|
|
)
|
|
assigned_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# SLA tracking
|
|
due_at = models.DateTimeField(db_index=True, help_text="SLA deadline")
|
|
is_overdue = models.BooleanField(default=False, db_index=True)
|
|
reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="First SLA reminder timestamp")
|
|
second_reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="Second SLA reminder timestamp")
|
|
escalated_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Resolution
|
|
resolution = models.TextField(blank=True)
|
|
resolution_category = models.CharField(
|
|
max_length=50,
|
|
choices=ResolutionCategory.choices,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Category of resolution"
|
|
)
|
|
resolved_at = models.DateTimeField(null=True, blank=True)
|
|
resolved_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_complaints"
|
|
)
|
|
|
|
# Closure
|
|
closed_at = models.DateTimeField(null=True, blank=True)
|
|
closed_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_complaints"
|
|
)
|
|
|
|
# Resolution satisfaction survey
|
|
resolution_survey = models.ForeignKey(
|
|
"surveys.SurveyInstance", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaint_resolution"
|
|
)
|
|
resolution_survey_sent_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["status", "-created_at"]),
|
|
models.Index(fields=["hospital", "status", "-created_at"]),
|
|
models.Index(fields=["is_overdue", "status"]),
|
|
models.Index(fields=["due_at", "status"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.title} - ({self.status})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Calculate SLA due date on creation, generate reference number, and sync complaint_type from metadata"""
|
|
# Track status change for signals
|
|
if self.pk:
|
|
try:
|
|
old_instance = Complaint.objects.get(pk=self.pk)
|
|
self._status_was = old_instance.status
|
|
except Complaint.DoesNotExist:
|
|
self._status_was = None
|
|
|
|
# Generate reference number if not set (for all creation methods: form, API, admin)
|
|
if not self.reference_number:
|
|
from datetime import datetime
|
|
import uuid
|
|
today = datetime.now().strftime("%Y%m%d")
|
|
random_suffix = str(uuid.uuid4().int)[:6]
|
|
self.reference_number = f"CMP-{today}-{random_suffix}"
|
|
|
|
if not self.due_at:
|
|
self.due_at = self.calculate_sla_due_date()
|
|
|
|
# Sync complaint_type from AI metadata if not already set
|
|
# This ensures that model field stays in sync with AI classification
|
|
if self.metadata and 'ai_analysis' in self.metadata:
|
|
ai_complaint_type = self.metadata['ai_analysis'].get('complaint_type', 'complaint')
|
|
# Only sync if model field is still default 'complaint'
|
|
# This preserves any manual changes while fixing AI-synced complaints
|
|
if self.complaint_type == 'complaint' and ai_complaint_type != 'complaint':
|
|
self.complaint_type = ai_complaint_type
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
def calculate_sla_due_date(self):
|
|
"""
|
|
Calculate SLA due date based on source, severity, and hospital configuration.
|
|
|
|
Priority order:
|
|
1. Source-based config (MOH, CHI, Internal)
|
|
2. Severity/priority-based config
|
|
3. Settings defaults
|
|
|
|
Source-based configs take precedence over severity/priority-based configs.
|
|
"""
|
|
# Try source-based SLA config first
|
|
if self.source:
|
|
try:
|
|
sla_config = ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
source=self.source,
|
|
is_active=True
|
|
)
|
|
sla_hours = sla_config.sla_hours
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall through to next option
|
|
|
|
# Try severity/priority-based config
|
|
try:
|
|
sla_config = ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
source__isnull=True, # Explicitly check for null source
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
sla_hours = sla_config.sla_hours
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall through to next option
|
|
|
|
# Try severity/priority-based config without source filter (backward compatibility)
|
|
try:
|
|
sla_config = ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
sla_hours = sla_config.sla_hours
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall back to settings
|
|
|
|
# Fall back to settings defaults
|
|
sla_hours = settings.SLA_DEFAULTS["complaint"].get(
|
|
self.severity, settings.SLA_DEFAULTS["complaint"]["medium"]
|
|
)
|
|
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
|
|
def get_sla_config(self):
|
|
"""
|
|
Get the SLA config for this complaint.
|
|
|
|
Returns the source-based or severity/priority-based config that applies to this complaint.
|
|
Returns None if no config is found (will use defaults).
|
|
"""
|
|
# Try source-based SLA config first
|
|
if self.source:
|
|
try:
|
|
return ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
source=self.source,
|
|
is_active=True
|
|
)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall through to next option
|
|
|
|
# Try severity/priority-based config
|
|
try:
|
|
return ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
source__isnull=True,
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall through to next option
|
|
|
|
# Try severity/priority-based config without source filter (backward compatibility)
|
|
try:
|
|
return ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # No config found
|
|
|
|
return None
|
|
|
|
def check_overdue(self):
|
|
"""Check if complaint is overdue and update status"""
|
|
if self.status in [ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED]:
|
|
return False
|
|
|
|
if timezone.now() > self.due_at:
|
|
if not self.is_overdue:
|
|
self.is_overdue = True
|
|
self.save(update_fields=["is_overdue"])
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def short_description_en(self):
|
|
"""Get AI-generated short description (English) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("short_description_en", "")
|
|
return ""
|
|
|
|
@property
|
|
def short_description_ar(self):
|
|
"""Get AI-generated short description (Arabic) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("short_description_ar", "")
|
|
return ""
|
|
|
|
@property
|
|
def short_description(self):
|
|
"""Get AI-generated short description from metadata (deprecated, use short_description_en)"""
|
|
return self.short_description_en
|
|
|
|
@property
|
|
def suggested_action_en(self):
|
|
"""Get AI-generated suggested action (English) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("suggested_action_en", "")
|
|
return ""
|
|
|
|
@property
|
|
def suggested_action_ar(self):
|
|
"""Get AI-generated suggested action (Arabic) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("suggested_action_ar", "")
|
|
return ""
|
|
|
|
@property
|
|
def suggested_action(self):
|
|
"""Get AI-generated suggested action from metadata (deprecated, use suggested_action_en)"""
|
|
return self.suggested_action_en
|
|
|
|
@property
|
|
def title_en(self):
|
|
"""Get AI-generated title (English) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("title_en", "")
|
|
return ""
|
|
|
|
@property
|
|
def title_ar(self):
|
|
"""Get AI-generated title (Arabic) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("title_ar", "")
|
|
return ""
|
|
|
|
@property
|
|
def reasoning_en(self):
|
|
"""Get AI-generated reasoning (English) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("reasoning_en", "")
|
|
return ""
|
|
|
|
@property
|
|
def reasoning_ar(self):
|
|
"""Get AI-generated reasoning (Arabic) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("reasoning_ar", "")
|
|
return ""
|
|
|
|
@property
|
|
def emotion(self):
|
|
"""Get AI-detected primary emotion from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("emotion", "neutral")
|
|
return "neutral"
|
|
|
|
@property
|
|
def emotion_intensity(self):
|
|
"""Get AI-detected emotion intensity (0.0 to 1.0) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("emotion_intensity", 0.0)
|
|
return 0.0
|
|
|
|
@property
|
|
def emotion_confidence(self):
|
|
"""Get AI confidence in emotion detection (0.0 to 1.0) from metadata"""
|
|
if self.metadata and "ai_analysis" in self.metadata:
|
|
return self.metadata["ai_analysis"].get("emotion_confidence", 0.0)
|
|
return 0.0
|
|
|
|
@property
|
|
def get_emotion_display(self):
|
|
"""Get human-readable emotion display"""
|
|
emotion_map = {
|
|
"anger": "Anger",
|
|
"sadness": "Sadness",
|
|
"confusion": "Confusion",
|
|
"fear": "Fear",
|
|
"neutral": "Neutral",
|
|
}
|
|
return emotion_map.get(self.emotion, "Neutral")
|
|
|
|
@property
|
|
def get_emotion_badge_class(self):
|
|
"""Get Bootstrap badge class for emotion"""
|
|
badge_map = {
|
|
"anger": "danger",
|
|
"sadness": "primary",
|
|
"confusion": "warning",
|
|
"fear": "info",
|
|
"neutral": "secondary",
|
|
}
|
|
return badge_map.get(self.emotion, "secondary")
|
|
|
|
def get_tracking_url(self):
|
|
"""
|
|
Get the public tracking URL for this complaint.
|
|
|
|
Returns the full URL that complainants can use to track their complaint status.
|
|
"""
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
from django.urls import reverse
|
|
|
|
# Build absolute URL
|
|
try:
|
|
site = get_current_site(None)
|
|
domain = site.domain
|
|
except:
|
|
domain = 'localhost:8000'
|
|
|
|
return f"https://{domain}{reverse('complaints:public_complaint_track')}?reference={self.reference_number}"
|
|
|
|
|
|
class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
|
"""Complaint attachment (images, documents, etc.)"""
|
|
|
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="attachments")
|
|
|
|
file = models.FileField(upload_to="complaints/%Y/%m/%d/")
|
|
filename = models.CharField(max_length=500)
|
|
file_type = models.CharField(max_length=100, blank=True)
|
|
file_size = models.IntegerField(help_text="File size in bytes")
|
|
|
|
uploaded_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="complaint_attachments"
|
|
)
|
|
|
|
description = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.complaint} - {self.filename}"
|
|
|
|
|
|
class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Complaint update/timeline entry.
|
|
|
|
Tracks all updates, status changes, and communications.
|
|
"""
|
|
|
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="updates")
|
|
|
|
# Update details
|
|
update_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("status_change", "Status Change"),
|
|
("assignment", "Assignment"),
|
|
("note", "Note"),
|
|
("resolution", "Resolution"),
|
|
("escalation", "Escalation"),
|
|
("communication", "Communication"),
|
|
],
|
|
db_index=True,
|
|
)
|
|
|
|
message = models.TextField()
|
|
|
|
# User who made the update
|
|
created_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="complaint_updates"
|
|
)
|
|
|
|
# Status change tracking
|
|
old_status = models.CharField(max_length=20, blank=True)
|
|
new_status = models.CharField(max_length=20, blank=True)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["complaint", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.complaint} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
|
|
class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
|
|
"""
|
|
SLA configuration for complaints per hospital, source, severity, and priority.
|
|
|
|
Allows flexible SLA configuration instead of hardcoded values.
|
|
Supports both source-based (MOH, CHI, Internal) and severity/priority-based configurations.
|
|
"""
|
|
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital", on_delete=models.CASCADE, related_name="complaint_sla_configs"
|
|
)
|
|
|
|
source = models.ForeignKey(
|
|
"px_sources.PXSource",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="complaint_sla_configs",
|
|
help_text="Complaint source (MOH, CHI, Patient, etc.). Empty = severity/priority-based config"
|
|
)
|
|
|
|
severity = models.CharField(
|
|
max_length=20,
|
|
choices=SeverityChoices.choices,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Severity level for this SLA (optional if source is specified)"
|
|
)
|
|
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=PriorityChoices.choices,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Priority level for this SLA (optional if source is specified)"
|
|
)
|
|
|
|
sla_hours = models.IntegerField(help_text="Number of hours until SLA deadline")
|
|
|
|
# Source-based reminder timing (from complaint creation)
|
|
first_reminder_hours_after = models.IntegerField(
|
|
default=0,
|
|
help_text="Send 1st reminder X hours after complaint creation (0 = use reminder_hours_before)"
|
|
)
|
|
|
|
second_reminder_hours_after = models.IntegerField(
|
|
default=0,
|
|
help_text="Send 2nd reminder X hours after complaint creation (0 = use second_reminder_hours_before)"
|
|
)
|
|
|
|
escalation_hours_after = models.IntegerField(
|
|
default=0,
|
|
help_text="Escalate complaint X hours after creation if unresolved (0 = use overdue logic)"
|
|
)
|
|
|
|
# Legacy reminder timing (before deadline - kept for backward compatibility)
|
|
reminder_hours_before = models.IntegerField(default=24, help_text="Send first reminder X hours before deadline")
|
|
|
|
# Second reminder configuration
|
|
second_reminder_enabled = models.BooleanField(default=False, help_text="Enable sending a second reminder")
|
|
|
|
second_reminder_hours_before = models.IntegerField(default=6, help_text="Send second reminder X hours before deadline")
|
|
|
|
# Thank you email configuration
|
|
thank_you_email_enabled = models.BooleanField(default=False, help_text="Send thank you email when complaint is closed")
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital", "source", "severity", "priority"]
|
|
unique_together = [["hospital", "source", "severity", "priority"]]
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
models.Index(fields=["hospital", "source", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
source_display = self.source.name if self.source else "Any Source"
|
|
sev_display = self.severity if self.severity else "Any Severity"
|
|
pri_display = self.priority if self.priority else "Any Priority"
|
|
return f"{self.hospital.name} - {source_display} - {sev_display}/{pri_display} - {self.sla_hours}h"
|
|
|
|
def get_first_reminder_hours_after(self, complaint_created_at=None):
|
|
"""
|
|
Calculate first reminder timing based on config.
|
|
Returns hours after creation if configured, else hours before deadline.
|
|
"""
|
|
if self.first_reminder_hours_after > 0:
|
|
return self.first_reminder_hours_after
|
|
else:
|
|
return max(0, self.sla_hours - self.reminder_hours_before)
|
|
|
|
def get_second_reminder_hours_after(self, complaint_created_at=None):
|
|
"""
|
|
Calculate second reminder timing based on config.
|
|
Returns hours after creation if configured, else hours before deadline.
|
|
"""
|
|
if self.second_reminder_hours_after > 0:
|
|
return self.second_reminder_hours_after
|
|
elif self.second_reminder_enabled:
|
|
return max(0, self.sla_hours - self.second_reminder_hours_before)
|
|
else:
|
|
return 0 # No second reminder
|
|
|
|
def get_escalation_hours_after(self, complaint_created_at=None):
|
|
"""
|
|
Calculate escalation timing based on config.
|
|
Returns hours after creation if configured, else use overdue logic (after SLA).
|
|
"""
|
|
if self.escalation_hours_after > 0:
|
|
return self.escalation_hours_after
|
|
else:
|
|
return None # Use standard overdue logic
|
|
|
|
|
|
class EscalationRule(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Configurable escalation rules for complaints.
|
|
|
|
Defines who receives escalated complaints based on conditions.
|
|
Supports multi-level escalation with configurable hierarchy.
|
|
"""
|
|
|
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="escalation_rules")
|
|
|
|
name = models.CharField(max_length=200)
|
|
description = models.TextField(blank=True)
|
|
|
|
# Escalation level (supports multi-level escalation)
|
|
escalation_level = models.IntegerField(
|
|
default=1,
|
|
help_text="Escalation level (1 = first level, 2 = second, etc.)"
|
|
)
|
|
|
|
max_escalation_level = models.IntegerField(
|
|
default=3,
|
|
help_text="Maximum escalation level before stopping (default: 3)"
|
|
)
|
|
|
|
# Trigger conditions
|
|
trigger_on_overdue = models.BooleanField(default=True, help_text="Trigger when complaint is overdue")
|
|
|
|
trigger_hours_overdue = models.IntegerField(default=0, help_text="Trigger X hours after overdue (0 = immediately)")
|
|
|
|
# Reminder-based escalation
|
|
reminder_escalation_enabled = models.BooleanField(
|
|
default=False,
|
|
help_text="Enable escalation after reminder if no action taken"
|
|
)
|
|
|
|
reminder_escalation_hours = models.IntegerField(
|
|
default=24,
|
|
help_text="Escalate X hours after reminder if no action"
|
|
)
|
|
|
|
# Escalation target
|
|
escalate_to_role = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("department_manager", "Department Manager"),
|
|
("hospital_admin", "Hospital Admin"),
|
|
("medical_director", "Medical Director"),
|
|
("admin_director", "Administrative Director"),
|
|
("px_admin", "PX Admin"),
|
|
("ceo", "CEO"),
|
|
("specific_user", "Specific User"),
|
|
],
|
|
help_text="Role to escalate to",
|
|
)
|
|
|
|
escalate_to_user = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="escalation_target_rules",
|
|
help_text="Specific user if escalate_to_role is 'specific_user'",
|
|
)
|
|
|
|
# Conditions
|
|
severity_filter = models.CharField(
|
|
max_length=20,
|
|
choices=SeverityChoices.choices,
|
|
blank=True,
|
|
help_text="Only escalate complaints with this severity (blank = all)",
|
|
)
|
|
|
|
priority_filter = models.CharField(
|
|
max_length=20,
|
|
choices=PriorityChoices.choices,
|
|
blank=True,
|
|
help_text="Only escalate complaints with this priority (blank = all)",
|
|
)
|
|
|
|
order = models.IntegerField(default=0, help_text="Escalation order (lower = first)")
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital", "order"]
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.name}"
|
|
|
|
|
|
class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Configurable thresholds for complaint-related triggers.
|
|
|
|
Defines when to trigger actions based on metrics (e.g., survey scores).
|
|
"""
|
|
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital", on_delete=models.CASCADE, related_name="complaint_thresholds"
|
|
)
|
|
|
|
threshold_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("resolution_survey_score", "Resolution Survey Score"),
|
|
("response_time", "Response Time"),
|
|
("resolution_time", "Resolution Time"),
|
|
],
|
|
help_text="Type of threshold",
|
|
)
|
|
|
|
threshold_value = models.FloatField(help_text="Threshold value (e.g., 50 for 50% score)")
|
|
|
|
comparison_operator = models.CharField(
|
|
max_length=10,
|
|
choices=[
|
|
("lt", "Less Than"),
|
|
("lte", "Less Than or Equal"),
|
|
("gt", "Greater Than"),
|
|
("gte", "Greater Than or Equal"),
|
|
("eq", "Equal"),
|
|
],
|
|
default="lt",
|
|
help_text="How to compare against threshold",
|
|
)
|
|
|
|
action_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("create_px_action", "Create PX Action"),
|
|
("send_notification", "Send Notification"),
|
|
("escalate", "Escalate"),
|
|
],
|
|
help_text="Action to take when threshold is breached",
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital", "threshold_type"]
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
models.Index(fields=["threshold_type", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.threshold_type} {self.comparison_operator} {self.threshold_value}"
|
|
|
|
def check_threshold(self, value):
|
|
"""Check if value breaches threshold"""
|
|
if self.comparison_operator == "lt":
|
|
return value < self.threshold_value
|
|
elif self.comparison_operator == "lte":
|
|
return value <= self.threshold_value
|
|
elif self.comparison_operator == "gt":
|
|
return value > self.threshold_value
|
|
elif self.comparison_operator == "gte":
|
|
return value >= self.threshold_value
|
|
elif self.comparison_operator == "eq":
|
|
return value == self.threshold_value
|
|
return False
|
|
|
|
|
|
class ExplanationSLAConfig(UUIDModel, TimeStampedModel):
|
|
"""
|
|
SLA configuration for staff explanation requests.
|
|
|
|
Defines time limits and escalation rules for staff to submit explanations
|
|
when a complaint is filed against them.
|
|
"""
|
|
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.CASCADE,
|
|
related_name="explanation_sla_configs"
|
|
)
|
|
|
|
# Time limits
|
|
response_hours = models.IntegerField(
|
|
default=48,
|
|
help_text="Hours staff has to submit explanation"
|
|
)
|
|
|
|
reminder_hours_before = models.IntegerField(
|
|
default=12,
|
|
help_text="Send reminder X hours before deadline"
|
|
)
|
|
|
|
# Escalation settings
|
|
auto_escalate_enabled = models.BooleanField(
|
|
default=True,
|
|
help_text="Automatically escalate to manager if no response"
|
|
)
|
|
|
|
escalation_hours_overdue = models.IntegerField(
|
|
default=0,
|
|
help_text="Escalate X hours after overdue (0 = immediately)"
|
|
)
|
|
|
|
max_escalation_levels = models.IntegerField(
|
|
default=3,
|
|
help_text="Maximum levels to escalate up staff hierarchy"
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital"]
|
|
verbose_name = "Explanation SLA Config"
|
|
verbose_name_plural = "Explanation SLA Configs"
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.response_hours}h to respond"
|
|
|
|
|
|
class Inquiry(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Inquiry model for general questions/requests.
|
|
|
|
Similar to complaints but for non-complaint inquiries.
|
|
"""
|
|
|
|
# Patient information
|
|
patient = models.ForeignKey(
|
|
"organizations.Patient", on_delete=models.CASCADE, null=True, blank=True, related_name="inquiries"
|
|
)
|
|
|
|
# Contact information (if patient not in system)
|
|
contact_name = models.CharField(max_length=200, blank=True)
|
|
contact_phone = models.CharField(max_length=20, blank=True)
|
|
contact_email = models.EmailField(blank=True)
|
|
|
|
# Organization
|
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="inquiries")
|
|
department = models.ForeignKey(
|
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="inquiries"
|
|
)
|
|
|
|
# Inquiry details
|
|
subject = models.CharField(max_length=500)
|
|
message = models.TextField()
|
|
|
|
# Category
|
|
category = models.CharField(
|
|
max_length=100,
|
|
choices=[
|
|
("appointment", "Appointment"),
|
|
("billing", "Billing"),
|
|
("medical_records", "Medical Records"),
|
|
("general", "General Information"),
|
|
("other", "Other"),
|
|
],
|
|
)
|
|
|
|
# Source
|
|
source = models.ForeignKey(
|
|
"px_sources.PXSource",
|
|
on_delete=models.PROTECT,
|
|
related_name="inquiries",
|
|
null=True,
|
|
blank=True,
|
|
help_text="Source of inquiry",
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("open", "Open"),
|
|
("in_progress", "In Progress"),
|
|
("resolved", "Resolved"),
|
|
("closed", "Closed"),
|
|
],
|
|
default="open",
|
|
db_index=True,
|
|
)
|
|
|
|
# Creator tracking
|
|
created_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_inquiries',
|
|
help_text="User who created this inquiry (SourceUser or Patient)"
|
|
)
|
|
|
|
# Assignment
|
|
assigned_to = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_inquiries"
|
|
)
|
|
assigned_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Response
|
|
response = models.TextField(blank=True)
|
|
responded_at = models.DateTimeField(null=True, blank=True)
|
|
responded_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_inquiries"
|
|
)
|
|
|
|
# Workflow classification
|
|
is_straightforward = models.BooleanField(
|
|
default=True,
|
|
verbose_name="Is Straightforward",
|
|
help_text="Direct resolution (no department coordination needed)"
|
|
)
|
|
|
|
is_outgoing = models.BooleanField(
|
|
default=False,
|
|
verbose_name="Is Outgoing",
|
|
help_text="Inquiry sent to external department for response"
|
|
)
|
|
|
|
outgoing_department = models.ForeignKey(
|
|
"organizations.Department",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="outgoing_inquiries",
|
|
help_text="Department that was contacted for this inquiry"
|
|
)
|
|
|
|
# Follow-up tracking
|
|
requires_follow_up = models.BooleanField(
|
|
default=False,
|
|
db_index=True,
|
|
help_text="This inquiry requires follow-up call"
|
|
)
|
|
|
|
follow_up_due_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Due date for follow-up call to inquirer"
|
|
)
|
|
|
|
follow_up_completed_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When follow-up call was completed"
|
|
)
|
|
|
|
follow_up_completed_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="completed_inquiry_followups",
|
|
help_text="User who completed the follow-up call"
|
|
)
|
|
|
|
follow_up_notes = models.TextField(
|
|
blank=True,
|
|
help_text="Notes from follow-up call"
|
|
)
|
|
|
|
follow_up_reminder_sent_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When reminder was sent for follow-up"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
verbose_name_plural = "Inquiries"
|
|
indexes = [
|
|
models.Index(fields=["status", "-created_at"]),
|
|
models.Index(fields=["hospital", "status"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.subject} ({self.status})"
|
|
|
|
|
|
class InquiryUpdate(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Inquiry update/timeline entry.
|
|
|
|
Tracks all updates, status changes, and communications for inquiries.
|
|
"""
|
|
|
|
inquiry = models.ForeignKey(Inquiry, on_delete=models.CASCADE, related_name="updates")
|
|
|
|
# Update details
|
|
update_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("status_change", "Status Change"),
|
|
("assignment", "Assignment"),
|
|
("note", "Note"),
|
|
("response", "Response"),
|
|
("communication", "Communication"),
|
|
],
|
|
db_index=True,
|
|
)
|
|
|
|
message = models.TextField()
|
|
|
|
# User who made the update
|
|
created_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="inquiry_updates"
|
|
)
|
|
|
|
# Status change tracking
|
|
old_status = models.CharField(max_length=20, blank=True)
|
|
new_status = models.CharField(max_length=20, blank=True)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["inquiry", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.inquiry} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
|
|
class InquiryAttachment(UUIDModel, TimeStampedModel):
|
|
"""Inquiry attachment (images, documents, etc.)"""
|
|
|
|
inquiry = models.ForeignKey(Inquiry, on_delete=models.CASCADE, related_name="attachments")
|
|
|
|
file = models.FileField(upload_to="inquiries/%Y/%m/%d/")
|
|
filename = models.CharField(max_length=500)
|
|
file_type = models.CharField(max_length=100, blank=True)
|
|
file_size = models.IntegerField(help_text="File size in bytes")
|
|
|
|
uploaded_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="inquiry_attachments"
|
|
)
|
|
|
|
description = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.inquiry} - {self.filename}"
|
|
|
|
|
|
class ComplaintExplanation(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Staff/recipient explanation about a complaint.
|
|
|
|
Allows staff members to submit their perspective via token-based link.
|
|
Each staff member can submit one explanation per complaint.
|
|
Includes SLA tracking for explanation submission deadlines.
|
|
"""
|
|
|
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="explanations")
|
|
|
|
staff = models.ForeignKey(
|
|
"organizations.Staff",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="complaint_explanations",
|
|
help_text="Staff member who submitted the explanation",
|
|
)
|
|
|
|
explanation = models.TextField(help_text="Staff's explanation about the complaint")
|
|
|
|
token = models.CharField(
|
|
max_length=64, unique=True, db_index=True, help_text="Unique access token for explanation submission"
|
|
)
|
|
|
|
is_used = models.BooleanField(
|
|
default=False, db_index=True, help_text="Token expiry tracking - becomes True after submission"
|
|
)
|
|
|
|
submitted_via = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("email_link", "Email Link"),
|
|
("direct", "Direct Entry"),
|
|
],
|
|
default="email_link",
|
|
help_text="How the explanation was submitted",
|
|
)
|
|
|
|
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When the explanation request email was sent")
|
|
|
|
responded_at = models.DateTimeField(null=True, blank=True, help_text="When the explanation was submitted")
|
|
|
|
# Request details
|
|
requested_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="requested_complaint_explanations",
|
|
help_text="User who requested the explanation",
|
|
)
|
|
|
|
request_message = models.TextField(blank=True, help_text="Optional message sent with the explanation request")
|
|
|
|
# SLA tracking for explanation requests
|
|
sla_due_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="SLA deadline for staff to submit explanation"
|
|
)
|
|
|
|
is_overdue = models.BooleanField(
|
|
default=False,
|
|
db_index=True,
|
|
help_text="Explanation request is overdue"
|
|
)
|
|
|
|
reminder_sent_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Reminder sent to staff about overdue explanation"
|
|
)
|
|
|
|
escalated_to_manager = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='escalated_from_staff',
|
|
help_text="Escalated to this explanation (manager's explanation request)"
|
|
)
|
|
|
|
escalated_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When explanation was escalated to manager"
|
|
)
|
|
|
|
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')}"
|