""" Survey Console UI views - Server-rendered templates for survey management """ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Q, Prefetch, Avg, Count, F, Case, When, IntegerField from django.db.models.functions import TruncDate from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.decorators.http import require_http_methods from django.db.models import ExpressionWrapper, FloatField from apps.core.services import AuditService from apps.organizations.models import Department, Hospital from .forms import SurveyQuestionFormSet, SurveyTemplateForm from .models import SurveyInstance, SurveyTemplate, SurveyQuestion from .tasks import send_satisfaction_feedback @login_required def survey_instance_list(request): """ Survey instances list view with filters. Features: - Server-side pagination - Filters (status, journey type, hospital, date range) - Search by patient MRN - Score display """ # Base queryset with optimizations queryset = SurveyInstance.objects.select_related( 'survey_template', 'patient', 'journey_instance__journey_template' ).prefetch_related( 'responses__question' ) # Apply RBAC filters user = request.user if user.is_px_admin(): pass # See all elif user.is_hospital_admin() and user.hospital: queryset = queryset.filter(survey_template__hospital=user.hospital) elif user.hospital: queryset = queryset.filter(survey_template__hospital=user.hospital) else: queryset = queryset.none() # Apply filters status_filter = request.GET.get('status') if status_filter: queryset = queryset.filter(status=status_filter) survey_type = request.GET.get('survey_type') if survey_type: queryset = queryset.filter(survey_template__survey_type=survey_type) is_negative = request.GET.get('is_negative') if is_negative == 'true': queryset = queryset.filter(is_negative=True) hospital_filter = request.GET.get('hospital') if hospital_filter: queryset = queryset.filter(survey_template__hospital_id=hospital_filter) # Search search_query = request.GET.get('search') if search_query: queryset = queryset.filter( Q(patient__mrn__icontains=search_query) | Q(patient__first_name__icontains=search_query) | Q(patient__last_name__icontains=search_query) | Q(encounter_id__icontains=search_query) ) # Date range date_from = request.GET.get('date_from') if date_from: queryset = queryset.filter(sent_at__gte=date_from) date_to = request.GET.get('date_to') if date_to: queryset = queryset.filter(sent_at__lte=date_to) # Ordering order_by = request.GET.get('order_by', '-created_at') queryset = queryset.order_by(order_by) # Pagination page_size = int(request.GET.get('page_size', 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get('page', 1) page_obj = paginator.get_page(page_number) # Get filter options hospitals = Hospital.objects.filter(status='active') if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) # Get base queryset for statistics (without pagination) stats_queryset = SurveyInstance.objects.select_related('survey_template') # Apply same RBAC filters if user.is_px_admin(): pass elif user.is_hospital_admin() and user.hospital: stats_queryset = stats_queryset.filter(survey_template__hospital=user.hospital) elif user.hospital: stats_queryset = stats_queryset.filter(survey_template__hospital=user.hospital) else: stats_queryset = stats_queryset.none() # Apply same filters to stats if status_filter: stats_queryset = stats_queryset.filter(status=status_filter) if survey_type: stats_queryset = stats_queryset.filter(survey_template__survey_type=survey_type) if is_negative == 'true': stats_queryset = stats_queryset.filter(is_negative=True) if hospital_filter: stats_queryset = stats_queryset.filter(survey_template__hospital_id=hospital_filter) if search_query: stats_queryset = stats_queryset.filter( Q(patient__mrn__icontains=search_query) | Q(patient__first_name__icontains=search_query) | Q(patient__last_name__icontains=search_query) | Q(encounter_id__icontains=search_query) ) if date_from: stats_queryset = stats_queryset.filter(sent_at__gte=date_from) if date_to: stats_queryset = stats_queryset.filter(sent_at__lte=date_to) # Statistics total_count = stats_queryset.count() # Include both 'sent' and 'pending' statuses for sent count sent_count = stats_queryset.filter(status__in=['sent', 'pending']).count() completed_count = stats_queryset.filter(status='completed').count() negative_count = stats_queryset.filter(is_negative=True).count() # Tracking statistics opened_count = stats_queryset.filter(open_count__gt=0).count() in_progress_count = stats_queryset.filter(status='in_progress').count() abandoned_count = stats_queryset.filter(status='abandoned').count() viewed_count = stats_queryset.filter(status='viewed').count() pending_count = stats_queryset.filter(status='pending').count() # Time metrics completed_surveys = stats_queryset.filter( status='completed', time_spent_seconds__isnull=False ) avg_completion_time = completed_surveys.aggregate( avg_time=Avg('time_spent_seconds') )['avg_time'] or 0 # Time to first open surveys_with_open = stats_queryset.filter( opened_at__isnull=False, sent_at__isnull=False ) if surveys_with_open.exists(): # Calculate average time to open total_time_to_open = 0 count = 0 for survey in surveys_with_open: if survey.opened_at and survey.sent_at: total_time_to_open += (survey.opened_at - survey.sent_at).total_seconds() count += 1 avg_time_to_open = total_time_to_open / count if count > 0 else 0 else: avg_time_to_open = 0 stats = { 'total': total_count, 'sent': sent_count, 'completed': completed_count, 'negative': negative_count, 'response_rate': round((completed_count / total_count * 100) if total_count > 0 else 0, 1), # New tracking stats 'opened': opened_count, 'open_rate': round((opened_count / sent_count * 100) if sent_count > 0 else 0, 1), 'in_progress': in_progress_count, 'abandoned': abandoned_count, 'viewed': viewed_count, 'pending': pending_count, 'avg_completion_time': int(avg_completion_time), 'avg_time_to_open': int(avg_time_to_open), } # Score Distribution score_distribution = [] score_ranges = [ ('1-2', 1, 2), ('2-3', 2, 3), ('3-4', 3, 4), ('4-5', 4, 5), ] for label, min_score, max_score in score_ranges: # Use lte for the highest range to include exact match if max_score == 5: count = stats_queryset.filter( total_score__gte=min_score, total_score__lte=max_score ).count() else: count = stats_queryset.filter( total_score__gte=min_score, total_score__lt=max_score ).count() score_distribution.append({ 'range': label, 'count': count, 'percentage': round((count / total_count * 100) if total_count > 0 else 0, 1) }) # Engagement Funnel Data - Include viewed and pending stages engagement_funnel = [ {'stage': 'Sent/Pending', 'count': sent_count, 'percentage': 100}, {'stage': 'Viewed', 'count': viewed_count, 'percentage': round((viewed_count / sent_count * 100) if sent_count > 0 else 0, 1)}, {'stage': 'Opened', 'count': opened_count, 'percentage': round((opened_count / sent_count * 100) if sent_count > 0 else 0, 1)}, {'stage': 'In Progress', 'count': in_progress_count, 'percentage': round((in_progress_count / opened_count * 100) if opened_count > 0 else 0, 1)}, {'stage': 'Completed', 'count': completed_count, 'percentage': round((completed_count / sent_count * 100) if sent_count > 0 else 0, 1)}, ] # Completion Time Distribution completion_time_ranges = [ ('< 1 min', 0, 60), ('1-5 min', 60, 300), ('5-10 min', 300, 600), ('10-20 min', 600, 1200), ('20+ min', 1200, float('inf')), ] completion_time_distribution = [] for label, min_seconds, max_seconds in completion_time_ranges: if max_seconds == float('inf'): count = completed_surveys.filter(time_spent_seconds__gte=min_seconds).count() else: count = completed_surveys.filter( time_spent_seconds__gte=min_seconds, time_spent_seconds__lt=max_seconds ).count() completion_time_distribution.append({ 'range': label, 'count': count, 'percentage': round((count / completed_count * 100) if completed_count > 0 else 0, 1) }) # Device Type Distribution device_distribution = [] from .models import SurveyTracking tracking_events = SurveyTracking.objects.filter( survey_instance__in=stats_queryset ).values('device_type').annotate( count=Count('id') ).order_by('-count') device_mapping = { 'mobile': 'Mobile', 'tablet': 'Tablet', 'desktop': 'Desktop', } for entry in tracking_events: device_key = entry['device_type'] device_name = device_mapping.get(device_key, device_key.title()) count = entry['count'] percentage = round((count / tracking_events.count() * 100) if tracking_events.count() > 0 else 0, 1) device_distribution.append({ 'type': device_key, 'name': device_name, 'count': count, 'percentage': percentage }) # Survey Trend (last 30 days) - Use created_at if sent_at is missing from django.utils import timezone import datetime thirty_days_ago = timezone.now() - datetime.timedelta(days=30) # Try sent_at first, fall back to created_at if sent_at is null trend_queryset = stats_queryset.filter( sent_at__gte=thirty_days_ago ) # If no surveys with sent_at in last 30 days, try created_at if not trend_queryset.exists(): trend_queryset = stats_queryset.filter( created_at__gte=thirty_days_ago ).annotate( date=TruncDate('created_at') ) else: trend_queryset = trend_queryset.annotate( date=TruncDate('sent_at') ) trend_data = trend_queryset.values('date').annotate( sent=Count('id'), completed=Count('id', filter=Q(status='completed')) ).order_by('date') trend_labels = [] trend_sent = [] trend_completed = [] for entry in trend_data: if entry['date']: trend_labels.append(entry['date'].strftime('%Y-%m-%d')) trend_sent.append(entry['sent']) trend_completed.append(entry['completed']) # Survey Type Distribution survey_type_data = stats_queryset.values( 'survey_template__survey_type' ).annotate( count=Count('id') ).order_by('-count') survey_types = [] survey_type_labels = [] survey_type_counts = [] survey_type_mapping = { 'stage': 'Journey Stage', 'complaint_resolution': 'Complaint Resolution', 'general': 'General', 'nps': 'NPS', } for entry in survey_type_data: type_key = entry['survey_template__survey_type'] type_name = survey_type_mapping.get(type_key, type_key.title()) count = entry['count'] percentage = round((count / total_count * 100) if total_count > 0 else 0, 1) survey_types.append({ 'type': type_key, 'name': type_name, 'count': count, 'percentage': percentage }) survey_type_labels.append(type_name) survey_type_counts.append(count) # Serialize chart data to JSON for clean JavaScript usage import json context = { 'page_obj': page_obj, 'surveys': page_obj.object_list, 'stats': stats, 'hospitals': hospitals, 'filters': request.GET, # Visualization data as JSON for clean JavaScript 'engagement_funnel_json': json.dumps(engagement_funnel), 'completion_time_distribution_json': json.dumps(completion_time_distribution), 'device_distribution_json': json.dumps(device_distribution), 'score_distribution_json': json.dumps(score_distribution), 'survey_types_json': json.dumps(survey_types), 'trend_labels_json': json.dumps(trend_labels), 'trend_sent_json': json.dumps(trend_sent), 'trend_completed_json': json.dumps(trend_completed), } # Debug logging import logging logger = logging.getLogger(__name__) logger.info(f"=== CHART DATA DEBUG ===") logger.info(f"Score Distribution: {score_distribution}") logger.info(f"Engagement Funnel: {engagement_funnel}") logger.info(f"Completion Time Distribution: {completion_time_distribution}") logger.info(f"Device Distribution: {device_distribution}") logger.info(f"Total surveys in stats_queryset: {total_count}") return render(request, 'surveys/instance_list.html', context) @login_required def survey_instance_detail(request, pk): """ Survey instance detail view with responses. Features: - Full survey details - All responses - Score breakdown - Related journey/stage info - Score comparison with template average - Related surveys from same patient """ survey = get_object_or_404( SurveyInstance.objects.select_related( 'survey_template', 'patient', 'journey_instance__journey_template' ).prefetch_related( 'responses__question' ), pk=pk ) # Get responses responses = survey.responses.all().order_by('question__order') # Calculate average score for this survey template template_average = SurveyInstance.objects.filter( survey_template=survey.survey_template, status='completed' ).aggregate( avg_score=Avg('total_score') )['avg_score'] or 0 # Get related surveys from the same patient related_surveys = SurveyInstance.objects.filter( patient=survey.patient, status='completed' ).exclude( id=survey.id ).select_related( 'survey_template' ).order_by('-completed_at')[:5] # Get response statistics for each question (for choice questions) question_stats = {} for response in responses: if response.question.question_type in ['multiple_choice', 'single_choice']: choice_responses = SurveyInstance.objects.filter( survey_template=survey.survey_template, status='completed' ).values( f'responses__choice_value' ).annotate( count=Count('id') ).filter( responses__question=response.question ).order_by('-count') question_stats[response.question.id] = { 'type': 'choice', 'options': [ { 'value': opt['responses__choice_value'], 'count': opt['count'], 'percentage': round((opt['count'] / choice_responses.count() * 100) if choice_responses.count() > 0 else 0, 1) } for opt in choice_responses if opt['responses__choice_value'] ] } elif response.question.question_type == 'rating': rating_stats = SurveyInstance.objects.filter( survey_template=survey.survey_template, status='completed' ).aggregate( avg_rating=Avg('responses__numeric_value'), total_responses=Count('responses') ) question_stats[response.question.id] = { 'type': 'rating', 'average': round(rating_stats['avg_rating'] or 0, 2), 'total_responses': rating_stats['total_responses'] or 0 } context = { 'survey': survey, 'responses': responses, 'template_average': round(template_average, 2), 'related_surveys': related_surveys, 'question_stats': question_stats, } return render(request, 'surveys/instance_detail.html', context) @login_required def survey_template_list(request): """Survey templates list view""" queryset = SurveyTemplate.objects.select_related('hospital').prefetch_related('questions') # Apply RBAC filters user = request.user if user.is_px_admin(): pass elif user.hospital: queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() # Apply filters survey_type = request.GET.get('survey_type') if survey_type: queryset = queryset.filter(survey_type=survey_type) hospital_filter = request.GET.get('hospital') if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) is_active = request.GET.get('is_active') if is_active == 'true': queryset = queryset.filter(is_active=True) elif is_active == 'false': queryset = queryset.filter(is_active=False) # Ordering queryset = queryset.order_by('hospital', 'survey_type', 'name') # Pagination page_size = int(request.GET.get('page_size', 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get('page', 1) page_obj = paginator.get_page(page_number) # Get filter options hospitals = Hospital.objects.filter(status='active') if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) context = { 'page_obj': page_obj, 'templates': page_obj.object_list, 'hospitals': hospitals, 'filters': request.GET, } return render(request, 'surveys/template_list.html', context) @login_required def survey_template_create(request): """Create a new survey template with questions""" # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to create survey templates.") return redirect('surveys:template_list') if request.method == 'POST': form = SurveyTemplateForm(request.POST) formset = SurveyQuestionFormSet(request.POST) if form.is_valid() and formset.is_valid(): template = form.save(commit=False) template.created_by = user template.save() questions = formset.save(commit=False) for question in questions: question.survey_template = template question.save() messages.success(request, "Survey template created successfully.") return redirect('surveys:template_detail', pk=template.pk) else: form = SurveyTemplateForm() formset = SurveyQuestionFormSet() context = { 'form': form, 'formset': formset, } return render(request, 'surveys/template_form.html', context) @login_required def survey_template_detail(request, pk): """View survey template details""" template = get_object_or_404( SurveyTemplate.objects.select_related('hospital').prefetch_related('questions'), pk=pk ) # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): if user.hospital and template.hospital != user.hospital: messages.error(request, "You don't have permission to view this template.") return redirect('surveys:template_list') # Get statistics total_instances = template.instances.count() completed_instances = template.instances.filter(status='completed').count() negative_instances = template.instances.filter(is_negative=True).count() avg_score = template.instances.filter(status='completed').aggregate( avg_score=Avg('total_score') )['avg_score'] or 0 context = { 'template': template, 'questions': template.questions.all().order_by('order'), 'stats': { 'total_instances': total_instances, 'completed_instances': completed_instances, 'negative_instances': negative_instances, 'completion_rate': round((completed_instances / total_instances * 100) if total_instances > 0 else 0, 1), 'avg_score': round(avg_score, 2), } } return render(request, 'surveys/template_detail.html', context) @login_required def survey_template_edit(request, pk): """Edit an existing survey template with questions""" template = get_object_or_404(SurveyTemplate, pk=pk) # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): if user.hospital and template.hospital != user.hospital: messages.error(request, "You don't have permission to edit this template.") return redirect('surveys:template_list') if request.method == 'POST': form = SurveyTemplateForm(request.POST, instance=template) formset = SurveyQuestionFormSet(request.POST, instance=template) if form.is_valid() and formset.is_valid(): form.save() formset.save() messages.success(request, "Survey template updated successfully.") return redirect('surveys:template_detail', pk=template.pk) else: form = SurveyTemplateForm(instance=template) formset = SurveyQuestionFormSet(instance=template) context = { 'form': form, 'formset': formset, 'template': template, } return render(request, 'surveys/template_form.html', context) @login_required def survey_template_delete(request, pk): """Delete a survey template""" template = get_object_or_404(SurveyTemplate, pk=pk) # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): if user.hospital and template.hospital != user.hospital: messages.error(request, "You don't have permission to delete this template.") return redirect('surveys:template_list') if request.method == 'POST': template_name = template.name template.delete() messages.success(request, f"Survey template '{template_name}' deleted successfully.") return redirect('surveys:template_list') context = { 'template': template, } return render(request, 'surveys/template_confirm_delete.html', context) @login_required @require_http_methods(["POST"]) def survey_log_patient_contact(request, pk): """ Log patient contact for negative survey. This records that the user contacted the patient to discuss the negative survey feedback. """ survey = get_object_or_404(SurveyInstance, pk=pk) # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): if user.hospital and survey.survey_template.hospital != user.hospital: messages.error(request, "You don't have permission to modify this survey.") return redirect('surveys:instance_detail', pk=pk) # Check if survey is negative if not survey.is_negative: messages.warning(request, "This survey is not marked as negative.") return redirect('surveys:instance_detail', pk=pk) # Get form data contact_notes = request.POST.get('contact_notes', '') issue_resolved = request.POST.get('issue_resolved') == 'on' if not contact_notes: messages.error(request, "Please provide contact notes.") return redirect('surveys:instance_detail', pk=pk) try: # Update survey survey.patient_contacted = True survey.patient_contacted_at = timezone.now() survey.patient_contacted_by = user survey.contact_notes = contact_notes survey.issue_resolved = issue_resolved survey.save(update_fields=[ 'patient_contacted', 'patient_contacted_at', 'patient_contacted_by', 'contact_notes', 'issue_resolved' ]) # Log audit AuditService.log_event( event_type='survey_patient_contacted', description=f"Patient contacted for negative survey by {user.get_full_name()}", user=user, content_object=survey, metadata={ 'contact_notes': contact_notes, 'issue_resolved': issue_resolved, 'survey_score': float(survey.total_score) if survey.total_score else None } ) status = "resolved" if issue_resolved else "discussed" messages.success(request, f"Patient contact logged successfully. Issue marked as {status}.") except Exception as e: messages.error(request, f"Error logging patient contact: {str(e)}") return redirect('surveys:instance_detail', pk=pk) @login_required @require_http_methods(["POST"]) def survey_send_satisfaction_feedback(request, pk): """ Send satisfaction feedback form to patient. This creates and sends a feedback form to assess patient satisfaction with how their negative survey concerns were addressed. """ survey = get_object_or_404(SurveyInstance, pk=pk) # Check permission user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): if user.hospital and survey.survey_template.hospital != user.hospital: messages.error(request, "You don't have permission to modify this survey.") return redirect('surveys:instance_detail', pk=pk) # Check if survey is negative if not survey.is_negative: messages.warning(request, "This survey is not marked as negative.") return redirect('surveys:instance_detail', pk=pk) # Check if patient was contacted if not survey.patient_contacted: messages.error(request, "Please log patient contact before sending satisfaction feedback.") return redirect('surveys:instance_detail', pk=pk) # Check if already sent if survey.satisfaction_feedback_sent: messages.warning(request, "Satisfaction feedback has already been sent for this survey.") return redirect('surveys:instance_detail', pk=pk) try: # Trigger async task to send satisfaction feedback send_satisfaction_feedback.delay(str(survey.id), str(user.id)) messages.success( request, "Satisfaction feedback form is being sent to the patient. " "They will receive a link to provide their feedback." ) except Exception as e: messages.error(request, f"Error sending satisfaction feedback: {str(e)}") return redirect('surveys:instance_detail', pk=pk)