""" Survey analytics and tracking utilities. This module provides functions to calculate survey engagement metrics: - Open rate - Completion rate - Abandonment rate - Time to complete - And other engagement metrics """ from django.db.models import Avg, Count, F, Q, Sum from django.utils import timezone from django.db.models.functions import TruncDay, TruncHour from .models import SurveyInstance, SurveyTracking def get_survey_engagement_stats(survey_template_id=None, hospital_id=None, days=30): """ Get comprehensive survey engagement statistics. Args: survey_template_id: Filter by specific survey template (optional) hospital_id: Filter by hospital (optional) days: Number of days to look back (default 30) Returns: dict: Engagement statistics """ from django.utils import timezone from datetime import timedelta cutoff_date = timezone.now() - timedelta(days=days) queryset = SurveyInstance.objects.filter(created_at__gte=cutoff_date) if survey_template_id: queryset = queryset.filter(survey_template_id=survey_template_id) if hospital_id: queryset = queryset.filter(hospital_id=hospital_id) total_sent = queryset.count() total_opened = queryset.filter(opened_at__isnull=False).count() total_completed = queryset.filter(status='completed').count() total_abandoned = queryset.filter( opened_at__isnull=False, status__in=['viewed', 'in_progress', 'abandoned'] ).count() # Calculate rates open_rate = (total_opened / total_sent * 100) if total_sent > 0 else 0 completion_rate = (total_completed / total_sent * 100) if total_sent > 0 else 0 abandonment_rate = (total_abandoned / total_opened * 100) if total_opened > 0 else 0 # Calculate average time to complete (in minutes) completed_surveys = queryset.filter( status='completed', sent_at__isnull=False, completed_at__isnull=False ).annotate( time_diff=F('completed_at') - F('sent_at') ) avg_time_minutes = 0 if completed_surveys.exists(): total_seconds = sum( s.time_diff.total_seconds() for s in completed_surveys if s.time_diff ) avg_time_minutes = total_seconds / completed_surveys.count() / 60 return { 'total_sent': total_sent, 'total_opened': total_opened, 'total_completed': total_completed, 'total_abandoned': total_abandoned, 'open_rate': round(open_rate, 2), 'completion_rate': round(completion_rate, 2), 'abandonment_rate': round(abandonment_rate, 2), 'avg_time_to_complete_minutes': round(avg_time_minutes, 2), } def get_patient_survey_timeline(patient_id): """ Get timeline of surveys for a specific patient. Args: patient_id: Patient ID Returns: list: Survey timeline with metrics """ surveys = SurveyInstance.objects.filter( patient_id=patient_id ).select_related( 'survey_template' ).order_by('-sent_at') timeline = [] for survey in surveys: # Calculate time to complete time_to_complete = None if survey.sent_at and survey.completed_at: time_to_complete = (survey.completed_at - survey.sent_at).total_seconds() elif survey.sent_at and survey.opened_at and not survey.completed_at: # Time since last activity for in-progress surveys time_to_complete = (timezone.now() - survey.opened_at).total_seconds() timeline.append({ 'survey_id': str(survey.id), 'survey_name': survey.survey_template.name, 'survey_type': survey.survey_template.survey_type, 'sent_at': survey.sent_at, 'opened_at': survey.opened_at, 'completed_at': survey.completed_at, 'status': survey.status, 'time_spent_seconds': survey.time_spent_seconds, 'time_to_complete_seconds': time_to_complete, 'open_count': getattr(survey, 'open_count', 0), 'total_score': float(survey.total_score) if survey.total_score else None, 'is_negative': survey.is_negative, 'delivery_channel': survey.delivery_channel, }) return timeline def get_survey_completion_times(survey_template_id=None, hospital_id=None, days=30): """ Get individual survey completion times. Args: survey_template_id: Filter by survey template (optional) hospital_id: Filter by hospital (optional) days: Number of days to look back (default 30) Returns: list: Survey completion times """ from django.utils import timezone from datetime import timedelta cutoff_date = timezone.now() - timedelta(days=days) queryset = SurveyInstance.objects.filter( status='completed', sent_at__isnull=False, completed_at__isnull=False, created_at__gte=cutoff_date ).select_related( 'patient', 'survey_template' ).annotate( time_to_complete=F('completed_at') - F('sent_at') ) if survey_template_id: queryset = queryset.filter(survey_template_id=survey_template_id) if hospital_id: queryset = queryset.filter(hospital_id=hospital_id) completion_times = [] for survey in queryset: if survey.time_to_complete: completion_times.append({ 'survey_id': str(survey.id), 'patient_name': survey.patient.get_full_name(), 'survey_name': survey.survey_template.name, 'sent_at': survey.sent_at, 'completed_at': survey.completed_at, 'time_to_complete_minutes': survey.time_to_complete.total_seconds() / 60, 'time_spent_seconds': survey.time_spent_seconds, 'total_score': float(survey.total_score) if survey.total_score else None, 'is_negative': survey.is_negative, }) # Sort by time to complete completion_times.sort(key=lambda x: x['time_to_complete_minutes']) return completion_times def get_survey_abandonment_analysis(survey_template_id=None, hospital_id=None, days=30): """ Analyze abandoned surveys to identify patterns. Args: survey_template_id: Filter by survey template (optional) hospital_id: Filter by hospital (optional) days: Number of days to look back (default 30) Returns: dict: Abandonment analysis """ from django.utils import timezone from datetime import timedelta cutoff_date = timezone.now() - timedelta(days=days) # Get abandoned surveys abandoned_queryset = SurveyInstance.objects.filter( opened_at__isnull=False, status__in=['viewed', 'in_progress', 'abandoned'], created_at__gte=cutoff_date ).select_related( 'survey_template', 'patient' ) if survey_template_id: abandoned_queryset = abandoned_queryset.filter(survey_template_id=survey_template_id) if hospital_id: abandoned_queryset = abandoned_queryset.filter(hospital_id=hospital_id) # Analyze abandonment by channel by_channel = {} for survey in abandoned_queryset: channel = survey.delivery_channel if channel not in by_channel: by_channel[channel] = 0 by_channel[channel] += 1 # Analyze abandonment by template by_template = {} for survey in abandoned_queryset: template_name = survey.survey_template.name if template_name not in by_template: by_template[template_name] = 0 by_template[template_name] += 1 # Calculate time until abandonment abandonment_times = [] for survey in abandoned_queryset: if survey.opened_at: time_abandoned = (timezone.now() - survey.opened_at).total_seconds() abandonment_times.append(time_abandoned) avg_abandonment_time_minutes = 0 if abandonment_times: avg_abandonment_time_minutes = sum(abandonment_times) / len(abandonment_times) / 60 return { 'total_abandoned': abandoned_queryset.count(), 'by_channel': by_channel, 'by_template': by_template, 'avg_time_until_abandonment_minutes': round(avg_abandonment_time_minutes, 2), } def get_hourly_survey_activity(hospital_id=None, days=7): """ Get survey activity by hour. Args: hospital_id: Filter by hospital (optional) days: Number of days to look back (default 7) Returns: list: Hourly activity data """ from django.utils import timezone from datetime import timedelta cutoff_date = timezone.now() - timedelta(days=days) queryset = SurveyInstance.objects.filter(created_at__gte=cutoff_date) if hospital_id: queryset = queryset.filter(hospital_id=hospital_id) # Annotate with hour activity = queryset.annotate( hour=TruncHour('created_at') ).values('hour', 'status').annotate( count=Count('id') ).order_by('hour') return list(activity) def track_survey_open(survey_instance): """ Track when a survey is opened. This should be called every time a survey is accessed. Args: survey_instance: SurveyInstance Returns: SurveyTracking: Created tracking event """ # Increment open count if not hasattr(survey_instance, 'open_count'): survey_instance.open_count = 0 survey_instance.open_count += 1 survey_instance.last_opened_at = timezone.now() # Update status if first open if not survey_instance.opened_at: survey_instance.opened_at = timezone.now() survey_instance.status = 'viewed' survey_instance.save(update_fields=['open_count', 'last_opened_at', 'opened_at', 'status']) # Create tracking event tracking = SurveyTracking.objects.create( survey_instance=survey_instance, event_type='page_view', user_agent='', # Will be filled by view ip_address='', # Will be filled by view ) return tracking def track_survey_completion(survey_instance): """ Track when a survey is completed. Args: survey_instance: SurveyInstance Returns: SurveyTracking: Created tracking event """ # Calculate time spent time_spent = None if survey_instance.opened_at and survey_instance.completed_at: time_spent = (survey_instance.completed_at - survey_instance.opened_at).total_seconds() # Update survey instance survey_instance.time_spent_seconds = int(time_spent) if time_spent else None survey_instance.save(update_fields=['time_spent_seconds']) # Create tracking event tracking = SurveyTracking.objects.create( survey_instance=survey_instance, event_type='survey_completed', total_time_spent=int(time_spent) if time_spent else None, ) return tracking def track_survey_abandonment(survey_instance): """ Track when a survey is abandoned (started but not completed). Args: survey_instance: SurveyInstance Returns: SurveyTracking: Created tracking event """ # Calculate time until abandonment time_abandoned = None if survey_instance.opened_at: time_abandoned = (timezone.now() - survey_instance.opened_at).total_seconds() # Update status survey_instance.status = 'abandoned' survey_instance.save(update_fields=['status']) # Create tracking event tracking = SurveyTracking.objects.create( survey_instance=survey_instance, event_type='survey_abandoned', total_time_spent=int(time_abandoned) if time_abandoned else None, ) return tracking