352 lines
12 KiB
Python
352 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 Entry"
|
|
|
|
# Meeting sources
|
|
PATIENT_FAMILY_COMMITTEE = "patient_family_committee", "Patient & Family Rights Committee Meeting"
|
|
EXECUTIVE_COMMITTEE = "executive_committee", "Executive Committee Meeting"
|
|
DEPARTMENT_MEETING = "department_meeting", "Department Meeting"
|
|
ROUNDS = "rounds", "Ward/Department Rounds"
|
|
STAFF_FEEDBACK = "staff_feedback", "Staff Feedback/Comments"
|
|
PATIENT_OBSERVATION = "patient_observation", "Patient Observation"
|
|
QUALITY_AUDIT = "quality_audit", "Quality Audit"
|
|
MANAGEMENT_REVIEW = "management_review", "Management Review"
|
|
|
|
|
|
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)
|
|
|
|
# Rejection
|
|
rejected_by = models.ForeignKey(
|
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="rejected_actions"
|
|
)
|
|
rejected_at = models.DateTimeField(null=True, blank=True)
|
|
rejection_reason = models.TextField(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=["assigned_to", "status", "-created_at"]),
|
|
models.Index(fields=["department", "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 Employee')")
|
|
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
|