""" Complaints Celery tasks This module contains tasks for: - Checking overdue complaints - Sending SLA reminders - Triggering resolution satisfaction surveys - Creating PX actions from complaints """ import logging from celery import shared_task from django.db import transaction from django.db.models import Q from django.utils import timezone logger = logging.getLogger(__name__) @shared_task def check_overdue_complaints(): """ Periodic task to check for overdue complaints. Runs every 15 minutes (configured in config/celery.py). Updates is_overdue flag for complaints past their SLA deadline. Triggers automatic escalation based on escalation rules. """ from apps.complaints.models import Complaint, ComplaintStatus # Get active complaints (not closed or cancelled) active_complaints = Complaint.objects.filter( status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED] ).select_related('hospital', 'patient', 'department') overdue_count = 0 escalated_count = 0 for complaint in active_complaints: if complaint.check_overdue(): overdue_count += 1 logger.warning( f"Complaint {complaint.id} is overdue: {complaint.title} " f"(due: {complaint.due_at})" ) # Trigger automatic escalation result = escalate_complaint_auto.delay(str(complaint.id)) if result: escalated_count += 1 if overdue_count > 0: logger.info(f"Found {overdue_count} overdue complaints, triggered {escalated_count} escalations") return { 'overdue_count': overdue_count, 'escalated_count': escalated_count } @shared_task def send_complaint_resolution_survey(complaint_id): """ Send resolution satisfaction survey when complaint is closed. This task is triggered when a complaint status changes to CLOSED. Args: complaint_id: UUID of the Complaint Returns: dict: Result with survey_instance_id """ from apps.complaints.models import Complaint from apps.core.services import create_audit_log from apps.surveys.models import SurveyInstance, SurveyTemplate try: complaint = Complaint.objects.select_related( 'patient', 'hospital' ).get(id=complaint_id) # Check if survey already sent if complaint.resolution_survey: logger.info(f"Resolution survey already sent for complaint {complaint_id}") return {'status': 'skipped', 'reason': 'already_sent'} # Get resolution satisfaction survey template try: survey_template = SurveyTemplate.objects.get( hospital=complaint.hospital, survey_type='complaint_resolution', is_active=True ) except SurveyTemplate.DoesNotExist: logger.warning( f"No resolution satisfaction survey template found for hospital {complaint.hospital.name}" ) return {'status': 'skipped', 'reason': 'no_template'} # Create survey instance with transaction.atomic(): survey_instance = SurveyInstance.objects.create( survey_template=survey_template, patient=complaint.patient, encounter_id=complaint.encounter_id, delivery_channel='sms', # Default recipient_phone=complaint.patient.phone, recipient_email=complaint.patient.email, metadata={ 'complaint_id': str(complaint.id), 'complaint_title': complaint.title } ) # Link survey to complaint complaint.resolution_survey = survey_instance complaint.resolution_survey_sent_at = timezone.now() complaint.save(update_fields=['resolution_survey', 'resolution_survey_sent_at']) # Send survey from apps.notifications.services import NotificationService notification_log = NotificationService.send_survey_invitation( survey_instance=survey_instance, language='en' # TODO: Get from patient preference ) # Update survey status survey_instance.status = 'active' survey_instance.sent_at = timezone.now() survey_instance.save(update_fields=['status', 'sent_at']) # Log audit event create_audit_log( event_type='survey_sent', description=f"Resolution satisfaction survey sent for complaint: {complaint.title}", content_object=survey_instance, metadata={ 'complaint_id': str(complaint.id), 'survey_template': survey_template.name } ) logger.info( f"Resolution satisfaction survey sent for complaint {complaint.id}" ) return { 'status': 'sent', 'survey_instance_id': str(survey_instance.id), 'notification_log_id': str(notification_log.id) } except Complaint.DoesNotExist: error_msg = f"Complaint {complaint_id} not found" logger.error(error_msg) return {'status': 'error', 'reason': error_msg} except Exception as e: error_msg = f"Error sending resolution survey: {str(e)}" logger.error(error_msg, exc_info=True) return {'status': 'error', 'reason': error_msg} @shared_task def check_resolution_survey_threshold(survey_instance_id, complaint_id): """ Check if resolution survey score breaches threshold and create PX Action if needed. This task is triggered when a complaint resolution survey is completed. Args: survey_instance_id: UUID of the SurveyInstance complaint_id: UUID of the Complaint Returns: dict: Result with action status """ from apps.complaints.models import Complaint, ComplaintThreshold from apps.surveys.models import SurveyInstance from apps.px_action_center.models import PXAction from django.contrib.contenttypes.models import ContentType try: survey = SurveyInstance.objects.get(id=survey_instance_id) complaint = Complaint.objects.select_related('hospital', 'patient').get(id=complaint_id) # Get threshold for this hospital try: threshold = ComplaintThreshold.objects.get( hospital=complaint.hospital, threshold_type='resolution_survey_score', is_active=True ) except ComplaintThreshold.DoesNotExist: logger.info(f"No resolution survey threshold configured for hospital {complaint.hospital.name_en}") return {'status': 'no_threshold'} # Check if threshold is breached if threshold.check_threshold(survey.score): logger.warning( f"Resolution survey score {survey.score} breaches threshold {threshold.threshold_value} " f"for complaint {complaint_id}" ) # Create PX Action complaint_ct = ContentType.objects.get_for_model(Complaint) action = PXAction.objects.create( title=f"Low Resolution Satisfaction: {complaint.title[:100]}", description=( f"Complaint resolution survey scored {survey.score}% " f"(threshold: {threshold.threshold_value}%). " f"Original complaint: {complaint.description[:200]}" ), source='complaint_resolution_survey', priority='high' if survey.score < 30 else 'medium', hospital=complaint.hospital, department=complaint.department, patient=complaint.patient, content_type=complaint_ct, object_id=complaint.id, metadata={ 'complaint_id': str(complaint.id), 'survey_id': str(survey.id), 'survey_score': survey.score, 'threshold_value': threshold.threshold_value, } ) # Log audit from apps.core.services import create_audit_log create_audit_log( event_type='px_action_created', description=f"PX Action created from low resolution survey score", content_object=action, metadata={ 'complaint_id': str(complaint.id), 'survey_score': survey.score, 'trigger': 'resolution_survey_threshold' } ) logger.info(f"Created PX Action {action.id} from low resolution survey score") return { 'status': 'action_created', 'action_id': str(action.id), 'survey_score': survey.score, 'threshold': threshold.threshold_value } else: logger.info(f"Resolution survey score {survey.score} is above threshold {threshold.threshold_value}") return {'status': 'threshold_not_breached', 'survey_score': survey.score} except SurveyInstance.DoesNotExist: error_msg = f"SurveyInstance {survey_instance_id} not found" logger.error(error_msg) return {'status': 'error', 'reason': error_msg} except Complaint.DoesNotExist: error_msg = f"Complaint {complaint_id} not found" logger.error(error_msg) return {'status': 'error', 'reason': error_msg} except Exception as e: error_msg = f"Error checking resolution survey threshold: {str(e)}" logger.error(error_msg, exc_info=True) return {'status': 'error', 'reason': error_msg} @shared_task def create_action_from_complaint(complaint_id): """ Create PX Action from complaint (if configured). This task is triggered when a complaint is created, if the hospital configuration requires automatic action creation. Args: complaint_id: UUID of the Complaint Returns: dict: Result with action_id """ from apps.complaints.models import Complaint from apps.organizations.models import Hospital from apps.px_action_center.models import PXAction from django.contrib.contenttypes.models import ContentType try: complaint = Complaint.objects.select_related('hospital', 'patient', 'department').get(id=complaint_id) # Check if hospital has auto-create enabled # For now, we'll check metadata on hospital or use a simple rule # In production, you'd have a HospitalComplaintConfig model auto_create = complaint.hospital.metadata.get('auto_create_action_on_complaint', False) if not auto_create: logger.info(f"Auto-create PX Action disabled for hospital {complaint.hospital.name_en}") return {'status': 'disabled'} # Create PX Action complaint_ct = ContentType.objects.get_for_model(Complaint) action = PXAction.objects.create( title=f"New Complaint: {complaint.title[:100]}", description=complaint.description[:500], source='complaint', priority=complaint.priority, hospital=complaint.hospital, department=complaint.department, patient=complaint.patient, content_type=complaint_ct, object_id=complaint.id, metadata={ 'complaint_id': str(complaint.id), 'complaint_category': complaint.category, 'complaint_severity': complaint.severity, } ) # Log audit from apps.core.services import create_audit_log create_audit_log( event_type='px_action_created', description=f"PX Action created from complaint", content_object=action, metadata={ 'complaint_id': str(complaint.id), 'trigger': 'complaint_creation' } ) logger.info(f"Created PX Action {action.id} from complaint {complaint_id}") return { 'status': 'action_created', 'action_id': str(action.id) } except Complaint.DoesNotExist: error_msg = f"Complaint {complaint_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: {str(e)}" logger.error(error_msg, exc_info=True) return {'status': 'error', 'reason': error_msg} @shared_task def escalate_complaint_auto(complaint_id): """ Automatically escalate complaint based on escalation rules. This task is triggered when a complaint becomes overdue. It finds matching escalation rules and reassigns the complaint. Args: complaint_id: UUID of the Complaint Returns: dict: Result with escalation status """ from apps.complaints.models import Complaint, ComplaintUpdate, EscalationRule from apps.accounts.models import User try: complaint = Complaint.objects.select_related( 'hospital', 'department', 'assigned_to' ).get(id=complaint_id) # Calculate hours overdue hours_overdue = (timezone.now() - complaint.due_at).total_seconds() / 3600 # Get applicable escalation rules for this hospital rules = EscalationRule.objects.filter( hospital=complaint.hospital, is_active=True, trigger_on_overdue=True ).order_by('order') # Filter rules by severity and priority if specified if complaint.severity: rules = rules.filter( Q(severity_filter='') | Q(severity_filter=complaint.severity) ) if complaint.priority: rules = rules.filter( Q(priority_filter='') | Q(priority_filter=complaint.priority) ) # Find first matching rule based on hours overdue matching_rule = None for rule in rules: if hours_overdue >= rule.trigger_hours_overdue: matching_rule = rule break if not matching_rule: logger.info(f"No matching escalation rule found for complaint {complaint_id}") return {'status': 'no_matching_rule'} # Determine escalation target escalation_target = None if matching_rule.escalate_to_role == 'department_manager': if complaint.department and complaint.department.manager: escalation_target = complaint.department.manager elif matching_rule.escalate_to_role == 'hospital_admin': # Find hospital admin for this hospital escalation_target = User.objects.filter( hospital=complaint.hospital, groups__name='Hospital Admin', is_active=True ).first() elif matching_rule.escalate_to_role == 'px_admin': # Find PX admin escalation_target = User.objects.filter( groups__name='PX Admin', is_active=True ).first() elif matching_rule.escalate_to_role == 'specific_user': escalation_target = matching_rule.escalate_to_user if not escalation_target: logger.warning( f"Could not find escalation target for rule {matching_rule.name} " f"on complaint {complaint_id}" ) return {'status': 'no_target_found', 'rule': matching_rule.name} # Perform escalation old_assignee = complaint.assigned_to complaint.assigned_to = escalation_target complaint.escalated_at = timezone.now() complaint.save(update_fields=['assigned_to', 'escalated_at']) # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type='escalation', message=( f"Automatically escalated to {escalation_target.get_full_name()} " f"(Rule: {matching_rule.name}). " f"Complaint is {hours_overdue:.1f} hours overdue." ), created_by=None, # System action metadata={ 'rule_id': str(matching_rule.id), 'rule_name': matching_rule.name, 'hours_overdue': hours_overdue, 'old_assignee_id': str(old_assignee.id) if old_assignee else None, 'new_assignee_id': str(escalation_target.id) } ) # Send notifications send_complaint_notification.delay( complaint_id=str(complaint.id), event_type='escalated' ) # Log audit from apps.core.services import create_audit_log create_audit_log( event_type='complaint_escalated', description=f"Complaint automatically escalated to {escalation_target.get_full_name()}", content_object=complaint, metadata={ 'rule': matching_rule.name, 'hours_overdue': hours_overdue, 'escalated_to': escalation_target.get_full_name() } ) logger.info( f"Escalated complaint {complaint_id} to {escalation_target.get_full_name()} " f"using rule '{matching_rule.name}'" ) return { 'status': 'escalated', 'rule': matching_rule.name, 'escalated_to': escalation_target.get_full_name(), 'hours_overdue': round(hours_overdue, 2) } except Complaint.DoesNotExist: error_msg = f"Complaint {complaint_id} not found" logger.error(error_msg) return {'status': 'error', 'reason': error_msg} except Exception as e: error_msg = f"Error escalating complaint: {str(e)}" logger.error(error_msg, exc_info=True) return {'status': 'error', 'reason': error_msg} @shared_task def send_complaint_notification(complaint_id, event_type): """ Send notification for complaint events. Args: complaint_id: UUID of the Complaint event_type: Type of event (created, assigned, overdue, escalated, resolved, closed) Returns: dict: Result with notification status """ from apps.complaints.models import Complaint from apps.notifications.services import NotificationService try: complaint = Complaint.objects.select_related( 'hospital', 'patient', 'assigned_to', 'department' ).get(id=complaint_id) # Determine recipients based on event type recipients = [] if event_type == 'created': # Notify assigned user or department manager if complaint.assigned_to: recipients.append(complaint.assigned_to) elif complaint.department and complaint.department.manager: recipients.append(complaint.department.manager) elif event_type == 'assigned': # Notify the assignee if complaint.assigned_to: recipients.append(complaint.assigned_to) elif event_type in ['overdue', 'escalated']: # Notify assignee and their manager if complaint.assigned_to: recipients.append(complaint.assigned_to) if complaint.department and complaint.department.manager: recipients.append(complaint.department.manager) elif event_type == 'resolved': # Notify patient recipients.append(complaint.patient) elif event_type == 'closed': # Notify patient recipients.append(complaint.patient) # Send notifications notification_count = 0 for recipient in recipients: try: # Check if NotificationService has send_notification method if hasattr(NotificationService, 'send_notification'): NotificationService.send_notification( recipient=recipient, title=f"Complaint {event_type.title()}: {complaint.title[:50]}", message=f"Complaint #{str(complaint.id)[:8]} has been {event_type}.", notification_type='complaint', related_object=complaint ) notification_count += 1 else: logger.warning(f"NotificationService.send_notification method not available") except Exception as e: logger.error(f"Failed to send notification to {recipient}: {str(e)}") logger.info(f"Sent {notification_count} notifications for complaint {complaint_id} event: {event_type}") return { 'status': 'sent', 'notification_count': notification_count, 'event_type': event_type } except Complaint.DoesNotExist: error_msg = f"Complaint {complaint_id} not found" logger.error(error_msg) return {'status': 'error', 'reason': error_msg} except Exception as e: error_msg = f"Error sending complaint notification: {str(e)}" logger.error(error_msg, exc_info=True) return {'status': 'error', 'reason': error_msg}