""" 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.http import FileResponse, HttpResponse 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 ManualSurveySendForm, SurveyQuestionFormSet, SurveyTemplateForm from .services import SurveyDeliveryService from .models import SurveyInstance, SurveyTemplate, SurveyQuestion from .tasks import send_satisfaction_feedback from datetime import datetime @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 def survey_comments_list(request): """ Survey comments list view with AI analysis. Features: - Display all survey comments with AI analysis - Filters (sentiment, survey type, hospital, date range) - Search by patient MRN - Server-side pagination - PatientType display from journey """ # Base queryset - only completed surveys with comments queryset = SurveyInstance.objects.select_related( 'survey_template', 'patient', 'journey_instance__journey_template' ).filter( status='completed', comment__isnull=False ).exclude( comment='' ) # 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 sentiment_filter = request.GET.get('sentiment') if sentiment_filter: queryset = queryset.filter( comment_analysis__sentiment=sentiment_filter ) survey_type = request.GET.get('survey_type') if survey_type: queryset = queryset.filter(survey_template__survey_type=survey_type) hospital_filter = request.GET.get('hospital') if hospital_filter: queryset = queryset.filter(survey_template__hospital_id=hospital_filter) # Patient type filter patient_type_filter = request.GET.get('patient_type') if patient_type_filter: # Map filter values to HIS codes patient_type_map = { 'outpatient': ['1'], 'inpatient': ['2', 'O'], 'emergency': ['3', 'E'], } if patient_type_filter in patient_type_map: codes = patient_type_map[patient_type_filter] queryset = queryset.filter( metadata__patient_type__in=codes ) # 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(comment__icontains=search_query) ) # Date range date_from = request.GET.get('date_from') if date_from: queryset = queryset.filter(completed_at__gte=date_from) date_to = request.GET.get('date_to') if date_to: queryset = queryset.filter(completed_at__lte=date_to) # Ordering order_by = request.GET.get('order_by', '-completed_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) # Statistics stats_queryset = SurveyInstance.objects.filter( status='completed', comment__isnull=False ).exclude( comment='' ) # Apply same RBAC filters to stats 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() total_count = stats_queryset.count() positive_count = stats_queryset.filter( comment_analysis__sentiment='positive' ).count() negative_count = stats_queryset.filter( comment_analysis__sentiment='negative' ).count() neutral_count = stats_queryset.filter( comment_analysis__sentiment='neutral' ).count() analyzed_count = stats_queryset.filter( comment_analyzed=True ).count() # Patient Type Distribution Chart Data patient_type_distribution = [] patient_type_labels = [] patient_type_mapping = { '1': 'Outpatient', '2': 'Inpatient', 'O': 'Inpatient', '3': 'Emergency', 'E': 'Emergency', } # Count each patient type outpatient_count = stats_queryset.filter( metadata__patient_type__in=['1'] ).count() inpatient_count = stats_queryset.filter( metadata__patient_type__in=['2', 'O'] ).count() emergency_count = stats_queryset.filter( metadata__patient_type__in=['3', 'E'] ).count() unknown_count = total_count - (outpatient_count + inpatient_count + emergency_count) patient_type_distribution = [ {'type': 'outpatient', 'label': 'Outpatient', 'count': outpatient_count, 'percentage': round((outpatient_count / total_count * 100) if total_count > 0 else 0, 1)}, {'type': 'inpatient', 'label': 'Inpatient', 'count': inpatient_count, 'percentage': round((inpatient_count / total_count * 100) if total_count > 0 else 0, 1)}, {'type': 'emergency', 'label': 'Emergency', 'count': emergency_count, 'percentage': round((emergency_count / total_count * 100) if total_count > 0 else 0, 1)}, {'type': 'unknown', 'label': 'N/A', 'count': unknown_count, 'percentage': round((unknown_count / total_count * 100) if total_count > 0 else 0, 1)}, ] # Sentiment by Patient Type Chart Data sentiment_by_patient_type = { 'types': ['Outpatient', 'Inpatient', 'Emergency', 'N/A'], 'positive': [], 'negative': [], 'neutral': [], } # Calculate sentiment for each patient type for pt_type, codes in [('outpatient', ['1']), ('inpatient', ['2', 'O']), ('emergency', ['3', 'E']), ('unknown', [])]: if codes: pt_queryset = stats_queryset.filter(metadata__patient_type__in=codes) else: pt_queryset = stats_queryset.exclude( metadata__patient_type__in=['1', '2', 'O', '3', 'E'] ) pt_positive = pt_queryset.filter(comment_analysis__sentiment='positive').count() pt_negative = pt_queryset.filter(comment_analysis__sentiment='negative').count() pt_neutral = pt_queryset.filter(comment_analysis__sentiment='neutral').count() sentiment_by_patient_type['positive'].append(pt_positive) sentiment_by_patient_type['negative'].append(pt_negative) sentiment_by_patient_type['neutral'].append(pt_neutral) # PatientType mapping helper - maps HIS codes to display values # HIS codes: "1"=Inpatient, "2"/"O"=Outpatient, "3"/"E"=Emergency PATIENT_TYPE_MAPPING = { '1': {'label': 'Outpatient', 'icon': 'bi-person-walking', 'color': 'bg-primary'}, '2': {'label': 'Inpatient', 'icon': 'bi-hospital', 'color': 'bg-warning'}, 'O': {'label': 'Inpatient', 'icon': 'bi-hospital', 'color': 'bg-warning'}, '3': {'label': 'Emergency', 'icon': 'bi-ambulance', 'color': 'bg-danger'}, 'E': {'label': 'Emergency', 'icon': 'bi-ambulance', 'color': 'bg-danger'}, } def get_patient_type_display(survey): """Get patient type display for a survey from HIS metadata""" # Get patient_type from survey metadata (saved by HIS adapter) patient_type_code = survey.metadata.get('patient_type') if patient_type_code: type_info = PATIENT_TYPE_MAPPING.get(str(patient_type_code)) if type_info: return { 'code': patient_type_code, 'label': type_info['label'], 'icon': type_info['icon'], 'color': type_info['color'] } # Fallback: try to get from journey if available if survey.journey_instance and survey.journey_instance.journey_template: journey_type = survey.journey_instance.journey_template.journey_type journey_mapping = { 'opd': {'label': 'Outpatient', 'icon': 'bi-person-walking', 'color': 'bg-primary'}, 'inpatient': {'label': 'Inpatient', 'icon': 'bi-hospital', 'color': 'bg-warning'}, 'ems': {'label': 'Emergency', 'icon': 'bi-ambulance', 'color': 'bg-danger'}, 'day_case': {'label': 'Day Case', 'icon': 'bi-calendar-day', 'color': 'bg-info'}, } return journey_mapping.get(journey_type, {'code': None, 'label': 'N/A', 'icon': 'bi-question-circle', 'color': 'bg-secondary'}) return {'code': None, 'label': 'N/A', 'icon': 'bi-question-circle', 'color': 'bg-secondary'} # Add patient type info to surveys for survey in page_obj.object_list: survey.patient_type = get_patient_type_display(survey) stats = { 'total': total_count, 'positive': positive_count, 'negative': negative_count, 'neutral': neutral_count, 'analyzed': analyzed_count, 'unanalyzed': total_count - analyzed_count, } # Serialize chart data to JSON import json patient_type_distribution_json = json.dumps(patient_type_distribution) sentiment_by_patient_type_json = json.dumps(sentiment_by_patient_type) context = { 'page_obj': page_obj, 'surveys': page_obj.object_list, 'stats': stats, 'hospitals': hospitals, 'filters': request.GET, # Chart data 'patient_type_distribution': patient_type_distribution, 'sentiment_by_patient_type': sentiment_by_patient_type, 'patient_type_distribution_json': patient_type_distribution_json, 'sentiment_by_patient_type_json': sentiment_by_patient_type_json, } return render(request, 'surveys/comment_list.html', context) @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) @login_required def manual_survey_send(request): """ Manually send a survey to a patient or staff member. Features: - Select survey template - Choose recipient type (patient/staff) - Search and select recipient - Choose delivery channel (email/SMS) - Add custom message (optional) - Send survey immediately """ user = request.user # Check permission if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to send surveys manually.") return redirect('surveys:instance_list') if request.method == 'POST': form = ManualSurveySendForm(user, request.POST) if form.is_valid(): try: # Get form data survey_template = form.cleaned_data['survey_template'] recipient_type = form.cleaned_data['recipient_type'] recipient_id = form.cleaned_data['recipient'] delivery_channel = form.cleaned_data['delivery_channel'] custom_message = form.cleaned_data.get('custom_message', '') # Get recipient object from apps.organizations.models import Patient, Staff if recipient_type == 'patient': recipient = get_object_or_404(Patient, pk=recipient_id) recipient_name = f"{recipient.first_name} {recipient.last_name}" else: recipient = get_object_or_404(Staff, pk=recipient_id) recipient_name = f"{recipient.first_name} {recipient.last_name}" # Check if recipient has contact info for selected channel if delivery_channel == 'email': contact_info = recipient.email if not contact_info: messages.error(request, f"{recipient_type.title()} does not have an email address.") return render(request, 'surveys/manual_send.html', {'form': form}) elif delivery_channel == 'sms': contact_info = recipient.phone if not contact_info: messages.error(request, f"{recipient_type.title()} does not have a phone number.") return render(request, 'surveys/manual_send.html', {'form': form}) # Create survey instance from .models import SurveyStatus survey_instance = SurveyInstance.objects.create( survey_template=survey_template, patient=recipient if recipient_type == 'patient' else None, staff=recipient if recipient_type == 'staff' else None, hospital=survey_template.hospital, delivery_channel=delivery_channel, recipient_email=contact_info if delivery_channel == 'email' else None, recipient_phone=contact_info if delivery_channel == 'sms' else None, status=SurveyStatus.PENDING, metadata={ 'sent_manually': True, 'sent_by': str(user.id), 'custom_message': custom_message, 'recipient_type': recipient_type } ) # Send survey success = SurveyDeliveryService.deliver_survey(survey_instance) if success: # Log audit AuditService.log_event( event_type='survey_sent_manually', description=f"Survey sent manually to {recipient_name} ({recipient_type}) by {user.get_full_name()}", user=user, content_object=survey_instance, metadata={ 'survey_template': survey_template.name, 'recipient_type': recipient_type, 'recipient_id': recipient_id, 'delivery_channel': delivery_channel, 'custom_message': custom_message } ) messages.success( request, f"Survey sent successfully to {recipient_name} via {delivery_channel.upper()}. " f"Survey ID: {survey_instance.id}" ) return redirect('surveys:instance_detail', pk=survey_instance.pk) else: messages.error(request, "Failed to send survey. Please try again.") survey_instance.delete() return render(request, 'surveys/manual_send.html', {'form': form}) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error sending survey manually: {str(e)}", exc_info=True) messages.error(request, f"Error sending survey: {str(e)}") return render(request, 'surveys/manual_send.html', {'form': form}) else: form = ManualSurveySendForm(user) context = { 'form': form, } return render(request, 'surveys/manual_send.html', context) @login_required def survey_analytics_reports(request): """ Survey analytics reports management page. Features: - List all available reports - View report details - Generate new reports - Download reports - Delete reports """ import os from django.conf import settings user = request.user # Check permission - only admins and hospital admins can view reports if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to view analytics reports.") return redirect('surveys:instance_list') output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports') # Get all reports reports = [] if os.path.exists(output_dir): for filename in os.listdir(output_dir): filepath = os.path.join(output_dir, filename) if os.path.isfile(filepath): stat = os.stat(filepath) report_type = 'unknown' if filename.endswith('.json'): report_type = 'json' elif filename.endswith('.html'): report_type = 'html' elif filename.endswith('.md'): report_type = 'markdown' reports.append({ 'filename': filename, 'type': report_type, 'size': stat.st_size, 'size_human': _human_readable_size(stat.st_size), 'created': stat.st_ctime, 'created_date': datetime.fromtimestamp(stat.st_ctime), 'modified': stat.st_mtime, }) # Sort by creation date (newest first) reports.sort(key=lambda x: x['created'], reverse=True) # Statistics total_reports = len(reports) json_reports = len([r for r in reports if r['type'] == 'json']) html_reports = len([r for r in reports if r['type'] == 'html']) markdown_reports = len([r for r in reports if r['type'] == 'markdown']) context = { 'reports': reports, 'stats': { 'total': total_reports, 'json': json_reports, 'html': html_reports, 'markdown': markdown_reports, }, } return render(request, 'surveys/analytics_reports.html', context) @login_required def survey_analytics_report_view(request, filename): """ View a specific survey analytics report. Features: - Display HTML reports in browser - Provide download links for all formats - Show report metadata """ import os from django.conf import settings user = request.user # Check permission if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to view analytics reports.") return redirect('surveys:analytics_reports') output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports') filepath = os.path.join(output_dir, filename) # Security check - ensure file is in reports directory if not os.path.abspath(filepath).startswith(os.path.abspath(output_dir)): messages.error(request, "Invalid report file.") return redirect('surveys:analytics_reports') if not os.path.exists(filepath): messages.error(request, "Report file not found.") return redirect('surveys:analytics_reports') # Determine report type report_type = 'unknown' if filename.endswith('.json'): report_type = 'json' elif filename.endswith('.html'): report_type = 'html' elif filename.endswith('.md'): report_type = 'markdown' # Get file info stat = os.stat(filepath) # For HTML files, render them directly if report_type == 'html': with open(filepath, 'r', encoding='utf-8') as f: content = f.read() context = { 'filename': filename, 'content': content, 'report_type': report_type, 'size': stat.st_size, 'size_human': _human_readable_size(stat.st_size), 'created_date': datetime.fromtimestamp(stat.st_ctime), 'modified_date': datetime.fromtimestamp(stat.st_mtime), } return render(request, 'surveys/analytics_report_view.html', context) # For JSON and Markdown files, show info and provide download context = { 'filename': filename, 'report_type': report_type, 'size': stat.st_size, 'size_human': _human_readable_size(stat.st_size), 'created_date': datetime.fromtimestamp(stat.st_ctime), 'modified_date': datetime.fromtimestamp(stat.st_mtime), } return render(request, 'surveys/analytics_report_info.html', context) @login_required def survey_analytics_report_download(request, filename): """ Download a survey analytics report file. """ import os from django.conf import settings from django.http import FileResponse user = request.user # Check permission if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to download analytics reports.") return redirect('surveys:analytics_reports') output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports') filepath = os.path.join(output_dir, filename) # Security check if not os.path.abspath(filepath).startswith(os.path.abspath(output_dir)): messages.error(request, "Invalid report file.") return redirect('surveys:analytics_reports') if not os.path.exists(filepath): messages.error(request, "Report file not found.") return redirect('surveys:analytics_reports') # Determine content type content_type = 'application/octet-stream' if filename.endswith('.html'): content_type = 'text/html' elif filename.endswith('.json'): content_type = 'application/json' elif filename.endswith('.md'): content_type = 'text/markdown' # Send file return FileResponse( open(filepath, 'rb'), content_type=content_type, as_attachment=True, filename=filename ) @login_required def survey_analytics_report_view_inline(request, filename): """ View a survey analytics report inline in the browser. Unlike download, this displays the file content directly in the browser without forcing a download. Works for HTML, JSON, and Markdown files. """ import os from django.conf import settings from django.http import FileResponse, HttpResponse user = request.user # Check permission if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to view analytics reports.") return redirect('surveys:analytics_reports') output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports') filepath = os.path.join(output_dir, filename) # Security check if not os.path.abspath(filepath).startswith(os.path.abspath(output_dir)): messages.error(request, "Invalid report file.") return redirect('surveys:analytics_reports') if not os.path.exists(filepath): messages.error(request, "Report file not found.") return redirect('surveys:analytics_reports') # Determine content type content_type = 'application/octet-stream' if filename.endswith('.html'): content_type = 'text/html' elif filename.endswith('.json'): content_type = 'application/json' elif filename.endswith('.md'): content_type = 'text/plain; charset=utf-8' # Plain text for markdown # For HTML and JSON, serve inline with FileResponse if filename.endswith(('.html', '.json')): response = FileResponse( open(filepath, 'rb'), content_type=content_type, as_attachment=False # Display inline ) # Add CSP header to allow inline scripts for HTML reports if filename.endswith('.html'): response['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' cdn.jsdelivr.net cdnjs.cloudflare.com;" return response # For Markdown, render it in a template with viewer elif filename.endswith('.md'): with open(filepath, 'r', encoding='utf-8') as f: content = f.read() stat = os.stat(filepath) context = { 'filename': filename, 'content': content, 'report_type': 'markdown', 'size': stat.st_size, 'size_human': _human_readable_size(stat.st_size), 'created_date': datetime.fromtimestamp(stat.st_ctime), 'modified_date': datetime.fromtimestamp(stat.st_mtime), } return render(request, 'surveys/analytics_report_markdown_view.html', context) # For other files, fallback to download return FileResponse( open(filepath, 'rb'), content_type=content_type, as_attachment=True, filename=filename ) @login_required @require_http_methods(["POST"]) def survey_analytics_report_delete(request, filename): """ Delete a survey analytics report file. """ import os from django.conf import settings user = request.user # Check permission - only PX admins can delete reports if not user.is_px_admin(): messages.error(request, "You don't have permission to delete analytics reports.") return redirect('surveys:analytics_reports') output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports') filepath = os.path.join(output_dir, filename) # Security check if not os.path.abspath(filepath).startswith(os.path.abspath(output_dir)): messages.error(request, "Invalid report file.") return redirect('surveys:analytics_reports') if not os.path.exists(filepath): messages.error(request, "Report file not found.") return redirect('surveys:analytics_reports') try: os.remove(filepath) messages.success(request, f"Report '{filename}' deleted successfully.") except Exception as e: messages.error(request, f"Error deleting report: {str(e)}") return redirect('surveys:analytics_reports') def _human_readable_size(size_bytes): """Convert bytes to human readable format""" for unit in ['B', 'KB', 'MB', 'GB']: if size_bytes < 1024.0: return f"{size_bytes:.2f} {unit}" size_bytes /= 1024.0 return f"{size_bytes:.2f} TB" # ============================================================================ # ENHANCED SURVEY REPORTS - Separate reports per survey type # ============================================================================ @login_required def enhanced_survey_reports_list(request): """ List all enhanced survey report directories. """ from django.conf import settings import os user = request.user # Check permission if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to view analytics reports.") return redirect('surveys:analytics_reports') output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports') # Find all report directories (folders starting with "reports_") report_sets = [] if os.path.exists(output_dir): for item in sorted(os.listdir(output_dir), reverse=True): item_path = os.path.join(output_dir, item) if os.path.isdir(item_path) and item.startswith('reports_'): # Check if it has an index.html index_path = os.path.join(item_path, 'index.html') if os.path.exists(index_path): stat = os.stat(item_path) # Count individual reports report_count = len([f for f in os.listdir(item_path) if f.endswith('.html') and f != 'index.html']) report_sets.append({ 'dir_name': item, 'created': datetime.fromtimestamp(stat.st_ctime), 'modified': datetime.fromtimestamp(stat.st_mtime), 'report_count': report_count, 'size': sum(os.path.getsize(os.path.join(item_path, f)) for f in os.listdir(item_path) if os.path.isfile(os.path.join(item_path, f))) }) context = { 'report_sets': report_sets, } return render(request, 'surveys/enhanced_reports_list.html', context) @login_required def enhanced_survey_report_view(request, dir_name): """ View the index of an enhanced report set. """ from django.conf import settings import os user = request.user # Check permission if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to view analytics reports.") return redirect('surveys:analytics_reports') output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports') dir_path = os.path.join(output_dir, dir_name) # Security check if not os.path.abspath(dir_path).startswith(os.path.abspath(output_dir)): messages.error(request, "Invalid report directory.") return redirect('surveys:enhanced_reports_list') if not os.path.exists(dir_path): messages.error(request, "Report directory not found.") return redirect('surveys:enhanced_reports_list') # Serve the index.html file index_path = os.path.join(dir_path, 'index.html') if os.path.exists(index_path): with open(index_path, 'r', encoding='utf-8') as f: content = f.read() return HttpResponse(content, content_type='text/html') messages.error(request, "Index file not found.") return redirect('surveys:enhanced_reports_list') @login_required def enhanced_survey_report_file(request, dir_name, filename): """ View/serve an individual enhanced report file. """ from django.conf import settings import os from django.http import FileResponse user = request.user # Check permission if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to view analytics reports.") return redirect('surveys:analytics_reports') output_dir = getattr(settings, 'SURVEY_REPORTS_DIR', 'reports') dir_path = os.path.join(output_dir, dir_name) filepath = os.path.join(dir_path, filename) # Security check if not os.path.abspath(filepath).startswith(os.path.abspath(dir_path)): messages.error(request, "Invalid file path.") return redirect('surveys:enhanced_reports_list') if not os.path.exists(filepath): messages.error(request, "File not found.") return redirect('surveys:enhanced_reports_list') # Serve file based on type if filename.endswith('.html'): with open(filepath, 'r', encoding='utf-8') as f: content = f.read() response = HttpResponse(content, content_type='text/html') # Allow inline scripts/CDN resources response['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com unpkg.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net unpkg.com; style-src 'self' 'unsafe-inline' cdn.jsdelivr.net cdnjs.cloudflare.com fonts.googleapis.com; font-src 'self' fonts.gstatic.com;" return response elif filename.endswith('.json'): return FileResponse(open(filepath, 'rb'), content_type='application/json') else: return FileResponse(open(filepath, 'rb'), as_attachment=True) @login_required def generate_enhanced_report_ui(request): """ UI view to generate enhanced reports with form. """ from apps.surveys.models import SurveyTemplate from django.conf import settings user = request.user # Check permission if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, "You don't have permission to generate analytics reports.") return redirect('surveys:analytics_reports') if request.method == 'POST': template_id = request.POST.get('template') start_date = request.POST.get('start_date') end_date = request.POST.get('end_date') try: # Get template name if specified template_name = None if template_id: try: template = SurveyTemplate.objects.get(id=template_id) template_name = template.name except SurveyTemplate.DoesNotExist: pass # Parse dates start_dt = None end_dt = None if start_date: start_dt = datetime.strptime(start_date, '%Y-%m-%d').date() if end_date: end_dt = datetime.strptime(end_date, '%Y-%m-%d').date() # Generate enhanced reports from .analytics_utils import generate_enhanced_survey_reports result = generate_enhanced_survey_reports( template_name=template_name, start_date=start_dt, end_date=end_dt ) messages.success( request, f"Generated {len(result['individual_reports'])} reports successfully!" ) # Redirect to the report directory dir_name = os.path.basename(result['reports_dir']) return redirect('surveys:enhanced_report_view', dir_name=dir_name) except Exception as e: messages.error(request, f"Error generating reports: {str(e)}") return redirect('surveys:enhanced_reports_list') # GET request - show form templates = SurveyTemplate.objects.filter(is_active=True).order_by('name') context = { 'templates': templates, } return render(request, 'surveys/generate_enhanced_report.html', context)