440 lines
12 KiB
Python
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
|