HH/apps/complaints/models.py

1046 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)"
)
# 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,
)
# 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"
)
# 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}"