2025-12-24 12:42:31 +03:00

440 lines
12 KiB
Python

"""
PX Action Center models - Action tracking with SLA and escalation
This module implements the PX Action Center that:
- Creates actions automatically from negative feedback
- Tracks SLA deadlines
- Sends reminders before due date
- Escalates overdue actions
- Manages approval workflow
- Tracks evidence and attachments
"""
from datetime import timedelta
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
from apps.core.models import PriorityChoices, SeverityChoices, TimeStampedModel, UUIDModel
class ActionStatus(models.TextChoices):
"""PX Action status choices"""
OPEN = 'open', 'Open'
IN_PROGRESS = 'in_progress', 'In Progress'
PENDING_APPROVAL = 'pending_approval', 'Pending Approval'
APPROVED = 'approved', 'Approved'
CLOSED = 'closed', 'Closed'
CANCELLED = 'cancelled', 'Cancelled'
class ActionSource(models.TextChoices):
"""PX Action source choices"""
SURVEY = 'survey', 'Negative Survey'
COMPLAINT = 'complaint', 'Complaint'
COMPLAINT_RESOLUTION = 'complaint_resolution', 'Negative Complaint Resolution'
SOCIAL_MEDIA = 'social_media', 'Social Media'
CALL_CENTER = 'call_center', 'Call Center'
KPI = 'kpi', 'KPI Decline'
MANUAL = 'manual', 'Manual'
class PXAction(UUIDModel, TimeStampedModel):
"""
PX Action - tracks improvement actions with SLA.
Actions are created automatically from:
- Negative stage survey scores
- Negative complaint resolution satisfaction
- Complaints (if configured)
- Negative social media sentiment
- Low call center ratings
- KPI declines
Workflow:
1. OPEN - Action created
2. IN_PROGRESS - Being worked on
3. PENDING_APPROVAL - Awaiting PX approval
4. APPROVED - Approved by PX
5. CLOSED - Completed
"""
# Source information (generic foreign key)
source_type = models.CharField(
max_length=50,
choices=ActionSource.choices,
db_index=True
)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
null=True,
blank=True
)
object_id = models.UUIDField(null=True, blank=True)
source_object = GenericForeignKey('content_type', 'object_id')
# Action details
title = models.CharField(max_length=500)
description = models.TextField()
# Organization
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
related_name='px_actions'
)
department = models.ForeignKey(
'organizations.Department',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='px_actions'
)
# Classification
category = models.CharField(
max_length=100,
choices=[
('clinical_quality', 'Clinical Quality'),
('patient_safety', 'Patient Safety'),
('service_quality', 'Service Quality'),
('staff_behavior', 'Staff Behavior'),
('facility', 'Facility & Environment'),
('process_improvement', 'Process Improvement'),
('other', 'Other'),
],
db_index=True
)
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
)
# Status and workflow
status = models.CharField(
max_length=20,
choices=ActionStatus.choices,
default=ActionStatus.OPEN,
db_index=True
)
# Assignment
assigned_to = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='assigned_actions'
)
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)
escalation_level = models.IntegerField(
default=0,
help_text="Number of times escalated"
)
# Approval
requires_approval = models.BooleanField(
default=True,
help_text="Requires PX Admin approval before closure"
)
approved_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='approved_actions'
)
approved_at = models.DateTimeField(null=True, blank=True)
# 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_actions'
)
# Action plan and outcome
action_plan = models.TextField(blank=True)
outcome = models.TextField(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']),
models.Index(fields=['source_type', '-created_at']),
]
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"""
sla_hours = settings.SLA_DEFAULTS['action'].get(
self.severity,
settings.SLA_DEFAULTS['action']['medium']
)
return timezone.now() + timedelta(hours=sla_hours)
def check_overdue(self):
"""Check if action is overdue"""
if self.status in [ActionStatus.CLOSED, ActionStatus.CANCELLED]:
return False
if timezone.now() > self.due_at:
if not self.is_overdue:
self.is_overdue = True
self.save(update_fields=['is_overdue'])
return True
return False
class PXActionLog(UUIDModel, TimeStampedModel):
"""
Action log entry - tracks updates and discussions.
"""
action = models.ForeignKey(
PXAction,
on_delete=models.CASCADE,
related_name='logs'
)
log_type = models.CharField(
max_length=50,
choices=[
('status_change', 'Status Change'),
('assignment', 'Assignment'),
('escalation', 'Escalation'),
('note', 'Note'),
('evidence', 'Evidence Added'),
('approval', 'Approval'),
('sla_reminder', 'SLA Reminder'),
],
db_index=True
)
message = models.TextField()
created_by = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
related_name='action_logs'
)
# Status change tracking
old_status = models.CharField(max_length=20, blank=True)
new_status = models.CharField(max_length=20, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['action', '-created_at']),
]
def __str__(self):
return f"{self.action} - {self.log_type} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
class PXActionAttachment(UUIDModel, TimeStampedModel):
"""Action attachment - evidence, documents, etc."""
action = models.ForeignKey(
PXAction,
on_delete=models.CASCADE,
related_name='attachments'
)
file = models.FileField(upload_to='actions/%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='action_attachments'
)
description = models.TextField(blank=True)
is_evidence = models.BooleanField(
default=False,
help_text="Mark as evidence for closure"
)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.action} - {self.filename}"
class PXActionSLAConfig(UUIDModel, TimeStampedModel):
"""
SLA configuration for PX Actions.
Allows per-hospital or per-department SLA customization.
"""
name = models.CharField(max_length=200)
# Scope
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='action_sla_configs'
)
department = models.ForeignKey(
'organizations.Department',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='action_sla_configs'
)
# SLA durations (in hours) by severity
critical_hours = models.IntegerField(default=24)
high_hours = models.IntegerField(default=48)
medium_hours = models.IntegerField(default=72)
low_hours = models.IntegerField(default=120)
# Reminder configuration
reminder_hours_before = models.IntegerField(
default=4,
help_text="Send reminder X hours before due"
)
# Escalation configuration
auto_escalate = models.BooleanField(
default=True,
help_text="Automatically escalate when overdue"
)
escalation_delay_hours = models.IntegerField(
default=2,
help_text="Hours after overdue before escalation"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['hospital', 'name']
def __str__(self):
scope = self.hospital.name if self.hospital else "Global"
return f"{self.name} ({scope})"
class RoutingRule(UUIDModel, TimeStampedModel):
"""
Routing rule - determines where actions are assigned.
Rules can be based on:
- Source type
- Category
- Severity
- Hospital/Department
"""
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# Conditions
source_type = models.CharField(
max_length=50,
choices=ActionSource.choices,
blank=True
)
category = models.CharField(max_length=100, blank=True)
severity = models.CharField(
max_length=20,
choices=SeverityChoices.choices,
blank=True
)
hospital = models.ForeignKey(
'organizations.Hospital',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='routing_rules'
)
department = models.ForeignKey(
'organizations.Department',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='routing_rules'
)
# Routing target
assign_to_role = models.CharField(
max_length=50,
blank=True,
help_text="Role to assign to (e.g., 'PX Coordinator')"
)
assign_to_user = models.ForeignKey(
'accounts.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='routing_rules'
)
assign_to_department = models.ForeignKey(
'organizations.Department',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='routing_target_rules',
help_text="Department to assign to"
)
# Priority
priority = models.IntegerField(
default=0,
help_text="Higher priority rules are evaluated first"
)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['-priority', 'name']
def __str__(self):
return self.name