""" 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, return_all: bool = False, fuzzy_threshold: float = 0.65) -> Tuple[list, float, str]: """ Enhanced staff matching with fuzzy matching and improved accuracy. This function uses fuzzy string matching (Levenshtein distance) to find staff members with improved handling of: - Name variations (with/without hyphens, different spellings) - Typos and minor errors - Matching against original full name field - Better confidence scoring Args: staff_name: Name extracted from complaint (without titles) hospital_id: Hospital ID to search within department_name: Optional department name to prioritize matching return_all: If True, return all matching staff. If False, return single best match. fuzzy_threshold: Minimum similarity ratio for fuzzy matches (0.0 to 1.0) Returns: If return_all=True: Tuple of (matches_list, confidence_score, matching_method) - matches_list: List of dicts with matched staff details - confidence_score: Float from 0.0 to 1.0 (best match confidence) - matching_method: Description of how staff was matched If return_all=False: 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 [], 0.0, "No staff name provided" staff_name = staff_name.strip() normalized_input = _normalize_name(staff_name) matches = [] # Build base query - staff from this hospital, active status base_query = Staff.objects.filter( hospital_id=hospital_id, status='active' ) # Get department if specified 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 # Fetch all staff to perform fuzzy matching all_staff = list(base_query) # If department specified, filter if dept_id: dept_staff = [s for s in all_staff if str(s.department.id) == dept_id if s.department] else: dept_staff = [] # ======================================== # LAYER 1: EXACT MATCHES # ======================================== # 1a. Exact match on first_name + last_name (English) words = staff_name.split() if len(words) >= 2: first_name = words[0] last_name = ' '.join(words[1:]) for staff in all_staff: if staff.first_name.lower() == first_name.lower() and \ staff.last_name.lower() == last_name.lower(): confidence = 0.95 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.90 method = f"Exact English match in {'correct' if (dept_id and staff.department and str(staff.department.id) == dept_id) else 'any'} department" if not any(m['id'] == str(staff.id) for m in matches): matches.append(_create_match_dict(staff, confidence, method, staff_name)) logger.info(f"EXACT MATCH (EN): {staff.first_name} {staff.last_name} == {first_name} {last_name}") # 1b. Exact match on full Arabic name for staff in all_staff: full_arabic = f"{staff.first_name_ar} {staff.last_name_ar}".strip() if full_arabic == staff_name: confidence = 0.95 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.90 method = f"Exact Arabic match in {'correct' if (dept_id and staff.department and str(staff.department.id) == dept_id) else 'any'} department" if not any(m['id'] == str(staff.id) for m in matches): matches.append(_create_match_dict(staff, confidence, method, staff_name)) logger.info(f"EXACT MATCH (AR): {full_arabic} == {staff_name}") # 1c. Exact match on 'name' field (original full name) for staff in all_staff: if staff.name and staff.name.lower() == staff_name.lower(): confidence = 0.93 method = "Exact match on original name field" if not any(m['id'] == str(staff.id) for m in matches): matches.append(_create_match_dict(staff, confidence, method, staff_name)) logger.info(f"EXACT MATCH (name field): {staff.name} == {staff_name}") # ======================================== # LAYER 2: FUZZY MATCHES (if no exact) # ======================================== if not matches: logger.info(f"No exact matches found, trying fuzzy matching for: {staff_name}") for staff in all_staff: # Try different name combinations name_combinations = [ f"{staff.first_name} {staff.last_name}", f"{staff.first_name_ar} {staff.last_name_ar}", staff.name or "", staff.first_name, staff.last_name, staff.first_name_ar, staff.last_name_ar ] # Check if any combination matches fuzzily best_ratio = 0.0 best_match_name = "" for combo in name_combinations: if not combo: continue ratio = _fuzzy_match_ratio(staff_name, combo) if ratio > best_ratio: best_ratio = ratio best_match_name = combo # If good fuzzy match found if best_ratio >= fuzzy_threshold: # Adjust confidence based on match quality and department dept_bonus = 0.05 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0 confidence = best_ratio * 0.85 + dept_bonus # Scale down slightly for fuzzy method = f"Fuzzy match ({best_ratio:.2f}) on '{best_match_name}'" if not any(m['id'] == str(staff.id) for m in matches): matches.append(_create_match_dict(staff, confidence, method, staff_name)) logger.info(f"FUZZY MATCH ({best_ratio:.2f}): {best_match_name} ~ {staff_name}") # ======================================== # LAYER 3: PARTIAL/WORD MATCHES # ======================================== if not matches: logger.info(f"No fuzzy matches found, trying partial/word matching for: {staff_name}") # Split input name into words input_words = [_normalize_name(w) for w in staff_name.split() if _normalize_name(w)] for staff in all_staff: # Build list of all name fields staff_names = [ staff.first_name, staff.last_name, staff.first_name_ar, staff.last_name_ar, staff.name or "" ] # Count word matches match_count = 0 total_words = len(input_words) for word in input_words: word_matched = False for staff_name_field in staff_names: if _normalize_name(staff_name_field) == word or \ word in _normalize_name(staff_name_field): word_matched = True break if word_matched: match_count += 1 # If at least 2 words match (or all if only 2 words) if match_count >= 2 or (total_words == 2 and match_count == 2): confidence = 0.60 + (match_count / total_words) * 0.15 dept_bonus = 0.05 if (dept_id and staff.department and str(staff.department.id) == dept_id) else 0.0 confidence += dept_bonus method = f"Partial match ({match_count}/{total_words} words)" if not any(m['id'] == str(staff.id) for m in matches): matches.append(_create_match_dict(staff, confidence, method, staff_name)) logger.info(f"PARTIAL MATCH ({match_count}/{total_words}): {staff.first_name} {staff.last_name}") # ======================================== # FINAL: SORT AND RETURN # ======================================== if matches: # Sort by confidence (descending) matches.sort(key=lambda x: x['confidence'], reverse=True) best_confidence = matches[0]['confidence'] best_method = matches[0]['matching_method'] logger.info( f"Returning {len(matches)} match(es) for '{staff_name}'. " f"Best: {matches[0]['name_en']} (confidence: {best_confidence:.2f}, method: {best_method})" ) if not return_all: return str(matches[0]['id']), best_confidence, best_method else: return matches, best_confidence, best_method else: logger.warning(f"No staff match found for name: '{staff_name}'") return [], 0.0, "No match found" def _fuzzy_match_ratio(str1: str, str2: str) -> float: """ Calculate fuzzy match ratio using difflib. Args: str1: First string str2: Second string Returns: Float from 0.0 to 1.0 representing similarity """ try: from difflib import SequenceMatcher return SequenceMatcher(None, str1.lower(), str2.lower()).ratio() except Exception: return 0.0 def _normalize_name(name: str) -> str: """ Normalize name for better matching. - Remove extra spaces - Remove hyphens (Al-Shammari -> AlShammari) - Convert to lowercase - Remove common titles """ if not name: return "" name = name.strip().lower() # Remove common titles (both English and Arabic) titles = ['dr.', 'dr', 'mr.', 'mr', 'mrs.', 'mrs', 'ms.', 'ms', 'د.', 'السيد', 'السيدة', 'الدكتور'] for title in titles: if name.startswith(title): name = name[len(title):].strip() # Remove hyphens for better matching (Al-Shammari -> AlShammari) name = name.replace('-', '') # Remove extra spaces while ' ' in name: name = name.replace(' ', ' ') return name.strip() def _create_match_dict(staff, confidence: float, method: str, source_name: str) -> Dict[str, Any]: """ Create a match dictionary for a staff member. Args: staff: Staff model instance confidence: Confidence score (0.0 to 1.0) method: Description of matching method source_name: Original input name that was matched Returns: Dictionary with match details """ return { 'id': str(staff.id), 'name_en': f"{staff.first_name} {staff.last_name}", 'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "", 'original_name': staff.name or "", 'job_title': staff.job_title, 'specialization': staff.specialization, 'department': staff.department.name if staff.department else None, 'department_id': str(staff.department.id) if staff.department else None, 'confidence': confidence, 'matching_method': method, 'source_name': source_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 # 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. Supports multi-level escalation with tracking. 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) # Get current escalation level from metadata current_level = complaint.metadata.get('escalation_level', 0) # Calculate hours overdue hours_overdue = (timezone.now() - complaint.due_at).total_seconds() / 3600 # Get applicable escalation rules for this hospital, ordered by escalation_level rules = EscalationRule.objects.filter( hospital=complaint.hospital, is_active=True, trigger_on_overdue=True ).order_by('escalation_level', '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 matching rule for next escalation level matching_rule = None for rule in rules: # Check if this is the next escalation level if rule.escalation_level == current_level + 1: # Check if we've exceeded trigger hours if hours_overdue >= rule.trigger_hours_overdue: # Check if we've exceeded max level max_level = rule.max_escalation_level if current_level >= max_level: logger.info( f"Complaint {complaint_id} has reached max escalation level {max_level}" ) return { 'status': 'max_level_reached', 'max_level': max_level, 'current_level': current_level } matching_rule = rule break if not matching_rule: logger.info( f"No matching escalation rule found for complaint {complaint_id} " f"(current level: {current_level}, hours overdue: {hours_overdue:.1f})" ) return {'status': 'no_matching_rule', 'current_level': current_level} # 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 == 'ceo': # Find CEO for this hospital escalation_target = User.objects.filter( hospital=complaint.hospital, groups__name='CEO', 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"({matching_rule.escalate_to_role}) on complaint {complaint_id}" ) return { 'status': 'no_target_found', 'rule': matching_rule.name, 'role': matching_rule.escalate_to_role } # Check if already assigned to this person to avoid redundant escalation if complaint.assigned_to and complaint.assigned_to.id == escalation_target.id: logger.info( f"Complaint {complaint_id} already assigned to {escalation_target.get_full_name()}, " f"skipping escalation to same person" ) return { 'status': 'already_assigned', 'escalated_to': escalation_target.get_full_name() } # Perform escalation old_assignee = complaint.assigned_to complaint.assigned_to = escalation_target complaint.escalated_at = timezone.now() # Update metadata with escalation level complaint.metadata['escalation_level'] = matching_rule.escalation_level complaint.metadata['last_escalation_rule'] = { 'id': str(matching_rule.id), 'name': matching_rule.name, 'level': matching_rule.escalation_level, 'timestamp': timezone.now().isoformat() } complaint.save(update_fields=['assigned_to', 'escalated_at', 'metadata']) # Create update ComplaintUpdate.objects.create( complaint=complaint, update_type='escalation', message=( f"Automatically escalated to {escalation_target.get_full_name()} " f"(Level {matching_rule.escalation_level}, 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, 'escalation_level': matching_rule.escalation_level, '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()} (Level {matching_rule.escalation_level})", content_object=complaint, metadata={ 'rule': matching_rule.name, 'level': matching_rule.escalation_level, '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"(Level {matching_rule.escalation_level}) using rule '{matching_rule.name}'" ) return { 'status': 'escalated', 'rule': matching_rule.name, 'level': matching_rule.escalation_level, '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 escalate_after_reminder(complaint_id): """ Escalate complaint after reminder if no action taken. This task is triggered by the SLA reminder task for rules with reminder_escalation_enabled. It checks if the complaint has had any activity since the reminder was sent, and escalates if not. Args: complaint_id: UUID of the Complaint Returns: dict: Result with escalation status """ from apps.complaints.models import Complaint, ComplaintUpdate, EscalationRule try: complaint = Complaint.objects.select_related( 'hospital', 'department', 'assigned_to' ).get(id=complaint_id) # Check if reminder was sent if not complaint.reminder_sent_at: logger.info(f"No reminder sent for complaint {complaint_id}, skipping escalation") return {'status': 'no_reminder_sent'} # Get SLA config to check reminder-based escalation from apps.complaints.models import ComplaintSLAConfig try: sla_config = ComplaintSLAConfig.objects.get( hospital=complaint.hospital, severity=complaint.severity, priority=complaint.priority, is_active=True ) except ComplaintSLAConfig.DoesNotExist: logger.info(f"No SLA config for complaint {complaint_id}, skipping reminder escalation") return {'status': 'no_sla_config'} # Check if reminder escalation is enabled for this hospital rules = EscalationRule.objects.filter( hospital=complaint.hospital, is_active=True, reminder_escalation_enabled=True ).order_by('escalation_level') # Filter by severity/priority 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) ) if not rules.exists(): logger.info(f"No reminder escalation rules for complaint {complaint_id}") return {'status': 'no_rules'} # Get current escalation level current_level = complaint.metadata.get('escalation_level', 0) # Find matching rule for next level matching_rule = None for rule in rules: if rule.escalation_level == current_level + 1: # Calculate time since reminder hours_since_reminder = (timezone.now() - complaint.reminder_sent_at).total_seconds() / 3600 # Check if enough time has passed since reminder if hours_since_reminder >= rule.reminder_escalation_hours: matching_rule = rule break if not matching_rule: logger.info( f"Reminder escalation not yet triggered for complaint {complaint_id} " f"(hours since reminder: {(timezone.now() - complaint.reminder_sent_at).total_seconds() / 3600:.1f})" ) return { 'status': 'not_yet_triggered', 'hours_since_reminder': (timezone.now() - complaint.reminder_sent_at).total_seconds() / 3600 } # Trigger the regular escalation task result = escalate_complaint_auto.delay(complaint_id) # Add metadata about this being a reminder-based escalation if complaint.metadata: complaint.metadata['reminder_escalation'] = { 'rule_id': str(matching_rule.id), 'rule_name': matching_rule.name, 'hours_since_reminder': (timezone.now() - complaint.reminder_sent_at).total_seconds() / 3600, 'timestamp': timezone.now().isoformat() } complaint.save(update_fields=['metadata']) logger.info( f"Reminder-based escalation triggered for complaint {complaint_id} " f"using rule '{matching_rule.name}'" ) return { 'status': 'reminder_escalation_triggered', 'rule': matching_rule.name, 'escalation_result': result } 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 in reminder escalation: {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 ALL staff names from analyze_complaint result (extracted by AI) staff_names = analysis.get('staff_names', []) primary_staff_name = analysis.get('primary_staff_name', '').strip() # Always get ALL matching staff for PX Admin review all_staff_matches = [] staff_confidence = 0.0 staff_matching_method = None matched_staff_id = None # Capture old staff before matching old_staff = complaint.staff # Process ALL extracted staff names if staff_names: logger.info(f"AI extracted {len(staff_names)} staff name(s): {staff_names}") # Loop through each extracted name and match to database for idx, staff_name in enumerate(staff_names): staff_name = staff_name.strip() if not staff_name: continue logger.info(f"Matching staff name {idx+1}/{len(staff_names)}: {staff_name}") # Try matching WITH department filter first (higher confidence if match found) matches_for_name, confidence_for_name, method_for_name = match_staff_from_name( staff_name=staff_name, hospital_id=str(complaint.hospital.id), department_name=department_name, return_all=True # Return ALL matches ) # If no match found with department, try WITHOUT department filter if not matches_for_name: logger.info(f"No match found with department filter '{department_name}' for '{staff_name}', trying without department filter...") matches_for_name, confidence_for_name, method_for_name = match_staff_from_name( staff_name=staff_name, hospital_id=str(complaint.hospital.id), department_name=None, # Search all departments return_all=True ) # Add source_name to each match so we know which extracted name it came from for match in matches_for_name: match['source_name'] = staff_name all_staff_matches.extend(matches_for_name) # Deduplicate matches (same staff can match multiple names) seen_ids = set() deduped_matches = [] for match in all_staff_matches: if match['id'] not in seen_ids: seen_ids.add(match['id']) deduped_matches.append(match) all_staff_matches = deduped_matches logger.info(f"Total unique staff matches found: {len(all_staff_matches)}") # Logic for staff assignment - CHANGED: NO AUTO-ASSIGNMENT needs_staff_review = False if all_staff_matches: # Sort by confidence (descending) all_staff_matches.sort(key=lambda x: x['confidence'], reverse=True) # Get best match (highest confidence) - BUT DON'T AUTO-ASSIGN best_match = all_staff_matches[0] matched_staff_id = best_match['id'] staff_confidence = best_match['confidence'] staff_matching_method = best_match['matching_method'] # DO NOT AUTO-ASSIGN STAFF - Only store suggestions in metadata # PX Admins will manually select from suggestions logger.info( f"Found staff suggestion: {best_match['name_en']} " f"for complaint {complaint_id} " f"(confidence: {staff_confidence:.2f}, method: {staff_matching_method}) - " f"NOT auto-assigned, pending manual review" ) # Mark for review if: # - Low confidence on best match # - Multiple names extracted (multiple people mentioned) # - Multiple database matches found # - ALWAYS mark for review since we're not auto-assigning needs_staff_review = True # Assign to department if confidence is high enough (>= 0.7) if staff_confidence >= 0.7 and best_match.get('department_id'): from apps.organizations.models import Department try: dept = Department.objects.get(id=best_match['department_id']) complaint.department = dept logger.info(f"Assigned to department based on staff match: {dept.name}") except Department.DoesNotExist: pass else: # No matches found logger.warning(f"No staff matches found for extracted names") needs_staff_review = False # No review needed if no names found else: # No staff names extracted logger.info("No staff names extracted from complaint") needs_staff_review = False # 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_names': staff_names, 'primary_staff_name': primary_staff_name, 'staff_matches': all_staff_matches, 'matched_staff_id': matched_staff_id, 'staff_confidence': staff_confidence, 'staff_matching_method': staff_matching_method, 'needs_staff_review': needs_staff_review, 'staff_match_count': len(all_staff_matches) } 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 ) # PX Action creation is now MANDATORY for all complaints action_id = None try: logger.info(f"Creating PX Action for complaint {complaint_id} (Mandatory for all complaints)") # Generate PX Action data using AI action_data = AIService.create_px_action_from_complaint(complaint) # Create PX Action object from apps.px_action_center.models import PXAction, PXActionLog from django.contrib.contenttypes.models import ContentType complaint_ct = ContentType.objects.get_for_model(Complaint) action = PXAction.objects.create( source_type='complaint', content_type=complaint_ct, object_id=complaint.id, title=action_data['title'], description=action_data['description'], hospital=complaint.hospital, department=complaint.department, category=action_data['category'], priority=action_data['priority'], severity=action_data['severity'], status='open', metadata={ 'source_complaint_id': str(complaint.id), 'source_complaint_title': complaint.title, 'ai_generated': True, 'auto_created': True, 'ai_reasoning': action_data.get('reasoning', '') } ) action_id = str(action.id) # Create action log entry PXActionLog.objects.create( action=action, log_type='note', message=f"Action automatically generated by AI for complaint: {complaint.title}", metadata={ 'complaint_id': str(complaint.id), 'ai_generated': True, 'auto_created': True, 'category': action_data['category'], 'priority': action_data['priority'], 'severity': action_data['severity'] } ) # Create complaint update ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=f"PX Action automatically created from AI-generated suggestion (Action #{action.id}) - {action_data['category']}", metadata={'action_id': str(action.id), 'category': action_data['category']} ) # Log audit from apps.core.services import create_audit_log create_audit_log( event_type='px_action_auto_created', description=f"PX Action automatically created from AI analysis for complaint: {complaint.title}", content_object=action, metadata={ 'complaint_id': str(complaint.id), 'category': action_data['category'], 'priority': action_data['priority'], 'severity': action_data['severity'], 'ai_reasoning': action_data.get('reasoning', '') } ) logger.info(f"PX Action {action.id} automatically created for complaint {complaint_id}") except Exception as e: logger.error(f"Error auto-creating PX Action for complaint {complaint_id}: {str(e)}", exc_info=True) # Don't fail the entire task if PX Action creation fails action_id = None 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, 'px_action_id': action_id, 'px_action_auto_created': action_id is not None } 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 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} def get_explanation_sla_config(hospital): """ Get explanation SLA configuration for a hospital. Returns the first active ExplanationSLAConfig for the hospital. Returns None if no config exists (will use defaults). """ from apps.complaints.models import ExplanationSLAConfig try: return ExplanationSLAConfig.objects.get( hospital=hospital, is_active=True ) except ExplanationSLAConfig.DoesNotExist: return None @shared_task def send_explanation_request_email(explanation_id): """ Send email to staff requesting explanation. Includes link with unique token for staff to submit explanation. Sets SLA deadline based on hospital configuration. """ from apps.complaints.models import ComplaintExplanation from django.core.mail import send_mail from django.conf import settings from django.template.loader import render_to_string explanation = ComplaintExplanation.objects.select_related( 'complaint', 'staff', 'requested_by' ).get(id=explanation_id) # Calculate SLA deadline sla_config = get_explanation_sla_config(explanation.complaint.hospital) sla_hours = sla_config.response_hours if sla_config else 48 explanation.sla_due_at = timezone.now() + timezone.timedelta(hours=sla_hours) explanation.email_sent_at = timezone.now() explanation.save(update_fields=['sla_due_at', 'email_sent_at']) # Prepare email context = { 'explanation': explanation, 'complaint': explanation.complaint, 'staff': explanation.staff, 'requested_by': explanation.requested_by, 'sla_hours': sla_hours, 'due_date': explanation.sla_due_at, 'site_url': settings.SITE_URL if hasattr(settings, 'SITE_URL') else 'http://localhost:8000', } subject = f"Explanation Request: Complaint #{str(explanation.complaint.id)[:8]}" # Render email templates message_en = render_to_string( 'complaints/emails/explanation_request_en.txt', context ) message_ar = render_to_string( 'complaints/emails/explanation_request_ar.txt', context ) # Send email send_mail( subject=subject, message=f"{message_en}\n\n{message_ar}", from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[explanation.staff.email], fail_silently=False ) # Log audit from apps.core.services import create_audit_log create_audit_log( event_type='explanation_request_sent', description=f"Explanation request email sent to {explanation.staff.get_full_name()}", content_object=explanation, metadata={ 'complaint_id': str(explanation.complaint.id), 'staff_name': explanation.staff.get_full_name(), 'sla_hours': sla_hours, 'due_date': explanation.sla_due_at.isoformat() } ) logger.info( f"Explanation request email sent to {explanation.staff.get_full_name()} " f"for complaint {explanation.complaint_id}" ) return {'status': 'sent', 'explanation_id': str(explanation.id)} @shared_task def check_overdue_explanation_requests(): """ Periodic task to check for overdue explanation requests. Runs every 15 minutes (configured in config/celery.py). Escalates to staff's manager if explanation not submitted within SLA. Follows staff hierarchy via report_to field. """ from apps.complaints.models import ComplaintExplanation from apps.organizations.models import Staff now = timezone.now() # Get explanation requests that are: # - Not submitted (is_used=False) # - Email sent (email_sent_at is not null) # - Past SLA deadline overdue_explanations = ComplaintExplanation.objects.filter( is_used=False, email_sent_at__isnull=False, sla_due_at__lt=now, escalated_to_manager__isnull=True # Not yet escalated ).select_related('complaint', 'staff', 'staff__department') escalated_count = 0 for explanation in overdue_explanations: # Mark as overdue if not explanation.is_overdue: explanation.is_overdue = True explanation.save(update_fields=['is_overdue']) # Get SLA config sla_config = get_explanation_sla_config(explanation.complaint.hospital) # Check if auto-escalation is enabled if not sla_config or not sla_config.auto_escalate_enabled: logger.info( f"Auto-escalation disabled for explanation {explanation.id}, " f"hospital {explanation.complaint.hospital.name}" ) continue # Get current escalation level current_level = explanation.metadata.get('escalation_level', 0) # Check max escalation level max_level = sla_config.max_escalation_levels if sla_config else 3 if current_level >= max_level: logger.info(f"Explanation {explanation.id} reached max escalation level {max_level}") continue # Calculate hours overdue hours_overdue = (now - explanation.sla_due_at).total_seconds() / 3600 # Check if we should escalate now escalation_delay = sla_config.escalation_hours_overdue if sla_config else 0 if hours_overdue < escalation_delay: logger.info( f"Explanation {explanation.id} overdue by {hours_overdue:.1f}h, " f"waiting for escalation delay of {escalation_delay}h" ) continue # Determine escalation target using staff hierarchy escalation_target = None if explanation.staff and explanation.staff.report_to: # Escalate to staff's manager escalation_target = explanation.staff.report_to # Check if manager already has an explanation request for this complaint existing_explanation = ComplaintExplanation.objects.filter( complaint=explanation.complaint, staff=escalation_target ).first() if existing_explanation: logger.info( f"Staff {escalation_target.get_full_name()} already has an explanation " f"request for complaint {explanation.complaint.id}, skipping escalation" ) # Mark as escalated anyway to avoid repeated checks explanation.escalated_to_manager = existing_explanation explanation.escalated_at = now explanation.metadata['escalation_level'] = current_level + 1 explanation.save(update_fields=['escalated_to_manager', 'escalated_at', 'metadata']) escalated_count += 1 continue # Create new explanation request for manager new_explanation = ComplaintExplanation.objects.create( complaint=explanation.complaint, staff=escalation_target, explanation='', # Will be filled by manager requested_by=explanation.requested_by, request_message=( f"Escalated from {explanation.staff.get_full_name()}. " f"Staff member did not provide explanation within SLA. " f"Please provide your explanation about this complaint." ), submitted_via='email_link', metadata={ 'escalated_from_explanation_id': str(explanation.id), 'escalation_level': current_level + 1, 'original_staff_id': str(explanation.staff.id), 'original_staff_name': explanation.staff.get_full_name() } ) # Link old explanation to new one explanation.escalated_to_manager = new_explanation explanation.escalated_at = now explanation.metadata['escalation_level'] = current_level + 1 explanation.save(update_fields=['escalated_to_manager', 'escalated_at', 'metadata']) # Send email to manager send_explanation_request_email.delay(str(new_explanation.id)) escalated_count += 1 logger.info( f"Escalated explanation request {explanation.id} to manager " f"{escalation_target.get_full_name()} (Level {current_level + 1})" ) else: logger.warning( f"No escalation target for explanation {explanation.id} " f"(staff has no report_to manager)" ) return { 'overdue_count': overdue_explanations.count(), 'escalated_count': escalated_count } @shared_task def send_explanation_reminders(): """ Send reminder emails for explanation requests approaching deadline. Runs every hour via Celery Beat. Sends reminder to staff if explanation not submitted and deadline approaching. """ from apps.complaints.models import ComplaintExplanation from django.core.mail import send_mail from django.conf import settings from django.template.loader import render_to_string now = timezone.now() # Get explanation requests that: # - Not submitted (is_used=False) # - Email sent (email_sent_at is not null) # - Haven't been reminded yet # - Approaching deadline explanations = ComplaintExplanation.objects.filter( is_used=False, email_sent_at__isnull=False, reminder_sent_at__isnull=True, escalated_to_manager__isnull=True ).select_related('complaint', 'staff') reminder_count = 0 for explanation in explanations: # Get SLA config sla_config = get_explanation_sla_config(explanation.complaint.hospital) reminder_hours_before = sla_config.reminder_hours_before if sla_config else 12 # Calculate reminder threshold time reminder_time = explanation.sla_due_at - timezone.timedelta(hours=reminder_hours_before) # Check if we should send reminder now if now >= reminder_time: # Calculate hours remaining hours_remaining = (explanation.sla_due_at - now).total_seconds() / 3600 if hours_remaining < 0: continue # Already overdue, will be handled by check_overdue_explanation_requests # Prepare email context context = { 'explanation': explanation, 'complaint': explanation.complaint, 'staff': explanation.staff, 'hours_remaining': int(hours_remaining), 'due_date': explanation.sla_due_at, 'site_url': settings.SITE_URL if hasattr(settings, 'SITE_URL') else 'http://localhost:8000', } subject = f"Reminder: Explanation Request - Complaint #{str(explanation.complaint.id)[:8]}" try: # Render email templates message_en = render_to_string( 'complaints/emails/explanation_reminder_en.txt', context ) message_ar = render_to_string( 'complaints/emails/explanation_reminder_ar.txt', context ) # Send email send_mail( subject=subject, message=f"{message_en}\n\n{message_ar}", from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[explanation.staff.email], fail_silently=False ) # Update explanation explanation.reminder_sent_at = now explanation.save(update_fields=['reminder_sent_at']) reminder_count += 1 logger.info( f"Explanation reminder sent to {explanation.staff.get_full_name()} " f"for complaint {explanation.complaint_id} " f"({int(hours_remaining)} hours remaining)" ) except Exception as e: logger.error(f"Failed to send explanation reminder for {explanation.id}: {str(e)}") return { 'status': 'completed', 'reminders_sent': reminder_count } @shared_task def send_sla_reminders(): """ Send SLA reminder emails for complaints approaching deadline. Runs every hour via Celery Beat. Finds complaints where reminder should be sent based on hospital's SLA configuration. Sends reminder email to assigned user or department manager. Creates timeline entry for reminder sent. Returns: dict: Result with reminder count and details """ from apps.complaints.models import Complaint, ComplaintUpdate, ComplaintStatus, ComplaintSLAConfig from apps.notifications.services import NotificationService from django.core.mail import send_mail from django.conf import settings from django.template.loader import render_to_string try: now = timezone.now() # Get active complaints that haven't been reminded yet OR need second reminder active_complaints = Complaint.objects.filter( status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS] ).filter( models.Q(reminder_sent_at__isnull=True) | # First reminder not sent models.Q( reminder_sent_at__isnull=False, second_reminder_sent_at__isnull=True, reminder_sent_at__lt=now - timezone.timedelta(hours=1) # At least 1 hour after first reminder ) ).select_related('hospital', 'patient', 'assigned_to', 'department', 'category') reminder_count = 0 skipped_count = 0 for complaint in active_complaints: # Get SLA config for this complaint try: sla_config = ComplaintSLAConfig.objects.get( hospital=complaint.hospital, severity=complaint.severity, priority=complaint.priority, is_active=True ) reminder_hours_before = sla_config.reminder_hours_before except ComplaintSLAConfig.DoesNotExist: # Use default of 24 hours reminder_hours_before = 24 # Calculate reminder threshold time reminder_time = complaint.due_at - timezone.timedelta(hours=reminder_hours_before) # Check if we should send FIRST reminder now if now >= reminder_time and complaint.reminder_sent_at is None: # Determine recipient recipient = complaint.assigned_to if not recipient and complaint.department and complaint.department.manager: recipient = complaint.department.manager if not recipient: logger.warning( f"No recipient for SLA reminder on complaint {complaint.id} " f"(no assigned user or department manager)" ) skipped_count += 1 continue # Calculate hours remaining hours_remaining = (complaint.due_at - now).total_seconds() / 3600 # Prepare email context context = { 'complaint': complaint, 'recipient': recipient, 'hours_remaining': int(hours_remaining), 'due_date': complaint.due_at, 'site_url': f"{settings.SITE_URL if hasattr(settings, 'SITE_URL') else 'http://localhost:8000'}", } # Render email templates subject = f"SLA Reminder: Complaint #{str(complaint.id)[:8]} - {complaint.title[:50]}" try: # Try to send via NotificationService first if hasattr(NotificationService, 'send_notification'): NotificationService.send_notification( recipient=recipient, title=subject, message=( f"This is a reminder that complaint #{str(complaint.id)[:8]} " f"is due in {int(hours_remaining)} hours. " f"Please take action to avoid SLA breach." ), notification_type='complaint', related_object=complaint, metadata={'event_type': 'sla_reminder'} ) else: # Fallback to direct email message_en = render_to_string( 'complaints/emails/sla_reminder_en.txt', context ) message_ar = render_to_string( 'complaints/emails/sla_reminder_ar.txt', context ) # Send to recipient's email recipient_email = recipient.email if hasattr(recipient, 'email') else None if recipient_email: send_mail( subject=subject, message=f"{message_en}\n\n{message_ar}", from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[recipient_email], fail_silently=False ) else: logger.warning(f"No email for recipient {recipient}") skipped_count += 1 continue # Update complaint complaint.reminder_sent_at = now complaint.save(update_fields=['reminder_sent_at']) # Create timeline entry ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=( f"SLA reminder sent to {recipient.get_full_name()}. " f"Complaint is due in {int(hours_remaining)} hours." ), created_by=None, # System action metadata={ 'event_type': 'sla_reminder', 'hours_remaining': int(hours_remaining), 'recipient_id': str(recipient.id) } ) # Log audit from apps.core.services import create_audit_log create_audit_log( event_type='sla_reminder_sent', description=f"SLA reminder sent for complaint {complaint.id}", content_object=complaint, metadata={ 'recipient': recipient.get_full_name(), 'hours_remaining': int(hours_remaining) } ) reminder_count += 1 logger.info( f"SLA reminder sent for complaint {complaint.id} " f"to {recipient.get_full_name()} " f"({int(hours_remaining)} hours remaining)" ) # Trigger reminder-based escalation check escalate_after_reminder.delay(str(complaint.id)) except Exception as e: logger.error(f"Failed to send SLA reminder for complaint {complaint.id}: {str(e)}") skipped_count += 1 # Check if we should send SECOND reminder now elif (sla_config.second_reminder_enabled and complaint.reminder_sent_at is not None and complaint.second_reminder_sent_at is None): # Calculate second reminder threshold time second_reminder_hours_before = sla_config.second_reminder_hours_before second_reminder_time = complaint.due_at - timezone.timedelta(hours=second_reminder_hours_before) if now >= second_reminder_time: # Determine recipient recipient = complaint.assigned_to if not recipient and complaint.department and complaint.department.manager: recipient = complaint.department.manager if not recipient: logger.warning( f"No recipient for second SLA reminder on complaint {complaint.id} " f"(no assigned user or department manager)" ) skipped_count += 1 continue # Calculate hours remaining hours_remaining = (complaint.due_at - now).total_seconds() / 3600 # Prepare email context context = { 'complaint': complaint, 'recipient': recipient, 'hours_remaining': int(hours_remaining), 'due_date': complaint.due_at, 'site_url': f"{settings.SITE_URL if hasattr(settings, 'SITE_URL') else 'http://localhost:8000'}", } # Render email templates subject = f"URGENT - Second SLA Reminder: Complaint #{str(complaint.id)[:8]} - {complaint.title[:50]}" try: # Try to send via NotificationService first if hasattr(NotificationService, 'send_notification'): NotificationService.send_notification( recipient=recipient, title=subject, message=( f"This is the SECOND and FINAL reminder that complaint #{str(complaint.id)[:8]} " f"is due in {int(hours_remaining)} hours. " f"URGENT action required to avoid SLA breach and escalation." ), notification_type='complaint', related_object=complaint, metadata={'event_type': 'sla_second_reminder'} ) else: # Fallback to direct email message_en = render_to_string( 'complaints/emails/sla_second_reminder_en.txt', context ) message_ar = render_to_string( 'complaints/emails/sla_second_reminder_ar.txt', context ) # Send to recipient's email recipient_email = recipient.email if hasattr(recipient, 'email') else None if recipient_email: send_mail( subject=subject, message=f"{message_en}\n\n{message_ar}", from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[recipient_email], fail_silently=False ) else: logger.warning(f"No email for recipient {recipient}") skipped_count += 1 continue # Update complaint complaint.second_reminder_sent_at = now complaint.save(update_fields=['second_reminder_sent_at']) # Create timeline entry ComplaintUpdate.objects.create( complaint=complaint, update_type='note', message=( f"SECOND SLA reminder sent to {recipient.get_full_name()}. " f"Complaint is due in {int(hours_remaining)} hours. " f"This is the final reminder before escalation." ), created_by=None, # System action metadata={ 'event_type': 'sla_second_reminder', 'hours_remaining': int(hours_remaining), 'recipient_id': str(recipient.id) } ) # Log audit from apps.core.services import create_audit_log create_audit_log( event_type='sla_second_reminder_sent', description=f"Second SLA reminder sent for complaint {complaint.id}", content_object=complaint, metadata={ 'recipient': recipient.get_full_name(), 'hours_remaining': int(hours_remaining) } ) reminder_count += 1 logger.info( f"Second SLA reminder sent for complaint {complaint.id} " f"to {recipient.get_full_name()} " f"({int(hours_remaining)} hours remaining)" ) # Trigger reminder-based escalation check (more urgent now) escalate_after_reminder.delay(str(complaint.id)) except Exception as e: logger.error(f"Failed to send second SLA reminder for complaint {complaint.id}: {str(e)}") skipped_count += 1 logger.info( f"SLA reminder check complete: {reminder_count} sent, {skipped_count} skipped" ) return { 'status': 'completed', 'reminders_sent': reminder_count, 'skipped': skipped_count } except Exception as e: error_msg = f"Error in SLA reminder task: {str(e)}" logger.error(error_msg, exc_info=True) return {'status': 'error', 'reason': error_msg}