HH/apps/complaints/tasks.py

773 lines
30 KiB
Python

"""
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 celery import shared_task
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
logger = logging.getLogger(__name__)
@shared_task
def check_overdue_complaints():
"""
Periodic task to check for overdue complaints.
Runs every 15 minutes (configured in config/celery.py).
Updates is_overdue flag for complaints past their SLA deadline.
Triggers automatic escalation based on escalation rules.
"""
from apps.complaints.models import Complaint, ComplaintStatus
# Get active complaints (not closed or cancelled)
active_complaints = Complaint.objects.filter(
status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED]
).select_related('hospital', 'patient', 'department')
overdue_count = 0
escalated_count = 0
for complaint in active_complaints:
if complaint.check_overdue():
overdue_count += 1
logger.warning(
f"Complaint {complaint.id} is overdue: {complaint.title} "
f"(due: {complaint.due_at})"
)
# Trigger automatic escalation
result = escalate_complaint_auto.delay(str(complaint.id))
if result:
escalated_count += 1
if overdue_count > 0:
logger.info(f"Found {overdue_count} overdue complaints, triggered {escalated_count} escalations")
return {
'overdue_count': overdue_count,
'escalated_count': escalated_count
}
@shared_task
def send_complaint_resolution_survey(complaint_id):
"""
Send resolution satisfaction survey when complaint is closed.
This task is triggered when a complaint status changes to CLOSED.
Args:
complaint_id: UUID of the Complaint
Returns:
dict: Result with survey_instance_id
"""
from apps.complaints.models import Complaint
from apps.core.services import create_audit_log
from apps.surveys.models import SurveyInstance, SurveyTemplate
try:
complaint = Complaint.objects.select_related(
'patient', 'hospital'
).get(id=complaint_id)
# Check if survey already sent
if complaint.resolution_survey:
logger.info(f"Resolution survey already sent for complaint {complaint_id}")
return {'status': 'skipped', 'reason': 'already_sent'}
# Get resolution satisfaction survey template
try:
survey_template = SurveyTemplate.objects.get(
hospital=complaint.hospital,
survey_type='complaint_resolution',
is_active=True
)
except SurveyTemplate.DoesNotExist:
logger.warning(
f"No resolution satisfaction survey template found for hospital {complaint.hospital.name}"
)
return {'status': 'skipped', 'reason': 'no_template'}
# Create survey instance
with transaction.atomic():
survey_instance = SurveyInstance.objects.create(
survey_template=survey_template,
patient=complaint.patient,
encounter_id=complaint.encounter_id,
delivery_channel='sms', # Default
recipient_phone=complaint.patient.phone,
recipient_email=complaint.patient.email,
metadata={
'complaint_id': str(complaint.id),
'complaint_title': complaint.title
}
)
# Link survey to complaint
complaint.resolution_survey = survey_instance
complaint.resolution_survey_sent_at = timezone.now()
complaint.save(update_fields=['resolution_survey', 'resolution_survey_sent_at'])
# Send survey
from apps.notifications.services import NotificationService
notification_log = NotificationService.send_survey_invitation(
survey_instance=survey_instance,
language='en' # TODO: Get from patient preference
)
# Update survey status
survey_instance.status = 'active'
survey_instance.sent_at = timezone.now()
survey_instance.save(update_fields=['status', 'sent_at'])
# Log audit event
create_audit_log(
event_type='survey_sent',
description=f"Resolution satisfaction survey sent for complaint: {complaint.title}",
content_object=survey_instance,
metadata={
'complaint_id': str(complaint.id),
'survey_template': survey_template.name
}
)
logger.info(
f"Resolution satisfaction survey sent for complaint {complaint.id}"
)
return {
'status': 'sent',
'survey_instance_id': str(survey_instance.id),
'notification_log_id': str(notification_log.id)
}
except Complaint.DoesNotExist:
error_msg = f"Complaint {complaint_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error sending resolution survey: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task
def check_resolution_survey_threshold(survey_instance_id, complaint_id):
"""
Check if resolution survey score breaches threshold and create PX Action if needed.
This task is triggered when a complaint resolution survey is completed.
Args:
survey_instance_id: UUID of the SurveyInstance
complaint_id: UUID of the Complaint
Returns:
dict: Result with action status
"""
from apps.complaints.models import Complaint, ComplaintThreshold
from apps.surveys.models import SurveyInstance
from apps.px_action_center.models import PXAction
from django.contrib.contenttypes.models import ContentType
try:
survey = SurveyInstance.objects.get(id=survey_instance_id)
complaint = Complaint.objects.select_related('hospital', 'patient').get(id=complaint_id)
# Get threshold for this hospital
try:
threshold = ComplaintThreshold.objects.get(
hospital=complaint.hospital,
threshold_type='resolution_survey_score',
is_active=True
)
except ComplaintThreshold.DoesNotExist:
logger.info(f"No resolution survey threshold configured for hospital {complaint.hospital.name_en}")
return {'status': 'no_threshold'}
# Check if threshold is breached
if threshold.check_threshold(survey.score):
logger.warning(
f"Resolution survey score {survey.score} breaches threshold {threshold.threshold_value} "
f"for complaint {complaint_id}"
)
# Create PX Action
complaint_ct = ContentType.objects.get_for_model(Complaint)
action = PXAction.objects.create(
title=f"Low Resolution Satisfaction: {complaint.title[:100]}",
description=(
f"Complaint resolution survey scored {survey.score}% "
f"(threshold: {threshold.threshold_value}%). "
f"Original complaint: {complaint.description[:200]}"
),
source='complaint_resolution_survey',
priority='high' if survey.score < 30 else 'medium',
hospital=complaint.hospital,
department=complaint.department,
patient=complaint.patient,
content_type=complaint_ct,
object_id=complaint.id,
metadata={
'complaint_id': str(complaint.id),
'survey_id': str(survey.id),
'survey_score': survey.score,
'threshold_value': threshold.threshold_value,
}
)
# Log audit
from apps.core.services import create_audit_log
create_audit_log(
event_type='px_action_created',
description=f"PX Action created from low resolution survey score",
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'survey_score': survey.score,
'trigger': 'resolution_survey_threshold'
}
)
logger.info(f"Created PX Action {action.id} from low resolution survey score")
return {
'status': 'action_created',
'action_id': str(action.id),
'survey_score': survey.score,
'threshold': threshold.threshold_value
}
else:
logger.info(f"Resolution survey score {survey.score} is above threshold {threshold.threshold_value}")
return {'status': 'threshold_not_breached', 'survey_score': survey.score}
except SurveyInstance.DoesNotExist:
error_msg = f"SurveyInstance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Complaint.DoesNotExist:
error_msg = f"Complaint {complaint_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error checking resolution survey threshold: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task
def create_action_from_complaint(complaint_id):
"""
Create PX Action from complaint (if configured).
This task is triggered when a complaint is created,
if the hospital configuration requires automatic action creation.
Args:
complaint_id: UUID of the Complaint
Returns:
dict: Result with action_id
"""
from apps.complaints.models import Complaint
from apps.organizations.models import Hospital
from apps.px_action_center.models import PXAction
from django.contrib.contenttypes.models import ContentType
try:
complaint = Complaint.objects.select_related('hospital', 'patient', 'department').get(id=complaint_id)
# Check if hospital has auto-create enabled
# For now, we'll check metadata on hospital or use a simple rule
# In production, you'd have a HospitalComplaintConfig model
# 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
)
# 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
if department := Department.objects.filter(
hospital_id=complaint.hospital.id,
name=department_name
).first():
complaint.department = department
# 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']
# 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
# 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
}
complaint.save(update_fields=['severity', 'priority', 'category', 'department', '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)
message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}, Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})"
message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}, العاطفة={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}