1037 lines
34 KiB
Python
1037 lines
34 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"
|
|
RESOLVED = "resolved", "Resolved"
|
|
CLOSED = "closed", "Closed"
|
|
CANCELLED = "cancelled", "Cancelled"
|
|
|
|
|
|
class ComplaintType(models.TextChoices):
|
|
"""Complaint type choices - distinguish between complaints and appreciations"""
|
|
|
|
COMPLAINT = "complaint", "Complaint"
|
|
APPRECIATION = "appreciation", "Appreciation"
|
|
|
|
|
|
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.
|
|
|
|
Replaces hardcoded category choices with flexible, hospital-specific categories.
|
|
Uses ManyToMany to allow categories to be shared across multiple hospitals.
|
|
"""
|
|
|
|
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",
|
|
)
|
|
|
|
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):
|
|
hospital_count = self.hospitals.count()
|
|
if hospital_count == 0:
|
|
return f"System-wide - {self.name_en}"
|
|
elif hospital_count == 1:
|
|
return f"{self.hospitals.first().name} - {self.name_en}"
|
|
else:
|
|
return f"Multiple hospitals - {self.name_en}"
|
|
|
|
|
|
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)
|
|
|
|
# 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
|
|
category = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints", null=True, blank=True
|
|
)
|
|
subcategory = models.CharField(max_length=100, blank=True)
|
|
|
|
# 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)"
|
|
)
|
|
|
|
# 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)"
|
|
help_text="Source of complaint"
|
|
)
|
|
|
|
# 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)
|
|
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 and sync complaint_type from metadata"""
|
|
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 the 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 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
|
|
|
|
@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")
|
|
|
|
|
|
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 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", "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 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"),
|
|
("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,
|
|
)
|
|
|
|
# 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})"
|
|
|
|
|
|
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}"
|