HH/apps/complaints/models.py
Marwan Alwali 2179fbf39a update
2025-12-31 13:16:30 +03:00

681 lines
20 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, TimeStampedModel, UUIDModel
class ComplaintStatus(models.TextChoices):
"""Complaint status choices"""
OPEN = 'open', 'Open'
IN_PROGRESS = 'in_progress', 'In Progress'
RESOLVED = 'resolved', 'Resolved'
CLOSED = 'closed', 'Closed'
CANCELLED = 'cancelled', 'Cancelled'
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 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.CASCADE,
related_name='complaints'
)
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'
)
physician = models.ForeignKey(
'organizations.Physician',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='complaints'
)
# Complaint details
title = models.CharField(max_length=500)
description = models.TextField()
# Classification
category = models.CharField(
max_length=100,
choices=[
('clinical_care', 'Clinical Care'),
('staff_behavior', 'Staff Behavior'),
('facility', 'Facility & Environment'),
('wait_time', 'Wait Time'),
('billing', 'Billing'),
('communication', 'Communication'),
('other', 'Other'),
],
db_index=True
)
subcategory = models.CharField(max_length=100, blank=True)
# 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.CharField(
max_length=50,
choices=ComplaintSource.choices,
default=ComplaintSource.PATIENT,
db_index=True
)
# 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)
escalated_at = models.DateTimeField(null=True, blank=True)
# Resolution
resolution = models.TextField(blank=True)
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.patient.get_full_name()} ({self.status})"
def save(self, *args, **kwargs):
"""Calculate SLA due date on creation"""
if not self.due_at:
self.due_at = self.calculate_sla_due_date()
super().save(*args, **kwargs)
def calculate_sla_due_date(self):
"""
Calculate SLA due date based on severity and hospital configuration.
First tries to use ComplaintSLAConfig from database.
Falls back to settings.SLA_DEFAULTS if no config exists.
"""
# Try to get SLA config from database
try:
sla_config = ComplaintSLAConfig.objects.get(
hospital=self.hospital,
severity=self.severity,
priority=self.priority,
is_active=True
)
sla_hours = sla_config.sla_hours
except ComplaintSLAConfig.DoesNotExist:
# Fall back to settings
sla_hours = settings.SLA_DEFAULTS['complaint'].get(
self.severity,
settings.SLA_DEFAULTS['complaint']['medium']
)
return timezone.now() + timedelta(hours=sla_hours)
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
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, severity, and priority.
Allows flexible SLA configuration instead of hardcoded values.
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='complaint_sla_configs'
)
severity = models.CharField(
max_length=20,
choices=SeverityChoices.choices,
help_text="Severity level for this SLA"
)
priority = models.CharField(
max_length=20,
choices=PriorityChoices.choices,
help_text="Priority level for this SLA"
)
sla_hours = models.IntegerField(
help_text="Number of hours until SLA deadline"
)
reminder_hours_before = models.IntegerField(
default=24,
help_text="Send reminder X hours before deadline"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['hospital', 'severity', 'priority']
unique_together = [['hospital', 'severity', 'priority']]
indexes = [
models.Index(fields=['hospital', 'is_active']),
]
def __str__(self):
return f"{self.hospital.name} - {self.severity}/{self.priority} - {self.sla_hours}h"
class ComplaintCategory(UUIDModel, TimeStampedModel):
"""
Custom complaint categories per hospital.
Replaces hardcoded category choices with flexible, hospital-specific categories.
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='complaint_categories',
null=True,
blank=True,
help_text="Leave blank for system-wide categories"
)
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"
)
order = models.IntegerField(
default=0,
help_text="Display order"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['hospital', 'order', 'name_en']
verbose_name_plural = 'Complaint Categories'
indexes = [
models.Index(fields=['hospital', 'is_active']),
models.Index(fields=['code']),
]
def __str__(self):
hospital_name = self.hospital.name if self.hospital else "System-wide"
return f"{hospital_name} - {self.name_en}"
class EscalationRule(UUIDModel, TimeStampedModel):
"""
Configurable escalation rules for complaints.
Defines who receives escalated complaints based on conditions.
"""
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='escalation_rules'
)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# 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)"
)
# Escalation target
escalate_to_role = models.CharField(
max_length=50,
choices=[
('department_manager', 'Department Manager'),
('hospital_admin', 'Hospital Admin'),
('px_admin', 'PX Admin'),
('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 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'),
]
)
# Status
status = models.CharField(
max_length=20,
choices=[
('open', 'Open'),
('in_progress', 'In Progress'),
('resolved', 'Resolved'),
('closed', 'Closed'),
],
default='open',
db_index=True
)
# Assignment
assigned_to = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_inquiries'
)
# 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'
)
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})"