""" 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}