""" 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))