""" Complaints Celery tasks This module contains tasks for: - Checking overdue complaints - Sending SLA reminders - Triggering resolution satisfaction surveys - Creating PX actions from complaints - AI-powered complaint analysis """ import logging from typing import Optional, Dict, Any, Tuple 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__) def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None) -> Tuple[Optional[str], float, str]: """ Match staff member from extracted name using multiple matching strategies. Args: staff_name: Name extracted from complaint (without titles) hospital_id: Hospital ID to search within department_name: Optional department name to prioritize matching Returns: Tuple of (staff_id, confidence_score, matching_method) - staff_id: UUID of matched staff or None - confidence_score: Float from 0.0 to 1.0 - matching_method: Description of how staff was matched """ from apps.organizations.models import Staff, Department if not staff_name or not staff_name.strip(): return None, 0.0, "No staff name provided" staff_name = staff_name.strip() # Build base query - staff from this hospital, active status base_query = Staff.objects.filter( hospital_id=hospital_id, status='active' ) # If department is specified, prioritize staff in that department dept_id = None if department_name: department = Department.objects.filter( hospital_id=hospital_id, name__iexact=department_name, status='active' ).first() if department: dept_id = department.id # Layer 1: Exact English match (full name) # Try "first_name last_name" format words = staff_name.split() if len(words) >= 2: first_name = words[0] last_name = ' '.join(words[1:]) # Exact match in correct department exact_query = base_query.filter( Q(first_name__iexact=first_name) & Q(last_name__iexact=last_name) ) if dept_id: exact_query = exact_query.filter(department_id=dept_id) staff = exact_query.first() if staff: confidence = 0.95 if dept_id else 0.90 method = f"Exact English match in {'correct' if dept_id else 'any'} department" logger.info(f"Matched staff using exact English match: {staff.first_name} {staff.last_name} (confidence: {confidence})") return str(staff.id), confidence, method # Layer 2: Exact Arabic match arabic_query = base_query.filter( Q(first_name_ar__iexact=staff_name) | Q(last_name_ar__iexact=staff_name) ) if dept_id: arabic_query = arabic_query.filter(department_id=dept_id) # Try full Arabic name match for staff in arabic_query: full_arabic_name = f"{staff.first_name_ar} {staff.last_name_ar}".strip() if full_arabic_name == staff_name: confidence = 0.95 if dept_id else 0.90 method = f"Exact Arabic match in {'correct' if dept_id else 'any'} department" logger.info(f"Matched staff using exact Arabic match: {staff.first_name_ar} {staff.last_name_ar} (confidence: {confidence})") return str(staff.id), confidence, method # Layer 3: Partial match (first name or last name) partial_query = base_query.filter( Q(first_name__icontains=staff_name) | Q(last_name__icontains=staff_name) | Q(first_name_ar__icontains=staff_name) | Q(last_name_ar__icontains=staff_name) ) if dept_id: partial_query = partial_query.filter(department_id=dept_id) staff = partial_query.first() if staff: confidence = 0.70 if dept_id else 0.60 method = f"Partial match in {'correct' if dept_id else 'any'} department" logger.info(f"Matched staff using partial match: {staff.first_name} {staff.last_name} (confidence: {confidence})") return str(staff.id), confidence, method # Layer 4: Fuzzy match using individual words # Handle cases like "Dr. Ahmed" or "Nurse Sarah" word_query = base_query.filter( Q(first_name__in=words) | Q(first_name_ar__in=words) ) if dept_id: word_query = word_query.filter(department_id=dept_id) staff = word_query.first() if staff: confidence = 0.50 if dept_id else 0.45 method = f"Word match in {'correct' if dept_id else 'any'} department" logger.info(f"Matched staff using word match: {staff.first_name} {staff.last_name} (confidence: {confidence})") return str(staff.id), confidence, method # No match found logger.warning(f"No staff match found for name: {staff_name}") return None, 0.0, "No match found" @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 # Handle case where metadata field might not exist (legacy data) hospital_metadata = getattr(complaint.hospital, 'metadata', None) if hospital_metadata is None: hospital_metadata = {} auto_create = 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}") return {'status': 'disabled'} # Use JSON-serializable values instead of model objects category_name = complaint.category.name_en if complaint.category else None category_id = str(complaint.category.id) if complaint.category else None # 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': category_name, 'complaint_category_id': category_id, '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 analyze_complaint_with_ai(complaint_id): """ Analyze a complaint using AI to determine severity and priority and category. This task is triggered when a complaint is created. It uses the AI service to analyze the complaint content and classify it. Args: complaint_id: UUID of the Complaint Returns: dict: Result with severity, priority, category, and reasoning """ from apps.complaints.models import Complaint from apps.core.ai_service import AIService, AIServiceError try: complaint = Complaint.objects.select_related('hospital').get(id=complaint_id) logger.info(f"Starting AI analysis for complaint {complaint_id}") # Get category name if category exists category_name = None if complaint.category: category_name = complaint.category.name_en # Analyze complaint using AI service try: analysis = AIService.analyze_complaint( title=complaint.title, description=complaint.description, category=category_name, hospital_id=complaint.hospital.id ) # Analyze emotion using AI service emotion_analysis = AIService.analyze_emotion( text=complaint.description ) # Update complaint with AI-determined values old_severity = complaint.severity old_priority = complaint.priority old_category = complaint.category old_department = complaint.department complaint.severity = analysis['severity'] complaint.priority = analysis['priority'] from apps.complaints.models import ComplaintCategory if category := ComplaintCategory.objects.filter(name_en=analysis['category']).first(): complaint.category = category # Update department from AI analysis department_name = analysis.get('department', '') if department_name: from apps.organizations.models import Department # Try exact match first (case-insensitive) if department := Department.objects.filter( hospital_id=complaint.hospital.id, name__iexact=department_name, status='active' ).first(): complaint.department = department logger.info(f"Matched department exactly: {department.name}") # If no exact match, try partial match elif department := Department.objects.filter( hospital_id=complaint.hospital.id, name__icontains=department_name, status='active' ).first(): complaint.department = department logger.info(f"Matched department partially: {department.name} from '{department_name}'") else: logger.warning(f"AI suggested department '{department_name}' but no match found in hospital '{complaint.hospital.name}'") # Update title from AI analysis (use English version) if analysis.get('title_en'): complaint.title = analysis['title_en'] elif analysis.get('title'): complaint.title = analysis['title'] # Get staff_name from analyze_complaint result (already extracted by AI) staff_name = analysis.get('staff_name', '').strip() matched_staff_id = None staff_confidence = 0.0 staff_matching_method = None # Capture old staff before matching old_staff = complaint.staff if staff_name: logger.info(f"AI extracted staff name: {staff_name}") # Try matching WITH department filter first (higher confidence if match found) matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name( staff_name=staff_name, hospital_id=str(complaint.hospital.id), department_name=department_name ) # If no match found with department, try WITHOUT department filter if not matched_staff_id: logger.info(f"No match found with department filter '{department_name}', trying without department filter...") matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name( staff_name=staff_name, hospital_id=str(complaint.hospital.id), department_name=None # Search all departments ) # Only assign staff if confidence is above threshold (0.6) if matched_staff_id and staff_confidence >= 0.6: from apps.organizations.models import Staff try: staff = Staff.objects.get(id=matched_staff_id) complaint.staff = staff logger.info( f"Assigned staff {staff.first_name} {staff.last_name} " f"to complaint {complaint_id} " f"(confidence: {staff_confidence:.2f}, method: {staff_matching_method})" ) except Staff.DoesNotExist: logger.warning(f"Staff {matched_staff_id} not found in database") else: logger.info( f"Staff match confidence {staff_confidence:.2f} below threshold 0.6, " f"or no match found. Not assigning staff." ) # Save reasoning in metadata # Use JSON-serializable values instead of model objects old_category_name = old_category.name_en if old_category else None old_category_id = str(old_category.id) if old_category else None old_department_name = old_department.name if old_department else None old_department_id = str(old_department.id) if old_department else None old_staff_name = f"{old_staff.first_name} {old_staff.last_name}" if old_staff else None old_staff_id = str(old_staff.id) if old_staff else None # Initialize metadata if needed if not complaint.metadata: complaint.metadata = {} # Update or create ai_analysis in metadata with bilingual support and emotion complaint.metadata['ai_analysis'] = { 'title_en': analysis.get('title_en', ''), 'title_ar': analysis.get('title_ar', ''), 'short_description_en': analysis.get('short_description_en', ''), 'short_description_ar': analysis.get('short_description_ar', ''), 'suggested_action_en': analysis.get('suggested_action_en', ''), 'suggested_action_ar': analysis.get('suggested_action_ar', ''), 'reasoning_en': analysis.get('reasoning_en', ''), 'reasoning_ar': analysis.get('reasoning_ar', ''), 'emotion': emotion_analysis.get('emotion', 'neutral'), 'emotion_intensity': emotion_analysis.get('intensity', 0.0), 'emotion_confidence': emotion_analysis.get('confidence', 0.0), 'analyzed_at': timezone.now().isoformat(), 'old_severity': old_severity, 'old_priority': old_priority, 'old_category': old_category_name, 'old_category_id': old_category_id, 'old_department': old_department_name, 'old_department_id': old_department_id, 'old_staff': old_staff_name, 'old_staff_id': old_staff_id, 'extracted_staff_name': staff_name, 'matched_staff_id': matched_staff_id, 'staff_confidence': staff_confidence, 'staff_matching_method': staff_matching_method } complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata']) # Re-calculate SLA due date based on new severity complaint.due_at = complaint.calculate_sla_due_date() complaint.save(update_fields=['due_at']) # Create timeline update for AI completion from apps.complaints.models import ComplaintUpdate # Build bilingual message emotion_display = emotion_analysis.get('emotion', 'neutral') emotion_intensity = emotion_analysis.get('intensity', 0.0) # Build English message message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}" if matched_staff_id: message_en += f", Staff={f'{complaint.staff.first_name} {complaint.staff.last_name}' if complaint.staff else 'N/A'} (confidence: {staff_confidence:.2f})" message_en += f", Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})" # Build Arabic message message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}" if matched_staff_id and complaint.staff: staff_name_ar = complaint.staff.first_name_ar if complaint.staff.first_name_ar else complaint.staff.first_name message_ar += f", الموظف={staff_name_ar} {complaint.staff.last_name_ar if complaint.staff.last_name_ar else complaint.staff.last_name} (الثقة: {staff_confidence:.2f})" message_ar += f", العاطفة={emotion_display} (الشدة: {emotion_intensity:.2f})" message = f"{message_en}\n\n{message_ar}" ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=message ) logger.info( f"AI analysis complete for complaint {complaint_id}: " f"severity={old_severity}->{analysis['severity']}, " f"priority={old_priority}->{analysis['priority']}, " f"category={old_category_name}->{analysis['category']}, " f"department={old_department_name}->{department_name}, " f"title_en={analysis.get('title_en', '')}" ) return { 'status': 'success', 'complaint_id': str(complaint_id), 'severity': analysis['severity'], 'priority': analysis['priority'], 'category': analysis['category'], 'department': department_name, 'title_en': analysis.get('title_en', ''), 'title_ar': analysis.get('title_ar', ''), 'short_description_en': analysis.get('short_description_en', ''), 'short_description_ar': analysis.get('short_description_ar', ''), 'suggested_action_en': analysis.get('suggested_action_en', ''), 'suggested_action_ar': analysis.get('suggested_action_ar', ''), 'reasoning_en': analysis.get('reasoning_en', ''), 'reasoning_ar': analysis.get('reasoning_ar', ''), 'emotion': emotion_analysis.get('emotion', 'neutral'), 'emotion_intensity': emotion_analysis.get('intensity', 0.0), 'emotion_confidence': emotion_analysis.get('confidence', 0.0), 'old_severity': old_severity, 'old_priority': old_priority } except AIServiceError as e: logger.error(f"AI service error for complaint {complaint_id}: {str(e)}") # Keep default values (medium/medium) and log the error return { 'status': 'ai_error', 'complaint_id': str(complaint_id), 'reason': str(e) } 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 analyzing complaint {complaint_id} with AI: {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}