2297 lines
74 KiB
Python
2297 lines
74 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 django.utils.translation import gettext_lazy as _
|
|
|
|
from apps.core.models import PriorityChoices, SeverityChoices, TenantModel, TimeStampedModel, UUIDModel
|
|
|
|
|
|
class ComplaintStatus(models.TextChoices):
|
|
"""Complaint status choices"""
|
|
|
|
OPEN = "open", "Open"
|
|
IN_PROGRESS = "in_progress", "In Progress"
|
|
PARTIALLY_RESOLVED = "partially_resolved", "Partially Resolved"
|
|
RESOLVED = "resolved", "Resolved"
|
|
CLOSED = "closed", "Closed"
|
|
CANCELLED = "cancelled", "Cancelled"
|
|
|
|
|
|
class ResolutionCategory(models.TextChoices):
|
|
"""Resolution category choices"""
|
|
|
|
FULL_ACTION_TAKEN = "full_action_taken", "Full Action Taken"
|
|
PARTIAL_ACTION_TAKEN = "partial_action_taken", "Partial Action Taken"
|
|
NO_ACTION_NEEDED = "no_action_needed", "No Action Needed"
|
|
CANNOT_RESOLVE = "cannot_resolve", "Cannot Resolve"
|
|
PATIENT_WITHDRAWN = "patient_withdrawn", "Patient Withdrawn"
|
|
|
|
|
|
class ComplaintType(models.TextChoices):
|
|
"""Complaint type choices - distinguish between complaints and appreciations"""
|
|
|
|
COMPLAINT = "complaint", "Complaint"
|
|
APPRECIATION = "appreciation", "Appreciation"
|
|
|
|
|
|
class ComplaintSourceType(models.TextChoices):
|
|
"""Complaint source type choices - Internal vs External"""
|
|
|
|
INTERNAL = "internal", "Internal"
|
|
EXTERNAL = "external", "External"
|
|
|
|
|
|
class ComplaintSource(models.TextChoices):
|
|
"""Complaint source choices"""
|
|
|
|
PATIENT = "patient", "Patient"
|
|
FAMILY = "family", "Family Member"
|
|
STAFF = "staff", "Staff"
|
|
SURVEY = "survey", "Survey"
|
|
SOCIAL_MEDIA = "social_media", "Social Media"
|
|
CALL_CENTER = "call_center", "Call Center"
|
|
MOH = "moh", "Ministry of Health"
|
|
CHI = "chi", "Council of Health Insurance"
|
|
OTHER = "other", "Other"
|
|
|
|
|
|
class ComplaintCategory(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Custom complaint categories per hospital with 4-level SHCT taxonomy.
|
|
|
|
Supports hierarchical structure:
|
|
- Level 1: Domain (CLINICAL, MANAGEMENT, RELATIONSHIPS)
|
|
- Level 2: Category (Quality, Safety, Communication, etc.)
|
|
- Level 3: Subcategory (Examination, Patient Journey, etc.)
|
|
- Level 4: Classification (Examination not performed, etc.)
|
|
|
|
Uses ManyToMany to allow categories to be shared across multiple hospitals.
|
|
"""
|
|
|
|
class LevelChoices(models.IntegerChoices):
|
|
DOMAIN = 1, "Domain"
|
|
CATEGORY = 2, "Category"
|
|
SUBCATEGORY = 3, "Subcategory"
|
|
CLASSIFICATION = 4, "Classification"
|
|
|
|
class DomainTypeChoices(models.TextChoices):
|
|
CLINICAL = "CLINICAL", "Clinical"
|
|
MANAGEMENT = "MANAGEMENT", "Management"
|
|
RELATIONSHIPS = "RELATIONSHIPS", "Relationships"
|
|
|
|
hospitals = models.ManyToManyField(
|
|
"organizations.Hospital",
|
|
blank=True,
|
|
related_name="complaint_categories",
|
|
help_text="Empty list = system-wide category. Add hospitals to share category.",
|
|
)
|
|
|
|
code = models.CharField(max_length=50, help_text="Unique code for this category")
|
|
|
|
name_en = models.CharField(max_length=200)
|
|
name_ar = models.CharField(max_length=200, blank=True)
|
|
|
|
description_en = models.TextField(blank=True)
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
parent = models.ForeignKey(
|
|
"self",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="subcategories",
|
|
help_text="Parent category for hierarchical structure",
|
|
)
|
|
|
|
level = models.IntegerField(
|
|
choices=LevelChoices.choices,
|
|
help_text="Hierarchy level (1=Domain, 2=Category, 3=Subcategory, 4=Classification)"
|
|
)
|
|
|
|
domain_type = models.CharField(
|
|
max_length=20,
|
|
choices=DomainTypeChoices.choices,
|
|
blank=True,
|
|
help_text="Domain type for top-level categories"
|
|
)
|
|
|
|
order = models.IntegerField(default=0, help_text="Display order")
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["order", "name_en"]
|
|
verbose_name_plural = "Complaint Categories"
|
|
indexes = [
|
|
models.Index(fields=["code"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
level_display = self.get_level_display()
|
|
hospital_count = self.hospitals.count()
|
|
|
|
if hospital_count == 0:
|
|
hospital_info = "System-wide"
|
|
elif hospital_count == 1:
|
|
hospital_info = self.hospitals.first().name
|
|
else:
|
|
hospital_info = f"{hospital_count} hospitals"
|
|
|
|
if self.level == self.LevelChoices.CLASSIFICATION and self.parent:
|
|
parent_path = " > ".join([self.parent.name_en])
|
|
return f"{level_display}: {parent_path} > {self.name_en}"
|
|
else:
|
|
return f"{level_display}: {self.name_en} ({hospital_info})"
|
|
|
|
|
|
class Complaint(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Complaint model with SLA tracking.
|
|
|
|
Workflow:
|
|
1. OPEN - Complaint received
|
|
2. IN_PROGRESS - Being investigated
|
|
3. RESOLVED - Solution provided
|
|
4. CLOSED - Confirmed closed (triggers resolution satisfaction survey)
|
|
|
|
SLA:
|
|
- Calculated based on severity and hospital configuration
|
|
- Reminders sent before due date
|
|
- Escalation triggered when overdue
|
|
"""
|
|
|
|
# Patient and encounter information
|
|
patient = models.ForeignKey(
|
|
"organizations.Patient", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
|
)
|
|
|
|
# Contact information for anonymous/unregistered submissions
|
|
contact_name = models.CharField(max_length=200, blank=True)
|
|
contact_phone = models.CharField(max_length=20, blank=True)
|
|
contact_email = models.EmailField(blank=True)
|
|
|
|
# Public complaint form fields
|
|
relation_to_patient = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('patient', 'Patient'),
|
|
('relative', 'Relative'),
|
|
],
|
|
blank=True,
|
|
verbose_name="Relation to Patient",
|
|
help_text="Complainant's relationship to the patient"
|
|
)
|
|
|
|
patient_name = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name="Patient Name",
|
|
help_text="Name of the patient involved"
|
|
)
|
|
|
|
national_id = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
verbose_name="National ID/Iqama No.",
|
|
help_text="Saudi National ID or Iqama number"
|
|
)
|
|
|
|
incident_date = models.DateField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name="Incident Date",
|
|
help_text="Date when the incident occurred"
|
|
)
|
|
|
|
staff_name = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name="Staff Involved",
|
|
help_text="Name of staff member involved (if known)"
|
|
)
|
|
|
|
expected_result = models.TextField(
|
|
blank=True,
|
|
verbose_name="Expected Complaint Result",
|
|
help_text="What the complainant expects as a resolution"
|
|
)
|
|
|
|
# Reference number for tracking
|
|
reference_number = models.CharField(
|
|
max_length=50,
|
|
unique=True,
|
|
db_index=True,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Unique reference number for patient tracking",
|
|
)
|
|
|
|
encounter_id = models.CharField(
|
|
max_length=100, blank=True, db_index=True, help_text="Related encounter ID if applicable"
|
|
)
|
|
|
|
# Organization
|
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="complaints")
|
|
department = models.ForeignKey(
|
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
|
)
|
|
staff = models.ForeignKey(
|
|
"organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
|
)
|
|
|
|
# Complaint details
|
|
title = models.CharField(max_length=500)
|
|
description = models.TextField()
|
|
|
|
# Classification - 4-level SHCT taxonomy
|
|
domain = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_domain",
|
|
null=True, blank=True, help_text="Level 1: Domain"
|
|
)
|
|
category = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints",
|
|
null=True, blank=True, help_text="Level 2: Category"
|
|
)
|
|
# Keep CharField for backward compatibility (stores the code)
|
|
subcategory = models.CharField(max_length=100, blank=True, help_text="Level 3: Subcategory code (legacy)")
|
|
classification = models.CharField(max_length=100, blank=True, help_text="Level 4: Classification code (legacy)")
|
|
# New FK fields for proper relationships
|
|
subcategory_obj = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_subcategory",
|
|
null=True, blank=True, help_text="Level 3: Subcategory"
|
|
)
|
|
classification_obj = models.ForeignKey(
|
|
ComplaintCategory, on_delete=models.PROTECT, related_name="complaints_classification",
|
|
null=True, blank=True, help_text="Level 4: Classification"
|
|
)
|
|
|
|
# Location hierarchy - required fields
|
|
location = models.ForeignKey(
|
|
'organizations.Location',
|
|
on_delete=models.PROTECT,
|
|
related_name='complaints',
|
|
null=True,
|
|
blank=True,
|
|
help_text="Location (e.g., Riyadh, Jeddah)"
|
|
)
|
|
main_section = models.ForeignKey(
|
|
'organizations.MainSection',
|
|
on_delete=models.PROTECT,
|
|
related_name='complaints',
|
|
null=True,
|
|
blank=True,
|
|
help_text="Section/Department"
|
|
)
|
|
subsection = models.ForeignKey(
|
|
'organizations.SubSection',
|
|
on_delete=models.PROTECT,
|
|
related_name='complaints',
|
|
null=True,
|
|
blank=True,
|
|
help_text="Subsection within the section"
|
|
)
|
|
|
|
# Type (complaint vs appreciation)
|
|
complaint_type = models.CharField(
|
|
max_length=20,
|
|
choices=ComplaintType.choices,
|
|
default=ComplaintType.COMPLAINT,
|
|
db_index=True,
|
|
help_text="Type of feedback (complaint vs appreciation)"
|
|
)
|
|
|
|
# Source type (Internal vs External)
|
|
complaint_source_type = models.CharField(
|
|
max_length=20,
|
|
choices=ComplaintSourceType.choices,
|
|
default=ComplaintSourceType.EXTERNAL,
|
|
db_index=True,
|
|
help_text="Source type (Internal = staff-generated, External = patient/public-generated)"
|
|
)
|
|
|
|
# Priority and severity
|
|
priority = models.CharField(
|
|
max_length=20, choices=PriorityChoices.choices, default=PriorityChoices.MEDIUM, db_index=True
|
|
)
|
|
severity = models.CharField(
|
|
max_length=20, choices=SeverityChoices.choices, default=SeverityChoices.MEDIUM, db_index=True
|
|
)
|
|
|
|
# Source
|
|
source = models.ForeignKey(
|
|
"px_sources.PXSource",
|
|
on_delete=models.PROTECT,
|
|
related_name="complaints",
|
|
null=True,
|
|
blank=True,
|
|
help_text="Source of complaint",
|
|
)
|
|
|
|
# Creator tracking
|
|
created_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_complaints',
|
|
help_text="User who created this complaint (SourceUser or Patient)"
|
|
)
|
|
|
|
# Status and workflow
|
|
status = models.CharField(
|
|
max_length=20, choices=ComplaintStatus.choices, default=ComplaintStatus.OPEN, db_index=True
|
|
)
|
|
|
|
# Assignment
|
|
assigned_to = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_complaints"
|
|
)
|
|
assigned_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# SLA tracking
|
|
due_at = models.DateTimeField(db_index=True, help_text="SLA deadline")
|
|
is_overdue = models.BooleanField(default=False, db_index=True)
|
|
reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="First SLA reminder timestamp")
|
|
second_reminder_sent_at = models.DateTimeField(null=True, blank=True, help_text="Second SLA reminder timestamp")
|
|
escalated_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Resolution
|
|
resolution = models.TextField(blank=True)
|
|
resolution_sent_at = models.DateTimeField(null=True, blank=True)
|
|
resolution_category = models.CharField(
|
|
max_length=50,
|
|
choices=ResolutionCategory.choices,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Category of resolution"
|
|
)
|
|
resolved_at = models.DateTimeField(null=True, blank=True)
|
|
resolved_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_complaints"
|
|
)
|
|
|
|
# Closure
|
|
closed_at = models.DateTimeField(null=True, blank=True)
|
|
closed_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_complaints"
|
|
)
|
|
|
|
# Resolution satisfaction survey
|
|
resolution_survey = models.ForeignKey(
|
|
"surveys.SurveyInstance", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaint_resolution"
|
|
)
|
|
resolution_survey_sent_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["status", "-created_at"]),
|
|
models.Index(fields=["hospital", "status", "-created_at"]),
|
|
models.Index(fields=["is_overdue", "status"]),
|
|
models.Index(fields=["due_at", "status"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.title} - ({self.status})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Calculate SLA due date on creation, generate reference number, and sync complaint_type from metadata"""
|
|
# Track status change for signals
|
|
if self.pk:
|
|
try:
|
|
old_instance = Complaint.objects.get(pk=self.pk)
|
|
self._status_was = old_instance.status
|
|
except Complaint.DoesNotExist:
|
|
self._status_was = None
|
|
|
|
# Generate reference number if not set (for all creation methods: form, API, admin)
|
|
if not self.reference_number:
|
|
from datetime import datetime
|
|
import uuid
|
|
today = datetime.now().strftime("%Y%m%d")
|
|
random_suffix = str(uuid.uuid4().int)[:6]
|
|
self.reference_number = f"CMP-{today}-{random_suffix}"
|
|
|
|
if not self.due_at:
|
|
self.due_at = self.calculate_sla_due_date()
|
|
|
|
# Sync complaint_type from AI metadata if not already set
|
|
# This ensures that model field stays in sync with AI classification
|
|
if self.metadata and 'ai_analysis' in self.metadata:
|
|
ai_complaint_type = self.metadata['ai_analysis'].get('complaint_type', 'complaint')
|
|
# Only sync if model field is still default 'complaint'
|
|
# This preserves any manual changes while fixing AI-synced complaints
|
|
if self.complaint_type == 'complaint' and ai_complaint_type != 'complaint':
|
|
self.complaint_type = ai_complaint_type
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
def calculate_sla_due_date(self):
|
|
"""
|
|
Calculate SLA due date based on source, severity, and hospital configuration.
|
|
|
|
Priority order:
|
|
1. Source-based config (MOH, CHI, Internal)
|
|
2. Severity/priority-based config
|
|
3. Settings defaults
|
|
|
|
Source-based configs take precedence over severity/priority-based configs.
|
|
"""
|
|
# Try source-based SLA config first
|
|
if self.source:
|
|
try:
|
|
sla_config = ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
source=self.source,
|
|
is_active=True
|
|
)
|
|
sla_hours = sla_config.sla_hours
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall through to next option
|
|
|
|
# Try severity/priority-based config
|
|
try:
|
|
sla_config = ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
source__isnull=True, # Explicitly check for null source
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
sla_hours = sla_config.sla_hours
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall through to next option
|
|
|
|
# Try severity/priority-based config without source filter (backward compatibility)
|
|
try:
|
|
sla_config = ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
sla_hours = sla_config.sla_hours
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall back to settings
|
|
|
|
# Fall back to settings defaults
|
|
sla_hours = settings.SLA_DEFAULTS["complaint"].get(
|
|
self.severity, settings.SLA_DEFAULTS["complaint"]["medium"]
|
|
)
|
|
|
|
return timezone.now() + timedelta(hours=sla_hours)
|
|
|
|
def get_sla_config(self):
|
|
"""
|
|
Get the SLA config for this complaint.
|
|
|
|
Returns the source-based or severity/priority-based config that applies to this complaint.
|
|
Returns None if no config is found (will use defaults).
|
|
"""
|
|
# Try source-based SLA config first
|
|
if self.source:
|
|
try:
|
|
return ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
source=self.source,
|
|
is_active=True
|
|
)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall through to next option
|
|
|
|
# Try severity/priority-based config
|
|
try:
|
|
return ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
source__isnull=True,
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # Fall through to next option
|
|
|
|
# Try severity/priority-based config without source filter (backward compatibility)
|
|
try:
|
|
return ComplaintSLAConfig.objects.get(
|
|
hospital=self.hospital,
|
|
severity=self.severity,
|
|
priority=self.priority,
|
|
is_active=True
|
|
)
|
|
except ComplaintSLAConfig.DoesNotExist:
|
|
pass # No config found
|
|
|
|
return None
|
|
|
|
def check_overdue(self):
|
|
"""Check if complaint is overdue and update status"""
|
|
if self.status in [ComplaintStatus.CLOSED, ComplaintStatus.CANCELLED]:
|
|
return False
|
|
|
|
if timezone.now() > self.due_at:
|
|
if not self.is_overdue:
|
|
self.is_overdue = True
|
|
self.save(update_fields=["is_overdue"])
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def is_active_status(self):
|
|
"""
|
|
Check if complaint is in an active status (can be worked on).
|
|
Active statuses: OPEN, IN_PROGRESS, PARTIALLY_RESOLVED
|
|
Inactive statuses: RESOLVED, CLOSED, CANCELLED
|
|
"""
|
|
return self.status in [
|
|
ComplaintStatus.OPEN,
|
|
ComplaintStatus.IN_PROGRESS,
|
|
ComplaintStatus.PARTIALLY_RESOLVED
|
|
]
|
|
|
|
@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 emotion_confidence_percent(self):
|
|
"""Get AI confidence as percentage (0-100) from metadata"""
|
|
return self.emotion_confidence * 100
|
|
|
|
@property
|
|
def emotion_intensity_percent(self):
|
|
"""Get AI emotion intensity as percentage (0-100) from metadata"""
|
|
return self.emotion_intensity * 100
|
|
|
|
@property
|
|
def get_emotion_display(self):
|
|
"""Get human-readable emotion display"""
|
|
emotion_map = {
|
|
"anger": "Anger",
|
|
"sadness": "Sadness",
|
|
"confusion": "Confusion",
|
|
"fear": "Fear",
|
|
"neutral": "Neutral",
|
|
}
|
|
return emotion_map.get(self.emotion, "Neutral")
|
|
|
|
@property
|
|
def get_emotion_badge_class(self):
|
|
"""Get Bootstrap badge class for emotion"""
|
|
badge_map = {
|
|
"anger": "danger",
|
|
"sadness": "primary",
|
|
"confusion": "warning",
|
|
"fear": "info",
|
|
"neutral": "secondary",
|
|
}
|
|
return badge_map.get(self.emotion, "secondary")
|
|
|
|
def get_tracking_url(self):
|
|
"""
|
|
Get the public tracking URL for this complaint.
|
|
|
|
Returns the full URL that complainants can use to track their complaint status.
|
|
"""
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
from django.urls import reverse
|
|
|
|
# Build absolute URL
|
|
try:
|
|
site = get_current_site(None)
|
|
domain = site.domain
|
|
except:
|
|
domain = 'localhost:8000'
|
|
|
|
return f"https://{domain}{reverse('complaints:public_complaint_track')}?reference={self.reference_number}"
|
|
|
|
|
|
class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
|
"""Complaint attachment (images, documents, etc.)"""
|
|
|
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="attachments")
|
|
|
|
file = models.FileField(upload_to="complaints/%Y/%m/%d/")
|
|
filename = models.CharField(max_length=500)
|
|
file_type = models.CharField(max_length=100, blank=True)
|
|
file_size = models.IntegerField(help_text="File size in bytes")
|
|
|
|
uploaded_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="complaint_attachments"
|
|
)
|
|
|
|
description = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.complaint} - {self.filename}"
|
|
|
|
|
|
class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Complaint update/timeline entry.
|
|
|
|
Tracks all updates, status changes, and communications.
|
|
"""
|
|
|
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="updates")
|
|
|
|
# Update details
|
|
update_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("status_change", "Status Change"),
|
|
("assignment", "Assignment"),
|
|
("note", "Note"),
|
|
("resolution", "Resolution"),
|
|
("escalation", "Escalation"),
|
|
("communication", "Communication"),
|
|
],
|
|
db_index=True,
|
|
)
|
|
|
|
message = models.TextField()
|
|
|
|
# User who made the update
|
|
created_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="complaint_updates"
|
|
)
|
|
|
|
# Status change tracking
|
|
old_status = models.CharField(max_length=20, blank=True)
|
|
new_status = models.CharField(max_length=20, blank=True)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["complaint", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.complaint} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
|
|
class ComplaintSLAConfig(UUIDModel, TimeStampedModel):
|
|
"""
|
|
SLA configuration for complaints per hospital, source, severity, and priority.
|
|
|
|
Allows flexible SLA configuration instead of hardcoded values.
|
|
Supports both source-based (MOH, CHI, Internal) and severity/priority-based configurations.
|
|
"""
|
|
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital", on_delete=models.CASCADE, related_name="complaint_sla_configs"
|
|
)
|
|
|
|
source = models.ForeignKey(
|
|
"px_sources.PXSource",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="complaint_sla_configs",
|
|
help_text="Complaint source (MOH, CHI, Patient, etc.). Empty = severity/priority-based config"
|
|
)
|
|
|
|
severity = models.CharField(
|
|
max_length=20,
|
|
choices=SeverityChoices.choices,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Severity level for this SLA (optional if source is specified)"
|
|
)
|
|
|
|
priority = models.CharField(
|
|
max_length=20,
|
|
choices=PriorityChoices.choices,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Priority level for this SLA (optional if source is specified)"
|
|
)
|
|
|
|
sla_hours = models.IntegerField(help_text="Number of hours until SLA deadline")
|
|
|
|
# Source-based reminder timing (from complaint creation)
|
|
first_reminder_hours_after = models.IntegerField(
|
|
default=0,
|
|
help_text="Send 1st reminder X hours after complaint creation (0 = use reminder_hours_before)"
|
|
)
|
|
|
|
second_reminder_hours_after = models.IntegerField(
|
|
default=0,
|
|
help_text="Send 2nd reminder X hours after complaint creation (0 = use second_reminder_hours_before)"
|
|
)
|
|
|
|
escalation_hours_after = models.IntegerField(
|
|
default=0,
|
|
help_text="Escalate complaint X hours after creation if unresolved (0 = use overdue logic)"
|
|
)
|
|
|
|
# Legacy reminder timing (before deadline - kept for backward compatibility)
|
|
reminder_hours_before = models.IntegerField(default=24, help_text="Send first reminder X hours before deadline")
|
|
|
|
# Second reminder configuration
|
|
second_reminder_enabled = models.BooleanField(default=False, help_text="Enable sending a second reminder")
|
|
|
|
second_reminder_hours_before = models.IntegerField(default=6, help_text="Send second reminder X hours before deadline")
|
|
|
|
# Thank you email configuration
|
|
thank_you_email_enabled = models.BooleanField(default=False, help_text="Send thank you email when complaint is closed")
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital", "source", "severity", "priority"]
|
|
unique_together = [["hospital", "source", "severity", "priority"]]
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
models.Index(fields=["hospital", "source", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
source_display = self.source.name_en if self.source else "Any Source"
|
|
sev_display = self.severity if self.severity else "Any Severity"
|
|
pri_display = self.priority if self.priority else "Any Priority"
|
|
return f"{self.hospital.name} - {source_display} - {sev_display}/{pri_display} - {self.sla_hours}h"
|
|
|
|
def get_first_reminder_hours_after(self, complaint_created_at=None):
|
|
"""
|
|
Calculate first reminder timing based on config.
|
|
Returns hours after creation if configured, else hours before deadline.
|
|
"""
|
|
if self.first_reminder_hours_after > 0:
|
|
return self.first_reminder_hours_after
|
|
else:
|
|
return max(0, self.sla_hours - self.reminder_hours_before)
|
|
|
|
def get_second_reminder_hours_after(self, complaint_created_at=None):
|
|
"""
|
|
Calculate second reminder timing based on config.
|
|
Returns hours after creation if configured, else hours before deadline.
|
|
"""
|
|
if self.second_reminder_hours_after > 0:
|
|
return self.second_reminder_hours_after
|
|
elif self.second_reminder_enabled:
|
|
return max(0, self.sla_hours - self.second_reminder_hours_before)
|
|
else:
|
|
return 0 # No second reminder
|
|
|
|
def get_escalation_hours_after(self, complaint_created_at=None):
|
|
"""
|
|
Calculate escalation timing based on config.
|
|
Returns hours after creation if configured, else use overdue logic (after SLA).
|
|
"""
|
|
if self.escalation_hours_after > 0:
|
|
return self.escalation_hours_after
|
|
else:
|
|
return None # Use standard overdue logic
|
|
|
|
|
|
class EscalationRule(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Configurable escalation rules for complaints.
|
|
|
|
Defines who receives escalated complaints based on conditions.
|
|
Supports multi-level escalation with configurable hierarchy.
|
|
"""
|
|
|
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="escalation_rules")
|
|
|
|
name = models.CharField(max_length=200)
|
|
description = models.TextField(blank=True)
|
|
|
|
# Escalation level (supports multi-level escalation)
|
|
escalation_level = models.IntegerField(
|
|
default=1,
|
|
help_text="Escalation level (1 = first level, 2 = second, etc.)"
|
|
)
|
|
|
|
max_escalation_level = models.IntegerField(
|
|
default=3,
|
|
help_text="Maximum escalation level before stopping (default: 3)"
|
|
)
|
|
|
|
# Trigger conditions
|
|
trigger_on_overdue = models.BooleanField(default=True, help_text="Trigger when complaint is overdue")
|
|
|
|
trigger_hours_overdue = models.IntegerField(default=0, help_text="Trigger X hours after overdue (0 = immediately)")
|
|
|
|
# Reminder-based escalation
|
|
reminder_escalation_enabled = models.BooleanField(
|
|
default=False,
|
|
help_text="Enable escalation after reminder if no action taken"
|
|
)
|
|
|
|
reminder_escalation_hours = models.IntegerField(
|
|
default=24,
|
|
help_text="Escalate X hours after reminder if no action"
|
|
)
|
|
|
|
# Escalation target
|
|
escalate_to_role = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("department_manager", "Department Manager"),
|
|
("hospital_admin", "Hospital Admin"),
|
|
("medical_director", "Medical Director"),
|
|
("admin_director", "Administrative Director"),
|
|
("px_admin", "PX Admin"),
|
|
("ceo", "CEO"),
|
|
("specific_user", "Specific User"),
|
|
],
|
|
help_text="Role to escalate to",
|
|
)
|
|
|
|
escalate_to_user = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="escalation_target_rules",
|
|
help_text="Specific user if escalate_to_role is 'specific_user'",
|
|
)
|
|
|
|
# Conditions
|
|
severity_filter = models.CharField(
|
|
max_length=20,
|
|
choices=SeverityChoices.choices,
|
|
blank=True,
|
|
help_text="Only escalate complaints with this severity (blank = all)",
|
|
)
|
|
|
|
priority_filter = models.CharField(
|
|
max_length=20,
|
|
choices=PriorityChoices.choices,
|
|
blank=True,
|
|
help_text="Only escalate complaints with this priority (blank = all)",
|
|
)
|
|
|
|
order = models.IntegerField(default=0, help_text="Escalation order (lower = first)")
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital", "order"]
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.name}"
|
|
|
|
|
|
class ComplaintThreshold(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Configurable thresholds for complaint-related triggers.
|
|
|
|
Defines when to trigger actions based on metrics (e.g., survey scores).
|
|
"""
|
|
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital", on_delete=models.CASCADE, related_name="complaint_thresholds"
|
|
)
|
|
|
|
threshold_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("resolution_survey_score", "Resolution Survey Score"),
|
|
("response_time", "Response Time"),
|
|
("resolution_time", "Resolution Time"),
|
|
],
|
|
help_text="Type of threshold",
|
|
)
|
|
|
|
threshold_value = models.FloatField(help_text="Threshold value (e.g., 50 for 50% score)")
|
|
|
|
comparison_operator = models.CharField(
|
|
max_length=10,
|
|
choices=[
|
|
("lt", "Less Than"),
|
|
("lte", "Less Than or Equal"),
|
|
("gt", "Greater Than"),
|
|
("gte", "Greater Than or Equal"),
|
|
("eq", "Equal"),
|
|
],
|
|
default="lt",
|
|
help_text="How to compare against threshold",
|
|
)
|
|
|
|
action_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("create_px_action", "Create PX Action"),
|
|
("send_notification", "Send Notification"),
|
|
("escalate", "Escalate"),
|
|
],
|
|
help_text="Action to take when threshold is breached",
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital", "threshold_type"]
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
models.Index(fields=["threshold_type", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.threshold_type} {self.comparison_operator} {self.threshold_value}"
|
|
|
|
def check_threshold(self, value):
|
|
"""Check if value breaches threshold"""
|
|
if self.comparison_operator == "lt":
|
|
return value < self.threshold_value
|
|
elif self.comparison_operator == "lte":
|
|
return value <= self.threshold_value
|
|
elif self.comparison_operator == "gt":
|
|
return value > self.threshold_value
|
|
elif self.comparison_operator == "gte":
|
|
return value >= self.threshold_value
|
|
elif self.comparison_operator == "eq":
|
|
return value == self.threshold_value
|
|
return False
|
|
|
|
|
|
class ExplanationSLAConfig(UUIDModel, TimeStampedModel):
|
|
"""
|
|
SLA configuration for staff explanation requests.
|
|
|
|
Defines time limits and escalation rules for staff to submit explanations
|
|
when a complaint is filed against them.
|
|
"""
|
|
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.CASCADE,
|
|
related_name="explanation_sla_configs"
|
|
)
|
|
|
|
# Time limits
|
|
response_hours = models.IntegerField(
|
|
default=48,
|
|
help_text="Hours staff has to submit explanation"
|
|
)
|
|
|
|
reminder_hours_before = models.IntegerField(
|
|
default=12,
|
|
help_text="Send reminder X hours before deadline"
|
|
)
|
|
|
|
# Escalation settings
|
|
auto_escalate_enabled = models.BooleanField(
|
|
default=True,
|
|
help_text="Automatically escalate to manager if no response"
|
|
)
|
|
|
|
escalation_hours_overdue = models.IntegerField(
|
|
default=0,
|
|
help_text="Escalate X hours after overdue (0 = immediately)"
|
|
)
|
|
|
|
max_escalation_levels = models.IntegerField(
|
|
default=3,
|
|
help_text="Maximum levels to escalate up staff hierarchy"
|
|
)
|
|
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["hospital"]
|
|
verbose_name = "Explanation SLA Config"
|
|
verbose_name_plural = "Explanation SLA Configs"
|
|
indexes = [
|
|
models.Index(fields=["hospital", "is_active"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.hospital.name} - {self.response_hours}h to respond"
|
|
|
|
|
|
class Inquiry(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Inquiry model for general questions/requests.
|
|
|
|
Similar to complaints but for non-complaint inquiries.
|
|
"""
|
|
|
|
# Patient information
|
|
patient = models.ForeignKey(
|
|
"organizations.Patient", on_delete=models.CASCADE, null=True, blank=True, related_name="inquiries"
|
|
)
|
|
|
|
# Contact information (if patient not in system)
|
|
contact_name = models.CharField(max_length=200, blank=True)
|
|
contact_phone = models.CharField(max_length=20, blank=True)
|
|
contact_email = models.EmailField(blank=True)
|
|
|
|
# Organization
|
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="inquiries")
|
|
department = models.ForeignKey(
|
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="inquiries"
|
|
)
|
|
|
|
# Inquiry details
|
|
subject = models.CharField(max_length=500)
|
|
message = models.TextField()
|
|
|
|
# Category
|
|
category = models.CharField(
|
|
max_length=100,
|
|
choices=[
|
|
("appointment", "Appointment"),
|
|
("billing", "Billing"),
|
|
("medical_records", "Medical Records"),
|
|
("general", "General Information"),
|
|
("other", "Other"),
|
|
],
|
|
)
|
|
|
|
# Source
|
|
source = models.ForeignKey(
|
|
"px_sources.PXSource",
|
|
on_delete=models.PROTECT,
|
|
related_name="inquiries",
|
|
null=True,
|
|
blank=True,
|
|
help_text="Source of inquiry",
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("open", "Open"),
|
|
("in_progress", "In Progress"),
|
|
("resolved", "Resolved"),
|
|
("closed", "Closed"),
|
|
],
|
|
default="open",
|
|
db_index=True,
|
|
)
|
|
|
|
# Creator tracking
|
|
created_by = models.ForeignKey(
|
|
'accounts.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_inquiries',
|
|
help_text="User who created this inquiry (SourceUser or Patient)"
|
|
)
|
|
|
|
# Assignment
|
|
assigned_to = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_inquiries"
|
|
)
|
|
assigned_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Response
|
|
response = models.TextField(blank=True)
|
|
responded_at = models.DateTimeField(null=True, blank=True)
|
|
responded_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_inquiries"
|
|
)
|
|
|
|
# Workflow classification
|
|
is_straightforward = models.BooleanField(
|
|
default=True,
|
|
verbose_name="Is Straightforward",
|
|
help_text="Direct resolution (no department coordination needed)"
|
|
)
|
|
|
|
is_outgoing = models.BooleanField(
|
|
default=False,
|
|
verbose_name="Is Outgoing",
|
|
help_text="Inquiry sent to external department for response"
|
|
)
|
|
|
|
outgoing_department = models.ForeignKey(
|
|
"organizations.Department",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="outgoing_inquiries",
|
|
help_text="Department that was contacted for this inquiry"
|
|
)
|
|
|
|
# Follow-up tracking
|
|
requires_follow_up = models.BooleanField(
|
|
default=False,
|
|
db_index=True,
|
|
help_text="This inquiry requires follow-up call"
|
|
)
|
|
|
|
follow_up_due_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="Due date for follow-up call to inquirer"
|
|
)
|
|
|
|
follow_up_completed_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When follow-up call was completed"
|
|
)
|
|
|
|
follow_up_completed_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="completed_inquiry_followups",
|
|
help_text="User who completed the follow-up call"
|
|
)
|
|
|
|
follow_up_notes = models.TextField(
|
|
blank=True,
|
|
help_text="Notes from follow-up call"
|
|
)
|
|
|
|
follow_up_reminder_sent_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When reminder was sent for follow-up"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
verbose_name_plural = "Inquiries"
|
|
indexes = [
|
|
models.Index(fields=["status", "-created_at"]),
|
|
models.Index(fields=["hospital", "status"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.subject} ({self.status})"
|
|
|
|
|
|
class InquiryUpdate(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Inquiry update/timeline entry.
|
|
|
|
Tracks all updates, status changes, and communications for inquiries.
|
|
"""
|
|
|
|
inquiry = models.ForeignKey(Inquiry, on_delete=models.CASCADE, related_name="updates")
|
|
|
|
# Update details
|
|
update_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("status_change", "Status Change"),
|
|
("assignment", "Assignment"),
|
|
("note", "Note"),
|
|
("response", "Response"),
|
|
("communication", "Communication"),
|
|
],
|
|
db_index=True,
|
|
)
|
|
|
|
message = models.TextField()
|
|
|
|
# User who made the update
|
|
created_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="inquiry_updates"
|
|
)
|
|
|
|
# Status change tracking
|
|
old_status = models.CharField(max_length=20, blank=True)
|
|
new_status = models.CharField(max_length=20, blank=True)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["inquiry", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.inquiry} - {self.update_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
|
|
class InquiryAttachment(UUIDModel, TimeStampedModel):
|
|
"""Inquiry attachment (images, documents, etc.)"""
|
|
|
|
inquiry = models.ForeignKey(Inquiry, on_delete=models.CASCADE, related_name="attachments")
|
|
|
|
file = models.FileField(upload_to="inquiries/%Y/%m/%d/")
|
|
filename = models.CharField(max_length=500)
|
|
file_type = models.CharField(max_length=100, blank=True)
|
|
file_size = models.IntegerField(help_text="File size in bytes")
|
|
|
|
uploaded_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, related_name="inquiry_attachments"
|
|
)
|
|
|
|
description = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.inquiry} - {self.filename}"
|
|
|
|
|
|
class ComplaintExplanation(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Staff/recipient explanation about a complaint.
|
|
|
|
Allows staff members to submit their perspective via token-based link.
|
|
Each staff member can submit one explanation per complaint.
|
|
Includes SLA tracking for explanation submission deadlines.
|
|
"""
|
|
|
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="explanations")
|
|
|
|
staff = models.ForeignKey(
|
|
"organizations.Staff",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="complaint_explanations",
|
|
help_text="Staff member who submitted the explanation",
|
|
)
|
|
|
|
explanation = models.TextField(help_text="Staff's explanation about the complaint")
|
|
|
|
token = models.CharField(
|
|
max_length=64, unique=True, db_index=True, help_text="Unique access token for explanation submission"
|
|
)
|
|
|
|
is_used = models.BooleanField(
|
|
default=False, db_index=True, help_text="Token expiry tracking - becomes True after submission"
|
|
)
|
|
|
|
submitted_via = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("email_link", "Email Link"),
|
|
("direct", "Direct Entry"),
|
|
],
|
|
default="email_link",
|
|
help_text="How the explanation was submitted",
|
|
)
|
|
|
|
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When the explanation request email was sent")
|
|
|
|
responded_at = models.DateTimeField(null=True, blank=True, help_text="When the explanation was submitted")
|
|
|
|
# Request details
|
|
requested_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="requested_complaint_explanations",
|
|
help_text="User who requested the explanation",
|
|
)
|
|
|
|
request_message = models.TextField(blank=True, help_text="Optional message sent with the explanation request")
|
|
|
|
# SLA tracking for explanation requests
|
|
sla_due_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
db_index=True,
|
|
help_text="SLA deadline for staff to submit explanation"
|
|
)
|
|
|
|
is_overdue = models.BooleanField(
|
|
default=False,
|
|
db_index=True,
|
|
help_text="Explanation request is overdue"
|
|
)
|
|
|
|
reminder_sent_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Reminder sent to staff about overdue explanation"
|
|
)
|
|
|
|
escalated_to_manager = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='escalated_from_staff',
|
|
help_text="Escalated to this explanation (manager's explanation request)"
|
|
)
|
|
|
|
escalated_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When explanation was escalated to manager"
|
|
)
|
|
|
|
# Acceptance review fields
|
|
class AcceptanceStatus(models.TextChoices):
|
|
PENDING = "pending", "Pending Review"
|
|
ACCEPTABLE = "acceptable", "Acceptable"
|
|
NOT_ACCEPTABLE = "not_acceptable", "Not Acceptable"
|
|
|
|
acceptance_status = models.CharField(
|
|
max_length=20,
|
|
choices=AcceptanceStatus.choices,
|
|
default=AcceptanceStatus.PENDING,
|
|
help_text="Review status of the explanation"
|
|
)
|
|
|
|
accepted_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="reviewed_explanations",
|
|
help_text="User who reviewed and marked the explanation"
|
|
)
|
|
|
|
accepted_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When the explanation was reviewed"
|
|
)
|
|
|
|
acceptance_notes = models.TextField(
|
|
blank=True,
|
|
help_text="Notes about the acceptance decision"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
verbose_name = "Complaint Explanation"
|
|
verbose_name_plural = "Complaint Explanations"
|
|
indexes = [
|
|
models.Index(fields=["complaint", "-created_at"]),
|
|
models.Index(fields=["token", "is_used"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
staff_name = str(self.staff) if self.staff else "Unknown"
|
|
return f"{self.complaint} - {staff_name} - {'Submitted' if self.is_used else 'Pending'}"
|
|
|
|
@property
|
|
def can_submit(self):
|
|
"""Check if explanation can still be submitted"""
|
|
return not self.is_used
|
|
|
|
@property
|
|
def attachment_count(self):
|
|
"""Count of explanation attachments"""
|
|
return self.attachments.count()
|
|
|
|
def get_token(self):
|
|
"""Return the access token"""
|
|
return self.token
|
|
|
|
|
|
class ExplanationAttachment(UUIDModel, TimeStampedModel):
|
|
"""Attachment for complaint explanation"""
|
|
|
|
explanation = models.ForeignKey(ComplaintExplanation, on_delete=models.CASCADE, related_name="attachments")
|
|
|
|
file = models.FileField(upload_to="explanation_attachments/%Y/%m/%d/")
|
|
filename = models.CharField(max_length=500)
|
|
file_type = models.CharField(max_length=100, blank=True)
|
|
file_size = models.IntegerField(help_text="File size in bytes")
|
|
|
|
description = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
verbose_name = "Explanation Attachment"
|
|
verbose_name_plural = "Explanation Attachments"
|
|
|
|
def __str__(self):
|
|
return f"{self.explanation} - {self.filename}"
|
|
|
|
|
|
class ComplaintPRInteraction(UUIDModel, TimeStampedModel):
|
|
"""
|
|
PR (Patient Relations) contact with complainant.
|
|
|
|
Tracks when PR staff contact the complainant to take a formal statement
|
|
and explain the complaint procedure.
|
|
"""
|
|
|
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="pr_interactions")
|
|
|
|
contact_date = models.DateTimeField(
|
|
help_text="Date and time of PR contact with complainant"
|
|
)
|
|
|
|
contact_method = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
("phone", "Phone"),
|
|
("in_person", "In Person"),
|
|
("email", "Email"),
|
|
("other", "Other"),
|
|
],
|
|
default="in_person",
|
|
help_text="Method of contact"
|
|
)
|
|
|
|
pr_staff = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="pr_interactions",
|
|
help_text="PR staff member who made the contact"
|
|
)
|
|
|
|
statement_text = models.TextField(
|
|
blank=True,
|
|
help_text="Formal statement taken from the complainant"
|
|
)
|
|
|
|
procedure_explained = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether complaint procedure was explained to the complainant"
|
|
)
|
|
|
|
notes = models.TextField(
|
|
blank=True,
|
|
help_text="Additional notes from the PR interaction"
|
|
)
|
|
|
|
created_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="created_pr_interactions",
|
|
help_text="User who created this PR interaction record"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-contact_date"]
|
|
verbose_name = "PR Interaction"
|
|
verbose_name_plural = "PR Interactions"
|
|
indexes = [
|
|
models.Index(fields=["complaint", "-contact_date"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
method_display = self.get_contact_method_display()
|
|
return f"{self.complaint} - {method_display} - {self.contact_date.strftime('%Y-%m-%d')}"
|
|
|
|
|
|
class ComplaintMeeting(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Meeting record for management intervention.
|
|
|
|
Simple record of meetings scheduled/agreed upon between management
|
|
and the complainant to resolve complaints.
|
|
"""
|
|
|
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="meetings")
|
|
|
|
meeting_date = models.DateTimeField(
|
|
help_text="Date and time of the meeting"
|
|
)
|
|
|
|
meeting_type = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
("management_intervention", "Management Intervention"),
|
|
("pr_follow_up", "PR Follow-up"),
|
|
("department_review", "Department Review"),
|
|
("other", "Other"),
|
|
],
|
|
default="management_intervention",
|
|
help_text="Type of meeting"
|
|
)
|
|
|
|
outcome = models.TextField(
|
|
blank=True,
|
|
help_text="Meeting outcome and agreed resolution"
|
|
)
|
|
|
|
notes = models.TextField(
|
|
blank=True,
|
|
help_text="Additional meeting notes"
|
|
)
|
|
|
|
created_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="created_meetings",
|
|
help_text="User who created this meeting record"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-meeting_date"]
|
|
verbose_name = "Complaint Meeting"
|
|
verbose_name_plural = "Complaint Meetings"
|
|
indexes = [
|
|
models.Index(fields=["complaint", "-meeting_date"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
type_display = self.get_meeting_type_display()
|
|
return f"{self.complaint} - {type_display} - {self.meeting_date.strftime('%Y-%m-%d')}"
|
|
|
|
|
|
|
|
class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Tracks departments involved in a complaint.
|
|
|
|
Allows multiple departments to be associated with a single complaint
|
|
with specific roles (primary, secondary/supporting, coordination).
|
|
"""
|
|
|
|
class RoleChoices(models.TextChoices):
|
|
PRIMARY = "primary", "Primary Department"
|
|
SECONDARY = "secondary", "Secondary/Supporting"
|
|
COORDINATION = "coordination", "Coordination Only"
|
|
INVESTIGATING = "investigating", "Investigating"
|
|
|
|
complaint = models.ForeignKey(
|
|
Complaint,
|
|
on_delete=models.CASCADE,
|
|
related_name="involved_departments"
|
|
)
|
|
|
|
department = models.ForeignKey(
|
|
"organizations.Department",
|
|
on_delete=models.CASCADE,
|
|
related_name="complaint_involvements"
|
|
)
|
|
|
|
role = models.CharField(
|
|
max_length=20,
|
|
choices=RoleChoices.choices,
|
|
default=RoleChoices.SECONDARY,
|
|
help_text="Role of this department in the complaint resolution"
|
|
)
|
|
|
|
is_primary = models.BooleanField(
|
|
default=False,
|
|
help_text="Mark as the primary responsible department"
|
|
)
|
|
|
|
added_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="added_department_involvements"
|
|
)
|
|
|
|
notes = models.TextField(
|
|
blank=True,
|
|
help_text="Additional notes about this department's involvement"
|
|
)
|
|
|
|
# Assignment within this department
|
|
assigned_to = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="department_assigned_complaints",
|
|
help_text="User assigned from this department to handle the complaint"
|
|
)
|
|
|
|
assigned_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True
|
|
)
|
|
|
|
# Response tracking
|
|
response_submitted = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether this department has submitted their response"
|
|
)
|
|
|
|
response_submitted_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True
|
|
)
|
|
|
|
response_notes = models.TextField(
|
|
blank=True,
|
|
help_text="Department's response/feedback on the complaint"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-is_primary", "-created_at"]
|
|
verbose_name = "Complaint Involved Department"
|
|
verbose_name_plural = "Complaint Involved Departments"
|
|
unique_together = [["complaint", "department"]]
|
|
indexes = [
|
|
models.Index(fields=["complaint", "role"]),
|
|
models.Index(fields=["department", "response_submitted"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
role_display = self.get_role_display()
|
|
primary_flag = " [PRIMARY]" if self.is_primary else ""
|
|
return f"{self.complaint.reference_number} - {self.department.name} ({role_display}){primary_flag}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Ensure only one primary department per complaint"""
|
|
if self.is_primary:
|
|
# Clear primary flag from other departments for this complaint
|
|
ComplaintInvolvedDepartment.objects.filter(
|
|
complaint=self.complaint,
|
|
is_primary=True
|
|
).exclude(pk=self.pk).update(is_primary=False)
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Tracks staff members involved in a complaint.
|
|
|
|
Allows multiple staff to be associated with a single complaint
|
|
with specific roles (accused, witness, responsible, etc.).
|
|
"""
|
|
|
|
class RoleChoices(models.TextChoices):
|
|
ACCUSED = "accused", "Accused/Involved"
|
|
WITNESS = "witness", "Witness"
|
|
RESPONSIBLE = "responsible", "Responsible for Resolution"
|
|
INVESTIGATOR = "investigator", "Investigator"
|
|
SUPPORT = "support", "Support Staff"
|
|
COORDINATOR = "coordinator", "Coordinator"
|
|
|
|
complaint = models.ForeignKey(
|
|
Complaint,
|
|
on_delete=models.CASCADE,
|
|
related_name="involved_staff"
|
|
)
|
|
|
|
staff = models.ForeignKey(
|
|
"organizations.Staff",
|
|
on_delete=models.CASCADE,
|
|
related_name="complaint_involvements"
|
|
)
|
|
|
|
role = models.CharField(
|
|
max_length=20,
|
|
choices=RoleChoices.choices,
|
|
default=RoleChoices.ACCUSED,
|
|
help_text="Role of this staff member in the complaint"
|
|
)
|
|
|
|
added_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="added_staff_involvements"
|
|
)
|
|
|
|
notes = models.TextField(
|
|
blank=True,
|
|
help_text="Additional notes about this staff member's involvement"
|
|
)
|
|
|
|
# Explanation tracking
|
|
explanation_requested = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether an explanation has been requested from this staff"
|
|
)
|
|
|
|
explanation_requested_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True
|
|
)
|
|
|
|
explanation_received = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether an explanation has been received"
|
|
)
|
|
|
|
explanation_received_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True
|
|
)
|
|
|
|
explanation = models.TextField(
|
|
blank=True,
|
|
help_text="The staff member's explanation"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["role", "-created_at"]
|
|
verbose_name = "Complaint Involved Staff"
|
|
verbose_name_plural = "Complaint Involved Staff"
|
|
unique_together = [["complaint", "staff"]]
|
|
indexes = [
|
|
models.Index(fields=["complaint", "role"]),
|
|
models.Index(fields=["staff", "explanation_received"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
role_display = self.get_role_display()
|
|
return f"{self.complaint.reference_number} - {self.staff} ({role_display})"
|
|
|
|
|
|
class OnCallAdminSchedule(UUIDModel, TimeStampedModel):
|
|
"""
|
|
On-call admin schedule configuration for complaint notifications.
|
|
|
|
Manages which PX Admins should be notified outside of working hours.
|
|
During working hours, ALL PX Admins are notified.
|
|
Outside working hours, only ON-CALL admins are notified.
|
|
"""
|
|
|
|
# Working days configuration (stored as list of day numbers: 0=Monday, 6=Sunday)
|
|
working_days = models.JSONField(
|
|
default=list,
|
|
help_text="List of working days (0=Monday, 6=Sunday). Default: [0,1,2,3,4] (Mon-Fri)"
|
|
)
|
|
|
|
# Working hours
|
|
work_start_time = models.TimeField(
|
|
default="08:00",
|
|
help_text="Start of working hours (e.g., 08:00)"
|
|
)
|
|
work_end_time = models.TimeField(
|
|
default="17:00",
|
|
help_text="End of working hours (e.g., 17:00)"
|
|
)
|
|
|
|
# Timezone for the schedule
|
|
timezone = models.CharField(
|
|
max_length=50,
|
|
default="Asia/Riyadh",
|
|
help_text="Timezone for working hours calculation (e.g., Asia/Riyadh)"
|
|
)
|
|
|
|
# Whether this config is active
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text="Whether this on-call schedule is active"
|
|
)
|
|
|
|
# Hospital scope (null = system-wide)
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="on_call_schedules",
|
|
help_text="Hospital scope. Leave empty for system-wide configuration."
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
verbose_name = "On-Call Admin Schedule"
|
|
verbose_name_plural = "On-Call Admin Schedules"
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=['hospital'],
|
|
condition=models.Q(hospital__isnull=False),
|
|
name='unique_oncall_per_hospital'
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=['hospital'],
|
|
condition=models.Q(hospital__isnull=True),
|
|
name='unique_system_wide_oncall'
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
scope = f"{self.hospital.name}" if self.hospital else "System-wide"
|
|
start_time = self.work_start_time.strftime('%H:%M') if hasattr(self.work_start_time, 'strftime') else str(self.work_start_time)[:5]
|
|
end_time = self.work_end_time.strftime('%H:%M') if hasattr(self.work_end_time, 'strftime') else str(self.work_end_time)[:5]
|
|
return f"On-Call Schedule - {scope} ({start_time}-{end_time})"
|
|
|
|
def get_working_days_list(self):
|
|
"""Get list of working days, with default if empty"""
|
|
if self.working_days:
|
|
return self.working_days
|
|
return [0, 1, 2, 3, 4] # Default: Monday-Friday
|
|
|
|
def is_working_time(self, check_datetime=None):
|
|
"""
|
|
Check if the given datetime is within working hours.
|
|
|
|
Args:
|
|
check_datetime: datetime to check (default: now)
|
|
|
|
Returns:
|
|
bool: True if within working hours, False otherwise
|
|
"""
|
|
import pytz
|
|
|
|
if check_datetime is None:
|
|
check_datetime = timezone.now()
|
|
|
|
# Convert to schedule timezone
|
|
tz = pytz.timezone(self.timezone)
|
|
if timezone.is_aware(check_datetime):
|
|
local_time = check_datetime.astimezone(tz)
|
|
else:
|
|
local_time = check_datetime.replace(tzinfo=tz)
|
|
|
|
# Check if it's a working day
|
|
working_days = self.get_working_days_list()
|
|
if local_time.weekday() not in working_days:
|
|
return False
|
|
|
|
# Check if it's within working hours
|
|
current_time = local_time.time()
|
|
return self.work_start_time <= current_time < self.work_end_time
|
|
|
|
|
|
class OnCallAdmin(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Individual on-call admin assignment.
|
|
|
|
Links PX Admin users to an on-call schedule.
|
|
"""
|
|
|
|
schedule = models.ForeignKey(
|
|
OnCallAdminSchedule,
|
|
on_delete=models.CASCADE,
|
|
related_name="on_call_admins"
|
|
)
|
|
|
|
admin_user = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.CASCADE,
|
|
related_name="on_call_schedules",
|
|
help_text="PX Admin user who is on-call",
|
|
limit_choices_to={'groups__name': 'PX Admin'}
|
|
)
|
|
|
|
# Optional: date range for this on-call assignment
|
|
start_date = models.DateField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Start date for this on-call assignment (optional)"
|
|
)
|
|
|
|
end_date = models.DateField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="End date for this on-call assignment (optional)"
|
|
)
|
|
|
|
# Priority/order for notifications (lower = higher priority)
|
|
notification_priority = models.PositiveIntegerField(
|
|
default=1,
|
|
help_text="Priority for notifications (1 = highest)"
|
|
)
|
|
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text="Whether this on-call assignment is currently active"
|
|
)
|
|
|
|
# Contact preferences for out-of-hours
|
|
notify_email = models.BooleanField(
|
|
default=True,
|
|
help_text="Send email notifications"
|
|
)
|
|
notify_sms = models.BooleanField(
|
|
default=False,
|
|
help_text="Send SMS notifications"
|
|
)
|
|
|
|
# Custom phone for SMS (optional, uses user's phone if not set)
|
|
sms_phone = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
help_text="Custom phone number for SMS notifications (optional)"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["notification_priority", "-created_at"]
|
|
verbose_name = "On-Call Admin"
|
|
verbose_name_plural = "On-Call Admins"
|
|
unique_together = [["schedule", "admin_user"]]
|
|
|
|
def __str__(self):
|
|
return f"{self.admin_user.get_full_name() or self.admin_user.email} - On-Call ({self.schedule})"
|
|
|
|
def is_currently_active(self, check_date=None):
|
|
"""
|
|
Check if this on-call assignment is active for the given date.
|
|
|
|
Args:
|
|
check_date: date to check (default: today)
|
|
|
|
Returns:
|
|
bool: True if active, False otherwise
|
|
"""
|
|
if not self.is_active:
|
|
return False
|
|
|
|
if check_date is None:
|
|
check_date = timezone.now().date()
|
|
|
|
# Check date range
|
|
if self.start_date and check_date < self.start_date:
|
|
return False
|
|
if self.end_date and check_date > self.end_date:
|
|
return False
|
|
|
|
return True
|
|
|
|
def get_notification_phone(self):
|
|
"""Get phone number for SMS notifications"""
|
|
if self.sms_phone:
|
|
return self.sms_phone
|
|
if hasattr(self.admin_user, 'phone') and self.admin_user.phone:
|
|
return self.admin_user.phone
|
|
return None
|
|
|
|
|
|
class ComplaintAdverseAction(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Tracks adverse actions or damages to patients related to complaints.
|
|
|
|
This model helps identify and address retaliation or negative treatment
|
|
that patients may experience after filing a complaint.
|
|
|
|
Examples:
|
|
- Doctor refusing to see the patient in subsequent visits
|
|
- Delayed or denied treatment
|
|
- Verbal abuse or hostile behavior
|
|
- Increased wait times
|
|
- Unnecessary procedures
|
|
- Dismissal from care
|
|
"""
|
|
|
|
class ActionType(models.TextChoices):
|
|
"""Types of adverse actions"""
|
|
REFUSED_SERVICE = "refused_service", _("Refused Service")
|
|
DELAYED_TREATMENT = "delayed_treatment", _("Delayed Treatment")
|
|
VERBAL_ABUSE = "verbal_abuse", _("Verbal Abuse / Hostility")
|
|
INCREASED_WAIT = "increased_wait", _("Increased Wait Time")
|
|
UNNECESSARY_PROCEDURE = "unnecessary_procedure", _("Unnecessary Procedure")
|
|
DISMISSED_FROM_CARE = "dismissed_from_care", _("Dismissed from Care")
|
|
POOR_TREATMENT = "poor_treatment", _("Poor Treatment Quality")
|
|
DISCRIMINATION = "discrimination", _("Discrimination")
|
|
RETALIATION = "retaliation", _("Retaliation")
|
|
OTHER = "other", _("Other")
|
|
|
|
class SeverityLevel(models.TextChoices):
|
|
"""Severity levels for adverse actions"""
|
|
LOW = "low", _("Low - Minor inconvenience")
|
|
MEDIUM = "medium", _("Medium - Moderate impact")
|
|
HIGH = "high", _("High - Significant harm")
|
|
CRITICAL = "critical", _("Critical - Severe harm / Life-threatening")
|
|
|
|
class VerificationStatus(models.TextChoices):
|
|
"""Verification status of the adverse action report"""
|
|
REPORTED = "reported", _("Reported - Awaiting Review")
|
|
UNDER_INVESTIGATION = "under_investigation", _("Under Investigation")
|
|
VERIFIED = "verified", _("Verified")
|
|
UNFOUNDED = "unfounded", _("Unfounded")
|
|
RESOLVED = "resolved", _("Resolved")
|
|
|
|
# Link to complaint
|
|
complaint = models.ForeignKey(
|
|
Complaint,
|
|
on_delete=models.CASCADE,
|
|
related_name="adverse_actions",
|
|
help_text=_("The complaint this adverse action is related to")
|
|
)
|
|
|
|
# Action details
|
|
action_type = models.CharField(
|
|
max_length=30,
|
|
choices=ActionType.choices,
|
|
default=ActionType.OTHER,
|
|
help_text=_("Type of adverse action")
|
|
)
|
|
|
|
severity = models.CharField(
|
|
max_length=10,
|
|
choices=SeverityLevel.choices,
|
|
default=SeverityLevel.MEDIUM,
|
|
help_text=_("Severity level of the adverse action")
|
|
)
|
|
|
|
description = models.TextField(
|
|
help_text=_("Detailed description of what happened to the patient")
|
|
)
|
|
|
|
# When it occurred
|
|
incident_date = models.DateTimeField(
|
|
help_text=_("Date and time when the adverse action occurred")
|
|
)
|
|
|
|
# Location/Context
|
|
location = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
help_text=_("Location where the incident occurred (e.g., Emergency Room, Clinic B)")
|
|
)
|
|
|
|
# Staff involved
|
|
involved_staff = models.ManyToManyField(
|
|
"organizations.Staff",
|
|
blank=True,
|
|
related_name="adverse_actions_involved",
|
|
help_text=_("Staff members involved in the adverse action")
|
|
)
|
|
|
|
# Impact on patient
|
|
patient_impact = models.TextField(
|
|
blank=True,
|
|
help_text=_("Description of the impact on the patient (physical, emotional, financial)")
|
|
)
|
|
|
|
# Verification and handling
|
|
status = models.CharField(
|
|
max_length=30,
|
|
choices=VerificationStatus.choices,
|
|
default=VerificationStatus.REPORTED,
|
|
help_text=_("Current status of the adverse action report")
|
|
)
|
|
|
|
reported_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="reported_adverse_actions",
|
|
help_text=_("User who reported this adverse action")
|
|
)
|
|
|
|
# Investigation
|
|
investigation_notes = models.TextField(
|
|
blank=True,
|
|
help_text=_("Notes from the investigation")
|
|
)
|
|
|
|
investigated_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="investigated_adverse_actions",
|
|
help_text=_("User who investigated this adverse action")
|
|
)
|
|
|
|
investigated_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text=_("When the investigation was completed")
|
|
)
|
|
|
|
# Resolution
|
|
resolution = models.TextField(
|
|
blank=True,
|
|
help_text=_("How the adverse action was resolved")
|
|
)
|
|
|
|
resolved_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="resolved_adverse_actions",
|
|
help_text=_("User who resolved this adverse action")
|
|
)
|
|
|
|
resolved_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text=_("When the adverse action was resolved")
|
|
)
|
|
|
|
# Metadata
|
|
is_escalated = models.BooleanField(
|
|
default=False,
|
|
help_text=_("Whether this adverse action has been escalated to management")
|
|
)
|
|
|
|
escalated_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text=_("When the adverse action was escalated")
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-incident_date", "-created_at"]
|
|
verbose_name = _("Complaint Adverse Action")
|
|
verbose_name_plural = _("Complaint Adverse Actions")
|
|
indexes = [
|
|
models.Index(fields=["complaint", "-incident_date"]),
|
|
models.Index(fields=["action_type", "severity"]),
|
|
models.Index(fields=["status", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.complaint.reference_number} - {self.get_action_type_display()} ({self.get_severity_display()})"
|
|
|
|
@property
|
|
def is_high_severity(self):
|
|
"""Check if this is a high or critical severity adverse action"""
|
|
return self.severity in [self.SeverityLevel.HIGH, self.SeverityLevel.CRITICAL]
|
|
|
|
@property
|
|
def days_since_incident(self):
|
|
"""Calculate days since the incident occurred"""
|
|
from django.utils import timezone
|
|
if self.incident_date:
|
|
return (timezone.now() - self.incident_date).days
|
|
return None
|
|
|
|
@property
|
|
def requires_investigation(self):
|
|
"""Check if this adverse action requires investigation"""
|
|
return self.status in [self.VerificationStatus.REPORTED, self.VerificationStatus.UNDER_INVESTIGATION]
|
|
|
|
|
|
class ComplaintAdverseActionAttachment(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Attachments for adverse action reports (evidence, documents, etc.)
|
|
"""
|
|
adverse_action = models.ForeignKey(
|
|
ComplaintAdverseAction,
|
|
on_delete=models.CASCADE,
|
|
related_name="attachments"
|
|
)
|
|
|
|
file = models.FileField(
|
|
upload_to="complaints/adverse_actions/%Y/%m/%d/",
|
|
help_text=_("Attachment file (image, document, audio recording, etc.)")
|
|
)
|
|
|
|
filename = models.CharField(max_length=255)
|
|
file_type = models.CharField(max_length=100, blank=True)
|
|
file_size = models.IntegerField(help_text=_("File size in bytes"))
|
|
|
|
description = models.TextField(
|
|
blank=True,
|
|
help_text=_("Description of what this attachment shows")
|
|
)
|
|
|
|
uploaded_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name="adverse_action_attachments"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
verbose_name = _("Adverse Action Attachment")
|
|
verbose_name_plural = _("Adverse Action Attachments")
|
|
|
|
def __str__(self):
|
|
return f"{self.adverse_action} - {self.filename}"
|