1013 lines
29 KiB
Python
1013 lines
29 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 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)
|
|
|
|
# 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.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
|
|
|
|
@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 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 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})"
|
|
|
|
|
|
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.
|
|
"""
|
|
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"
|
|
)
|
|
|
|
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 = self.staff if self.staff else 'Unknown'
|
|
return f"{self.complaint} - {staff_name} - {'Submitted' if self.is_used else 'Pending'}"
|
|
|
|
@property
|
|
def is_expired(self):
|
|
"""Check if token is expired (already used)"""
|
|
return self.is_used
|
|
|
|
def can_submit(self):
|
|
"""Check if explanation can still be submitted"""
|
|
return not self.is_used
|
|
|
|
|
|
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}"
|