358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""
|
|
PX Action Center Celery tasks
|
|
|
|
This module contains tasks for:
|
|
- Checking overdue actions
|
|
- Sending SLA reminders
|
|
- Escalating overdue actions
|
|
- Creating actions from negative feedback
|
|
"""
|
|
import logging
|
|
|
|
from celery import shared_task
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@shared_task
|
|
def check_overdue_actions():
|
|
"""
|
|
Periodic task to check for overdue actions.
|
|
|
|
Runs every 15 minutes (configured in config/celery.py).
|
|
Updates is_overdue flag and triggers escalation if configured.
|
|
"""
|
|
from apps.px_action_center.models import ActionStatus, PXAction
|
|
|
|
# Get active actions (not closed or cancelled)
|
|
active_actions = PXAction.objects.filter(
|
|
status__in=[ActionStatus.OPEN, ActionStatus.IN_PROGRESS, ActionStatus.PENDING_APPROVAL]
|
|
).select_related('hospital', 'assigned_to')
|
|
|
|
overdue_count = 0
|
|
escalated_count = 0
|
|
|
|
for action in active_actions:
|
|
if action.check_overdue():
|
|
overdue_count += 1
|
|
logger.warning(
|
|
f"Action {action.id} is overdue: {action.title} "
|
|
f"(due: {action.due_at})"
|
|
)
|
|
|
|
# Trigger escalation
|
|
escalate_action.delay(str(action.id))
|
|
escalated_count += 1
|
|
|
|
if overdue_count > 0:
|
|
logger.info(f"Found {overdue_count} overdue actions, escalated {escalated_count}")
|
|
|
|
return {'overdue_count': overdue_count, 'escalated_count': escalated_count}
|
|
|
|
|
|
@shared_task
|
|
def send_sla_reminders():
|
|
"""
|
|
Periodic task to send SLA reminders.
|
|
|
|
Runs every hour (configured in config/celery.py).
|
|
Sends reminders for actions approaching their SLA deadline.
|
|
"""
|
|
from apps.px_action_center.models import ActionStatus, PXAction
|
|
|
|
# Get active actions without reminders sent
|
|
active_actions = PXAction.objects.filter(
|
|
status__in=[ActionStatus.OPEN, ActionStatus.IN_PROGRESS],
|
|
reminder_sent_at__isnull=True
|
|
).select_related('hospital', 'assigned_to')
|
|
|
|
reminder_count = 0
|
|
|
|
for action in active_actions:
|
|
# Check if within reminder window (4 hours before due by default)
|
|
time_until_due = action.due_at - timezone.now()
|
|
hours_until_due = time_until_due.total_seconds() / 3600
|
|
|
|
if 0 < hours_until_due <= 4: # Within 4 hours of due date
|
|
# Send reminder
|
|
if action.assigned_to and action.assigned_to.email:
|
|
from apps.notifications.services import send_email
|
|
send_email(
|
|
email=action.assigned_to.email,
|
|
subject=f"SLA Reminder: Action Due Soon - {action.title}",
|
|
message=f"Action {action.title} is due in {int(hours_until_due)} hours.",
|
|
related_object=action,
|
|
metadata={'action_id': str(action.id)}
|
|
)
|
|
|
|
# Mark reminder sent
|
|
action.reminder_sent_at = timezone.now()
|
|
action.save(update_fields=['reminder_sent_at'])
|
|
|
|
# Log reminder
|
|
from apps.px_action_center.models import PXActionLog
|
|
PXActionLog.objects.create(
|
|
action=action,
|
|
log_type='sla_reminder',
|
|
message=f"SLA reminder sent - due in {int(hours_until_due)} hours"
|
|
)
|
|
|
|
reminder_count += 1
|
|
logger.info(f"SLA reminder sent for action {action.id}")
|
|
|
|
if reminder_count > 0:
|
|
logger.info(f"Sent {reminder_count} SLA reminders")
|
|
|
|
return {'reminder_count': reminder_count}
|
|
|
|
|
|
@shared_task
|
|
def escalate_action(action_id):
|
|
"""
|
|
Escalate an overdue action.
|
|
|
|
Escalation logic:
|
|
1. Increment escalation level
|
|
2. Reassign to higher level (department manager → hospital admin → PX admin)
|
|
3. Log escalation
|
|
4. Send notification
|
|
|
|
Args:
|
|
action_id: UUID of the PXAction
|
|
|
|
Returns:
|
|
dict: Result with escalation details
|
|
"""
|
|
from apps.px_action_center.models import PXAction, PXActionLog
|
|
|
|
try:
|
|
action = PXAction.objects.select_related(
|
|
'hospital', 'department', 'assigned_to'
|
|
).get(id=action_id)
|
|
|
|
# Check if already escalated recently
|
|
if action.escalated_at:
|
|
hours_since_escalation = (timezone.now() - action.escalated_at).total_seconds() / 3600
|
|
if hours_since_escalation < 2: # Don't escalate more than once every 2 hours
|
|
return {'status': 'skipped', 'reason': 'recently_escalated'}
|
|
|
|
# Increment escalation level
|
|
action.escalation_level += 1
|
|
action.escalated_at = timezone.now()
|
|
|
|
# Determine escalation target based on current assignment
|
|
old_assignee = action.assigned_to
|
|
new_assignee = None
|
|
|
|
# Simple escalation logic: assign to department manager or hospital admin
|
|
if action.department and action.department.manager:
|
|
new_assignee = action.department.manager
|
|
elif action.hospital:
|
|
# Find hospital admin for this hospital
|
|
from apps.accounts.models import User
|
|
hospital_admins = User.objects.filter(
|
|
hospital=action.hospital,
|
|
groups__name='Hospital Admin'
|
|
).first()
|
|
if hospital_admins:
|
|
new_assignee = hospital_admins
|
|
|
|
if new_assignee and new_assignee != old_assignee:
|
|
action.assigned_to = new_assignee
|
|
action.assigned_at = timezone.now()
|
|
|
|
action.save()
|
|
|
|
# Log escalation
|
|
PXActionLog.objects.create(
|
|
action=action,
|
|
log_type='escalation',
|
|
message=f"Action escalated (level {action.escalation_level}). Assigned to {new_assignee.get_full_name() if new_assignee else 'unassigned'}",
|
|
metadata={
|
|
'escalation_level': action.escalation_level,
|
|
'old_assignee': str(old_assignee.id) if old_assignee else None,
|
|
'new_assignee': str(new_assignee.id) if new_assignee else None
|
|
}
|
|
)
|
|
|
|
# Send notification
|
|
if new_assignee and new_assignee.email:
|
|
from apps.notifications.services import send_email
|
|
send_email(
|
|
email=new_assignee.email,
|
|
subject=f"Escalated Action Assigned: {action.title}",
|
|
message=f"An overdue action has been escalated to you: {action.title}",
|
|
related_object=action
|
|
)
|
|
|
|
logger.info(
|
|
f"Action {action.id} escalated to level {action.escalation_level}, "
|
|
f"assigned to {new_assignee.get_full_name() if new_assignee else 'unassigned'}"
|
|
)
|
|
|
|
return {
|
|
'status': 'escalated',
|
|
'escalation_level': action.escalation_level,
|
|
'assigned_to': new_assignee.get_full_name() if new_assignee else None
|
|
}
|
|
|
|
except PXAction.DoesNotExist:
|
|
error_msg = f"Action {action_id} not found"
|
|
logger.error(error_msg)
|
|
return {'status': 'error', 'reason': error_msg}
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error escalating action: {str(e)}"
|
|
logger.error(error_msg, exc_info=True)
|
|
return {'status': 'error', 'reason': error_msg}
|
|
|
|
|
|
@shared_task
|
|
def create_action_from_survey(survey_instance_id):
|
|
"""
|
|
Create PX Action from negative survey.
|
|
|
|
Args:
|
|
survey_instance_id: UUID of SurveyInstance with negative score
|
|
|
|
Returns:
|
|
dict: Result with action_id
|
|
"""
|
|
from apps.core.services import create_audit_log
|
|
from apps.px_action_center.models import PXAction
|
|
from apps.surveys.models import SurveyInstance
|
|
|
|
try:
|
|
survey = SurveyInstance.objects.select_related(
|
|
'survey_template', 'patient', 'journey_instance__hospital',
|
|
'journey_stage_instance__department'
|
|
).get(id=survey_instance_id)
|
|
|
|
# Verify it's negative
|
|
if not survey.is_negative:
|
|
return {'status': 'skipped', 'reason': 'not_negative'}
|
|
|
|
# Create action
|
|
action = PXAction.objects.create(
|
|
source_type='survey',
|
|
content_object=survey,
|
|
title=f"Negative Survey: {survey.survey_template.name} - {survey.patient.get_full_name()}",
|
|
description=f"Patient {survey.patient.get_full_name()} gave a negative rating ({survey.total_score}) on {survey.survey_template.name}",
|
|
hospital=survey.journey_instance.hospital if survey.journey_instance else survey.survey_template.hospital,
|
|
department=survey.journey_stage_instance.department if survey.journey_stage_instance else None,
|
|
category='service_quality',
|
|
severity='medium',
|
|
priority='medium',
|
|
metadata={
|
|
'survey_id': str(survey.id),
|
|
'score': float(survey.total_score) if survey.total_score else None,
|
|
'survey_template': survey.survey_template.name
|
|
}
|
|
)
|
|
|
|
# Log action creation
|
|
create_audit_log(
|
|
event_type='action_created',
|
|
description=f"PX Action created from negative survey: {action.title}",
|
|
content_object=action,
|
|
metadata={
|
|
'source': 'survey',
|
|
'survey_id': str(survey.id),
|
|
'score': float(survey.total_score) if survey.total_score else None
|
|
}
|
|
)
|
|
|
|
logger.info(f"PX Action created from negative survey {survey.id}: {action.id}")
|
|
|
|
return {
|
|
'status': 'created',
|
|
'action_id': str(action.id)
|
|
}
|
|
|
|
except SurveyInstance.DoesNotExist:
|
|
error_msg = f"Survey instance {survey_instance_id} not found"
|
|
logger.error(error_msg)
|
|
return {'status': 'error', 'reason': error_msg}
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error creating action from survey: {str(e)}"
|
|
logger.error(error_msg, exc_info=True)
|
|
return {'status': 'error', 'reason': error_msg}
|
|
|
|
|
|
@shared_task
|
|
def create_action_from_complaint_resolution(survey_instance_id):
|
|
"""
|
|
Create PX Action from negative complaint resolution satisfaction.
|
|
|
|
Args:
|
|
survey_instance_id: UUID of complaint resolution SurveyInstance
|
|
|
|
Returns:
|
|
dict: Result with action_id
|
|
"""
|
|
from apps.core.services import create_audit_log
|
|
from apps.px_action_center.models import PXAction
|
|
from apps.surveys.models import SurveyInstance
|
|
|
|
try:
|
|
survey = SurveyInstance.objects.select_related(
|
|
'survey_template', 'patient'
|
|
).get(id=survey_instance_id)
|
|
|
|
# Verify it's negative
|
|
if not survey.is_negative:
|
|
return {'status': 'skipped', 'reason': 'not_negative'}
|
|
|
|
# Get related complaint
|
|
complaint = survey.complaint_resolution.first() if hasattr(survey, 'complaint_resolution') else None
|
|
|
|
# Create action
|
|
action = PXAction.objects.create(
|
|
source_type='complaint_resolution',
|
|
content_object=survey,
|
|
title=f"Dissatisfied with Complaint Resolution - {survey.patient.get_full_name()}",
|
|
description=f"Patient dissatisfied with complaint resolution (score: {survey.total_score})",
|
|
hospital=complaint.hospital if complaint else survey.survey_template.hospital,
|
|
department=complaint.department if complaint else None,
|
|
category='service_quality',
|
|
severity='high', # Complaint resolution dissatisfaction is high priority
|
|
priority='high',
|
|
metadata={
|
|
'survey_id': str(survey.id),
|
|
'complaint_id': str(complaint.id) if complaint else None,
|
|
'score': float(survey.total_score) if survey.total_score else None
|
|
}
|
|
)
|
|
|
|
# Log action creation
|
|
create_audit_log(
|
|
event_type='action_created',
|
|
description=f"PX Action created from negative complaint resolution: {action.title}",
|
|
content_object=action,
|
|
metadata={
|
|
'source': 'complaint_resolution',
|
|
'survey_id': str(survey.id),
|
|
'score': float(survey.total_score) if survey.total_score else None
|
|
}
|
|
)
|
|
|
|
logger.info(f"PX Action created from negative complaint resolution {survey.id}: {action.id}")
|
|
|
|
return {
|
|
'status': 'created',
|
|
'action_id': str(action.id)
|
|
}
|
|
|
|
except SurveyInstance.DoesNotExist:
|
|
error_msg = f"Survey instance {survey_instance_id} not found"
|
|
logger.error(error_msg)
|
|
return {'status': 'error', 'reason': error_msg}
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error creating action from complaint resolution: {str(e)}"
|
|
logger.error(error_msg, exc_info=True)
|
|
return {'status': 'error', 'reason': error_msg}
|