HH/apps/surveys/tasks.py
2026-01-24 15:27:30 +03:00

648 lines
26 KiB
Python

"""
Survey Celery tasks
This module contains tasks for:
- Creating and sending surveys
- Sending survey reminders
- Processing survey responses
- Triggering actions based on negative feedback
"""
import logging
from celery import shared_task
from django.db import transaction
from django.utils import timezone
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3)
def create_and_send_survey(self, stage_instance_id):
"""
Create survey instance and send invitation.
This task is triggered when a journey stage completes
and auto_send_survey=True.
Args:
stage_instance_id: UUID of PatientJourneyStageInstance
Returns:
dict: Result with survey_instance_id and delivery status
"""
from apps.core.services import create_audit_log
from apps.journeys.models import PatientJourneyStageInstance
from apps.notifications.services import NotificationService
from apps.surveys.models import SurveyInstance
try:
# Get stage instance
stage_instance = PatientJourneyStageInstance.objects.select_related(
'stage_template__survey_template',
'journey_instance__patient',
'journey_instance__hospital'
).get(id=stage_instance_id)
# Verify survey template exists
if not stage_instance.stage_template.survey_template:
logger.warning(f"No survey template for stage {stage_instance.stage_template.name}")
return {'status': 'skipped', 'reason': 'no_survey_template'}
# Check if survey already created
if stage_instance.survey_instance:
logger.info(f"Survey already exists for stage instance {stage_instance_id}")
return {'status': 'skipped', 'reason': 'already_exists'}
patient = stage_instance.journey_instance.patient
# Determine delivery channel and recipient
delivery_channel = 'sms' # Default
recipient_phone = patient.phone
recipient_email = patient.email
# Create survey instance
with transaction.atomic():
survey_instance = SurveyInstance.objects.create(
survey_template=stage_instance.stage_template.survey_template,
patient=patient,
journey_instance=stage_instance.journey_instance,
encounter_id=stage_instance.journey_instance.encounter_id,
delivery_channel=delivery_channel,
recipient_phone=recipient_phone,
recipient_email=recipient_email
)
# Link survey to stage
stage_instance.survey_instance = survey_instance
stage_instance.survey_sent_at = timezone.now()
stage_instance.save(update_fields=['survey_instance', 'survey_sent_at'])
# Send survey invitation
notification_log = NotificationService.send_survey_invitation(
survey_instance=survey_instance,
language=patient.language if hasattr(patient, 'language') else 'en'
)
# Update survey instance 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"Survey sent to {patient.get_full_name()} for stage {stage_instance.stage_template.name}",
content_object=survey_instance,
metadata={
'survey_template': stage_instance.stage_template.survey_template.name,
'stage': stage_instance.stage_template.name,
'encounter_id': stage_instance.journey_instance.encounter_id,
'channel': delivery_channel
}
)
logger.info(
f"Survey created and sent: {survey_instance.id} to {patient.get_full_name()} "
f"via {delivery_channel}"
)
return {
'status': 'sent',
'survey_instance_id': str(survey_instance.id),
'notification_log_id': str(notification_log.id)
}
except PatientJourneyStageInstance.DoesNotExist:
error_msg = f"Stage instance {stage_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error creating/sending survey: {str(e)}"
logger.error(error_msg, exc_info=True)
# Retry the task
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
@shared_task
def mark_abandoned_surveys(hours=24):
"""
Mark surveys as abandoned if not completed within specified time.
This task runs periodically to check for surveys that have been opened
or started but not completed. It marks them as 'abandoned' status.
Args:
hours: Hours after which to mark survey as abandoned (default: 24)
Returns:
dict: Result with count of surveys marked as abandoned
"""
from django.conf import settings
from apps.surveys.models import SurveyInstance, SurveyTracking
from datetime import timedelta
try:
# Get hours from settings if not provided
if hours is None:
hours = getattr(settings, 'SURVEY_ABANDONMENT_HOURS', 24)
logger.info(f"Checking for abandoned surveys (cutoff: {hours} hours)")
# Calculate cutoff time
cutoff_time = timezone.now() - timedelta(hours=hours)
# Find surveys that should be marked as abandoned
surveys_to_abandon = SurveyInstance.objects.filter(
status__in=['viewed', 'in_progress'],
token_expires_at__gt=timezone.now(),
last_opened_at__lt=cutoff_time
).select_related('survey_template', 'patient')
count = surveys_to_abandon.count()
if count == 0:
logger.info('No surveys to mark as abandoned')
return {'status': 'completed', 'marked': 0}
logger.info(f"Marking {count} surveys as abandoned")
# Mark surveys as abandoned
for survey in surveys_to_abandon:
time_since_open = timezone.now() - survey.last_opened_at
# Update status
survey.status = 'abandoned'
survey.save(update_fields=['status'])
# Get question count for this survey
tracking_events = survey.tracking_events.filter(
event_type='question_answered'
)
# Track abandonment event
SurveyTracking.objects.create(
survey_instance=survey,
event_type='survey_abandoned',
current_question=tracking_events.count(),
total_time_spent=survey.time_spent_seconds or 0,
metadata={
'time_since_open_hours': round(time_since_open.total_seconds() / 3600, 2),
'questions_answered': tracking_events.count(),
'original_status': survey.status,
}
)
logger.info(f"Successfully marked {count} surveys as abandoned")
return {
'status': 'completed',
'marked': count,
'hours_cutoff': hours
}
except Exception as e:
error_msg = f"Error marking abandoned surveys: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task
def send_survey_reminder(survey_instance_id):
"""
Send reminder for incomplete survey.
Args:
survey_instance_id: UUID of SurveyInstance
Returns:
dict: Result with delivery status
"""
from apps.notifications.services import NotificationService
from apps.surveys.models import SurveyInstance
try:
survey_instance = SurveyInstance.objects.select_related(
'patient', 'survey_template'
).get(id=survey_instance_id)
# Only send reminder if survey is still pending/active
if survey_instance.status not in ['pending', 'active']:
return {'status': 'skipped', 'reason': f'survey_status_{survey_instance.status}'}
# Send reminder
patient = survey_instance.patient
language = patient.language if hasattr(patient, 'language') else 'en'
notification_log = NotificationService.send_survey_invitation(
survey_instance=survey_instance,
language=language
)
logger.info(f"Survey reminder sent for {survey_instance.id}")
return {
'status': 'sent',
'notification_log_id': str(notification_log.id)
}
except SurveyInstance.DoesNotExist:
error_msg = f"Survey instance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error sending survey reminder: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task
def process_survey_completion(survey_instance_id):
"""
Process completed survey.
This task:
1. Calculates the survey score
2. Checks if score is negative
3. Creates PXAction if negative (Phase 6)
Args:
survey_instance_id: UUID of SurveyInstance
Returns:
dict: Result with score and action status
"""
from apps.core.services import create_audit_log
from apps.surveys.models import SurveyInstance
try:
survey_instance = SurveyInstance.objects.select_related(
'survey_template', 'patient'
).get(id=survey_instance_id)
# Calculate score
score = survey_instance.calculate_score()
# Log completion
create_audit_log(
event_type='survey_completed',
description=f"Survey completed by {survey_instance.patient.get_full_name()} with score {score}",
content_object=survey_instance,
metadata={
'score': float(score) if score else None,
'is_negative': survey_instance.is_negative,
'survey_template': survey_instance.survey_template.name
}
)
logger.info(
f"Survey {survey_instance.id} completed with score {score} "
f"(negative: {survey_instance.is_negative})"
)
# If negative, create PXAction
if survey_instance.is_negative:
logger.info(f"Negative survey detected - creating PXAction")
# Check if it's a complaint resolution survey
if survey_instance.survey_template.survey_type == 'complaint_resolution':
from apps.px_action_center.tasks import create_action_from_complaint_resolution
create_action_from_complaint_resolution.delay(str(survey_instance.id))
else:
# Regular survey
from apps.px_action_center.tasks import create_action_from_survey
create_action_from_survey.delay(str(survey_instance.id))
return {
'status': 'processed',
'score': float(score) if score else None,
'is_negative': survey_instance.is_negative
}
except SurveyInstance.DoesNotExist:
error_msg = f"Survey instance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error processing survey completion: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'status': 'error', 'reason': error_msg}
@shared_task(bind=True, max_retries=3)
def create_post_discharge_survey(self, journey_instance_id):
"""
Create comprehensive post-discharge survey by merging questions from completed stages.
This task is triggered after patient discharge:
1. Gets all completed stages in the journey
2. Merges questions from each stage's survey_template
3. Creates a single comprehensive survey instance
4. Sends survey invitation to patient
Args:
journey_instance_id: UUID of PatientJourneyInstance
Returns:
dict: Result with survey_instance_id and delivery status
"""
from apps.core.services import create_audit_log
from apps.journeys.models import PatientJourneyInstance, StageStatus
from apps.notifications.services import NotificationService
from apps.surveys.models import SurveyInstance, SurveyQuestion, SurveyTemplate
try:
# Get journey instance
journey_instance = PatientJourneyInstance.objects.select_related(
'journey_template',
'patient',
'hospital'
).prefetch_related(
'stage_instances__stage_template__survey_template__questions'
).get(id=journey_instance_id)
logger.info(f"Creating post-discharge survey for journey {journey_instance_id}")
# Get all completed stages
completed_stages = journey_instance.stage_instances.filter(
status=StageStatus.COMPLETED
).select_related('stage_template__survey_template').order_by('stage_template__order')
if not completed_stages.exists():
logger.warning(f"No completed stages for journey {journey_instance_id}")
return {'status': 'skipped', 'reason': 'no_completed_stages'}
# Collect survey templates from completed stages
survey_templates = []
for stage_instance in completed_stages:
if stage_instance.stage_template.survey_template:
survey_templates.append({
'stage': stage_instance.stage_template,
'survey_template': stage_instance.stage_template.survey_template
})
logger.info(
f"Including questions from stage: {stage_instance.stage_template.name} "
f"(template: {stage_instance.stage_template.survey_template.name})"
)
if not survey_templates:
logger.warning(f"No survey templates found for completed stages in journey {journey_instance_id}")
return {'status': 'skipped', 'reason': 'no_survey_templates'}
# Create comprehensive survey template on-the-fly
from django.utils import timezone
import uuid
# Generate a unique name for this comprehensive survey
survey_name = f"Post-Discharge Survey - {journey_instance.patient.get_full_name()} - {journey_instance.encounter_id}"
survey_code = f"POST_DISCHARGE_{uuid.uuid4().hex[:8].upper()}"
# Create the survey template
comprehensive_template = SurveyTemplate.objects.create(
name=survey_name,
name_ar=f"استبيان ما بعد الخروج - {journey_instance.patient.get_full_name()}",
code=survey_code,
survey_type='general', # Use 'general' instead of SurveyType.GENERAL_FEEDBACK
hospital=journey_instance.hospital,
description=f"Comprehensive post-discharge survey for encounter {journey_instance.encounter_id}",
scoring_method='average',
negative_threshold=3.0,
is_active=True,
metadata={
'is_post_discharge_comprehensive': True,
'journey_instance_id': str(journey_instance.id),
'encounter_id': journey_instance.encounter_id,
'stages_count': len(survey_templates)
}
)
# Merge questions from all stage survey templates
question_order = 0
for stage_info in survey_templates:
stage_template = stage_info['stage']
stage_survey_template = stage_info['survey_template']
# Add section header for this stage
SurveyQuestion.objects.create(
survey_template=comprehensive_template,
text=f"--- {stage_template.name} ---",
text_ar=f"--- {stage_template.name_ar or stage_template.name} ---",
question_type='section_header',
order=question_order,
is_required=False,
weight=0,
metadata={'is_section_header': True, 'stage_name': stage_template.name}
)
question_order += 1
# Add all questions from this stage's template
for original_question in stage_survey_template.questions.filter(is_active=True).order_by('order'):
# Create a copy of the question
SurveyQuestion.objects.create(
survey_template=comprehensive_template,
text=original_question.text, # Use 'text' instead of 'question'
text_ar=original_question.text_ar, # Use 'text_ar' instead of 'question_ar'
question_type=original_question.question_type,
order=question_order,
is_required=original_question.is_required,
weight=original_question.weight,
choices_json=original_question.choices_json, # Use 'choices_json' instead of 'choices'
branch_logic=original_question.branch_logic,
metadata={
'original_question_id': str(original_question.id),
'original_stage': stage_template.name,
'original_survey_template': stage_survey_template.name
}
)
question_order += 1
logger.info(f"Added {stage_survey_template.questions.filter(is_active=True).count()} questions from {stage_template.name}")
logger.info(f"Created comprehensive survey template {comprehensive_template.id} with {question_order} items")
# Determine delivery channel and recipient
delivery_channel = 'sms' # Default
recipient_phone = journey_instance.patient.phone
recipient_email = journey_instance.patient.email
# Create survey instance
with transaction.atomic():
survey_instance = SurveyInstance.objects.create(
survey_template=comprehensive_template,
patient=journey_instance.patient,
journey_instance=journey_instance,
encounter_id=journey_instance.encounter_id,
delivery_channel=delivery_channel,
recipient_phone=recipient_phone,
recipient_email=recipient_email,
status='pending'
)
# Send survey invitation
notification_log = NotificationService.send_survey_invitation(
survey_instance=survey_instance,
language=journey_instance.patient.language if hasattr(journey_instance.patient, 'language') else 'en'
)
# Update survey instance 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='post_discharge_survey_sent',
description=f"Post-discharge survey sent to {journey_instance.patient.get_full_name()} for encounter {journey_instance.encounter_id}",
content_object=survey_instance,
metadata={
'survey_template': comprehensive_template.name,
'journey_instance': str(journey_instance.id),
'encounter_id': journey_instance.encounter_id,
'stages_included': len(survey_templates),
'total_questions': question_order,
'channel': delivery_channel
}
)
logger.info(
f"Post-discharge survey created and sent: {survey_instance.id} to "
f"{journey_instance.patient.get_full_name()} via {delivery_channel} "
f"({len(survey_templates)} stages, {question_order} questions)"
)
return {
'status': 'sent',
'survey_instance_id': str(survey_instance.id),
'survey_template_id': str(comprehensive_template.id),
'notification_log_id': str(notification_log.id),
'stages_included': len(survey_templates),
'total_questions': question_order
}
except PatientJourneyInstance.DoesNotExist:
error_msg = f"Journey instance {journey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error creating post-discharge survey: {str(e)}"
logger.error(error_msg, exc_info=True)
# Retry the task
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))
@shared_task(bind=True, max_retries=3)
def send_satisfaction_feedback(self, survey_instance_id, user_id=None):
"""
Send satisfaction feedback form to patient after negative survey contact.
This creates a feedback form linked to the survey and sends it to the patient
to assess their satisfaction with the resolution.
Args:
survey_instance_id: UUID of SurveyInstance
user_id: UUID of User who initiated the feedback (optional)
Returns:
dict: Result with feedback_id and delivery status
"""
from apps.core.services import create_audit_log
from apps.feedback.models import Feedback, FeedbackType, FeedbackResponse
from apps.notifications.services import NotificationService
from apps.surveys.models import SurveyInstance
try:
# Get survey instance
survey_instance = SurveyInstance.objects.select_related(
'patient', 'survey_template', 'journey_instance__hospital'
).get(id=survey_instance_id)
# Check if feedback already sent
if survey_instance.satisfaction_feedback_sent:
logger.warning(f"Satisfaction feedback already sent for survey {survey_instance_id}")
return {'status': 'skipped', 'reason': 'already_sent'}
# Check if patient was contacted
if not survey_instance.patient_contacted:
logger.warning(f"Patient not contacted yet for survey {survey_instance_id}")
return {'status': 'skipped', 'reason': 'patient_not_contacted'}
patient = survey_instance.patient
hospital = survey_instance.journey_instance.hospital if survey_instance.journey_instance else patient.primary_hospital
# Create satisfaction feedback
with transaction.atomic():
feedback = Feedback.objects.create(
patient=patient,
hospital=hospital,
department=None, # Department not directly linked to survey instance
feedback_type=FeedbackType.SATISFACTION_CHECK,
title=f"Satisfaction Check - {survey_instance.survey_template.name}",
message=f"Please rate your satisfaction with how we addressed your concerns regarding the survey.",
category='communication',
priority='medium',
sentiment='neutral',
status='submitted',
related_survey=survey_instance,
encounter_id=survey_instance.encounter_id,
source='system',
metadata={
'survey_id': str(survey_instance.id),
'survey_score': float(survey_instance.total_score) if survey_instance.total_score else None,
'auto_generated': True
}
)
# Create initial response
FeedbackResponse.objects.create(
feedback=feedback,
response_type='note',
message=f"Satisfaction feedback automatically created following negative survey (Score: {survey_instance.total_score})",
created_by_id=user_id,
is_internal=True
)
# Update survey instance
survey_instance.satisfaction_feedback_sent = True
survey_instance.satisfaction_feedback_sent_at = timezone.now()
survey_instance.save(update_fields=['satisfaction_feedback_sent', 'satisfaction_feedback_sent_at'])
# Send notification to patient
# TODO: Implement feedback form link notification
# For now, we'll log it
logger.info(f"Satisfaction feedback {feedback.id} created for survey {survey_instance.id}")
# Log audit event
create_audit_log(
event_type='satisfaction_feedback_sent',
description=f"Satisfaction feedback sent to {patient.get_full_name()} for survey {survey_instance.survey_template.name}",
content_object=feedback,
metadata={
'survey_id': str(survey_instance.id),
'survey_score': float(survey_instance.total_score) if survey_instance.total_score else None,
'feedback_id': str(feedback.id)
}
)
return {
'status': 'sent',
'feedback_id': str(feedback.id),
'survey_id': str(survey_instance.id)
}
except SurveyInstance.DoesNotExist:
error_msg = f"Survey instance {survey_instance_id} not found"
logger.error(error_msg)
return {'status': 'error', 'reason': error_msg}
except Exception as e:
error_msg = f"Error sending satisfaction feedback: {str(e)}"
logger.error(error_msg, exc_info=True)
# Retry the task
raise self.retry(exc=e, countdown=60 * (self.request.retries + 1))