diff --git a/apps/analytics/ui_views.py b/apps/analytics/ui_views.py index 69a6b60..6999334 100644 --- a/apps/analytics/ui_views.py +++ b/apps/analytics/ui_views.py @@ -1,6 +1,7 @@ """ Analytics Console UI views """ + from datetime import datetime from django.contrib.auth.decorators import login_required @@ -25,14 +26,14 @@ import json def serialize_queryset_values(queryset): """Properly serialize QuerySet values to JSON string.""" if queryset is None: - return '[]' + return "[]" data = list(queryset) result = [] for item in data: row = {} for key, value in item.items(): # Convert UUID to string - if hasattr(value, 'hex'): # UUID object + if hasattr(value, "hex"): # UUID object row[key] = str(value) # Convert Python None to JavaScript null elif value is None: @@ -60,11 +61,11 @@ def analytics_dashboard(request): from django.utils import timezone from datetime import timedelta from django.db.models.functions import TruncDate, TruncMonth - + user = request.user # Get hospital filter - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: hospital = Hospital.objects.filter(id=hospital_filter).first() elif user.hospital: @@ -75,7 +76,7 @@ def analytics_dashboard(request): # Base querysets complaints_queryset = Complaint.objects.all() actions_queryset = PXAction.objects.all() - surveys_queryset = SurveyInstance.objects.filter(status='completed') + surveys_queryset = SurveyInstance.objects.filter(status="completed") feedback_queryset = Feedback.objects.all() if hospital: @@ -86,117 +87,128 @@ def analytics_dashboard(request): # ============ COMPLAINTS KPIs ============ total_complaints = complaints_queryset.count() - open_complaints = complaints_queryset.filter(status='open').count() - in_progress_complaints = complaints_queryset.filter(status='in_progress').count() - resolved_complaints = complaints_queryset.filter(status='resolved').count() - closed_complaints = complaints_queryset.filter(status='closed').count() + open_complaints = complaints_queryset.filter(status="open").count() + in_progress_complaints = complaints_queryset.filter(status="in_progress").count() + resolved_complaints = complaints_queryset.filter(status="resolved").count() + closed_complaints = complaints_queryset.filter(status="closed").count() overdue_complaints = complaints_queryset.filter(is_overdue=True).count() - + # Complaint sources - complaint_sources = complaints_queryset.values('source').annotate(count=Count('id')).order_by('-count')[:6] + complaint_sources = complaints_queryset.values("source").annotate(count=Count("id")).order_by("-count")[:6] # Complaint domains (Level 1) - top_domains = complaints_queryset.filter(domain__isnull=False).values('domain__name_en').annotate(count=Count('id')).order_by('-count')[:5] + top_domains = ( + complaints_queryset.filter(domain__isnull=False) + .values("domain__name_en") + .annotate(count=Count("id")) + .order_by("-count")[:5] + ) # Complaint categories (Level 2) - top_categories = complaints_queryset.filter(category__isnull=False).values('category__name_en').annotate(count=Count('id')).order_by('-count')[:5] + top_categories = ( + complaints_queryset.filter(category__isnull=False) + .values("category__name_en") + .annotate(count=Count("id")) + .order_by("-count")[:5] + ) # Complaint severity - severity_breakdown = complaints_queryset.values('severity').annotate(count=Count('id')).order_by('-count') - + severity_breakdown = complaints_queryset.values("severity").annotate(count=Count("id")).order_by("-count") + # Status breakdown - status_breakdown = complaints_queryset.values('status').annotate(count=Count('id')).order_by('-count') + status_breakdown = complaints_queryset.values("status").annotate(count=Count("id")).order_by("-count") # ============ ACTIONS KPIs ============ total_actions = actions_queryset.count() - open_actions = actions_queryset.filter(status='open').count() - in_progress_actions = actions_queryset.filter(status='in_progress').count() - approved_actions = actions_queryset.filter(status='approved').count() - closed_actions = actions_queryset.filter(status='closed').count() + open_actions = actions_queryset.filter(status="open").count() + in_progress_actions = actions_queryset.filter(status="in_progress").count() + approved_actions = actions_queryset.filter(status="approved").count() + closed_actions = actions_queryset.filter(status="closed").count() overdue_actions = actions_queryset.filter(is_overdue=True).count() - + # Action sources - action_sources = actions_queryset.values('source_type').annotate(count=Count('id')).order_by('-count')[:6] - + action_sources = actions_queryset.values("source_type").annotate(count=Count("id")).order_by("-count")[:6] + # Action categories - action_categories = actions_queryset.exclude(category='').values('category').annotate(count=Count('id')).order_by('-count')[:5] + action_categories = ( + actions_queryset.exclude(category="").values("category").annotate(count=Count("id")).order_by("-count")[:5] + ) # ============ SURVEYS KPIs ============ total_surveys = surveys_queryset.count() - avg_survey_score = surveys_queryset.aggregate(avg=Avg('total_score'))['avg'] or 0 + avg_survey_score = surveys_queryset.aggregate(avg=Avg("total_score"))["avg"] or 0 negative_surveys = surveys_queryset.filter(is_negative=True).count() - + # Survey completion rate all_surveys = SurveyInstance.objects.all() if hospital: all_surveys = all_surveys.filter(survey_template__hospital=hospital) total_sent = all_surveys.count() - completed_surveys = all_surveys.filter(status='completed').count() + completed_surveys = all_surveys.filter(status="completed").count() completion_rate = (completed_surveys / total_sent * 100) if total_sent > 0 else 0 - + # Survey types - survey_types = all_surveys.values('survey_template__survey_type').annotate(count=Count('id')).order_by('-count')[:5] + survey_types = all_surveys.values("survey_template__survey_type").annotate(count=Count("id")).order_by("-count")[:5] # ============ FEEDBACK KPIs ============ total_feedback = feedback_queryset.count() - compliments = feedback_queryset.filter(feedback_type='compliment').count() - suggestions = feedback_queryset.filter(feedback_type='suggestion').count() - + compliments = feedback_queryset.filter(feedback_type="compliment").count() + suggestions = feedback_queryset.filter(feedback_type="suggestion").count() + # Sentiment analysis - sentiment_breakdown = feedback_queryset.values('sentiment').annotate(count=Count('id')).order_by('-count') - + sentiment_breakdown = feedback_queryset.values("sentiment").annotate(count=Count("id")).order_by("-count") + # Feedback categories - feedback_categories = feedback_queryset.values('category').annotate(count=Count('id')).order_by('-count')[:5] - + feedback_categories = feedback_queryset.values("category").annotate(count=Count("id")).order_by("-count")[:5] + # Average rating - avg_rating = feedback_queryset.filter(rating__isnull=False).aggregate(avg=Avg('rating'))['avg'] or 0 + avg_rating = feedback_queryset.filter(rating__isnull=False).aggregate(avg=Avg("rating"))["avg"] or 0 # ============ TRENDS (Last 30 days) ============ thirty_days_ago = timezone.now() - timedelta(days=30) - + # Complaint trends - complaint_trend = complaints_queryset.filter( - created_at__gte=thirty_days_ago - ).annotate( - day=TruncDate('created_at') - ).values('day').annotate(count=Count('id')).order_by('day') - + complaint_trend = ( + complaints_queryset.filter(created_at__gte=thirty_days_ago) + .annotate(day=TruncDate("created_at")) + .values("day") + .annotate(count=Count("id")) + .order_by("day") + ) + # Survey score trend - survey_score_trend = surveys_queryset.filter( - completed_at__gte=thirty_days_ago - ).annotate( - day=TruncDate('completed_at') - ).values('day').annotate(avg_score=Avg('total_score')).order_by('day') + survey_score_trend = ( + surveys_queryset.filter(completed_at__gte=thirty_days_ago) + .annotate(day=TruncDate("completed_at")) + .values("day") + .annotate(avg_score=Avg("total_score")) + .order_by("day") + ) # ============ DEPARTMENT RANKINGS ============ - department_rankings = Department.objects.filter( - status='active' - ).annotate( - avg_score=Avg( - 'journey_instances__surveys__total_score', - filter=Q(journey_instances__surveys__status='completed') - ), - survey_count=Count( - 'journey_instances__surveys', - filter=Q(journey_instances__surveys__status='completed') - ), - complaint_count=Count('complaints'), - action_count=Count('px_actions') - ).filter( - survey_count__gt=0 - ).order_by('-avg_score')[:7] + department_rankings = ( + Department.objects.filter(status="active") + .annotate( + avg_score=Avg( + "journey_instances__surveys__total_score", filter=Q(journey_instances__surveys__status="completed") + ), + survey_count=Count("journey_instances__surveys", filter=Q(journey_instances__surveys__status="completed")), + complaint_count=Count("complaints"), + action_count=Count("px_actions"), + ) + .filter(survey_count__gt=0) + .order_by("-avg_score")[:7] + ) # ============ TIME-BASED CALCULATIONS ============ # Average resolution time (complaints) resolved_with_time = complaints_queryset.filter( - status__in=['resolved', 'closed'], - resolved_at__isnull=False, - created_at__isnull=False + status__in=["resolved", "closed"], resolved_at__isnull=False, created_at__isnull=False ) if resolved_with_time.exists(): avg_resolution_hours = resolved_with_time.annotate( - resolution_time=F('resolved_at') - F('created_at') - ).aggregate(avg=Avg('resolution_time'))['avg'] + resolution_time=F("resolved_at") - F("created_at") + ).aggregate(avg=Avg("resolution_time"))["avg"] if avg_resolution_hours: avg_resolution_hours = avg_resolution_hours.total_seconds() / 3600 else: @@ -206,14 +218,12 @@ def analytics_dashboard(request): # Average action completion time closed_actions_with_time = actions_queryset.filter( - status='closed', - closed_at__isnull=False, - created_at__isnull=False + status="closed", closed_at__isnull=False, created_at__isnull=False ) if closed_actions_with_time.exists(): - avg_action_days = closed_actions_with_time.annotate( - completion_time=F('closed_at') - F('created_at') - ).aggregate(avg=Avg('completion_time'))['avg'] + avg_action_days = closed_actions_with_time.annotate(completion_time=F("closed_at") - F("created_at")).aggregate( + avg=Avg("completion_time") + )["avg"] if avg_action_days: avg_action_days = avg_action_days.days else: @@ -224,17 +234,13 @@ def analytics_dashboard(request): # ============ SLA COMPLIANCE ============ total_with_sla = complaints_queryset.filter(due_at__isnull=False).count() resolved_within_sla = complaints_queryset.filter( - status__in=['resolved', 'closed'], - resolved_at__lte=F('due_at') + status__in=["resolved", "closed"], resolved_at__lte=F("due_at") ).count() sla_compliance = (resolved_within_sla / total_with_sla * 100) if total_with_sla > 0 else 0 # ============ NPS CALCULATION ============ # NPS = % Promoters (9-10) - % Detractors (0-6) - nps_surveys = surveys_queryset.filter( - survey_template__survey_type='nps', - total_score__isnull=False - ) + nps_surveys = surveys_queryset.filter(survey_template__survey_type="nps", total_score__isnull=False) if nps_surveys.exists(): promoters = nps_surveys.filter(total_score__gte=9).count() detractors = nps_surveys.filter(total_score__lte=6).count() @@ -243,76 +249,52 @@ def analytics_dashboard(request): else: nps_score = 0 - # Get hospitals for filter - hospitals = Hospital.objects.filter(status='active') - if not user.is_px_admin() and user.hospital: - hospitals = hospitals.filter(id=user.hospital.id) - - # Build comprehensive KPI data kpis = { - # Complaints - 'total_complaints': total_complaints, - 'open_complaints': open_complaints, - 'in_progress_complaints': in_progress_complaints, - 'resolved_complaints': resolved_complaints, - 'closed_complaints': closed_complaints, - 'overdue_complaints': overdue_complaints, - 'avg_resolution_hours': round(avg_resolution_hours, 1), - 'sla_compliance': round(sla_compliance, 1), - - # Actions - 'total_actions': total_actions, - 'open_actions': open_actions, - 'in_progress_actions': in_progress_actions, - 'approved_actions': approved_actions, - 'closed_actions': closed_actions, - 'overdue_actions': overdue_actions, - 'avg_action_days': round(avg_action_days, 1), - - # Surveys - 'total_surveys': total_surveys, - 'avg_survey_score': round(avg_survey_score, 2), - 'nps_score': round(nps_score, 1), - 'negative_surveys': negative_surveys, - 'completion_rate': round(completion_rate, 1), - - # Feedback - 'total_feedback': total_feedback, - 'compliments': compliments, - 'suggestions': suggestions, - 'avg_rating': round(avg_rating, 2), + "total_complaints": total_complaints, + "open_complaints": open_complaints, + "in_progress_complaints": in_progress_complaints, + "resolved_complaints": resolved_complaints, + "closed_complaints": closed_complaints, + "overdue_complaints": overdue_complaints, + "avg_resolution_hours": round(avg_resolution_hours, 1), + "sla_compliance": round(sla_compliance, 1), + "total_actions": total_actions, + "open_actions": open_actions, + "in_progress_actions": in_progress_actions, + "approved_actions": approved_actions, + "closed_actions": closed_actions, + "overdue_actions": overdue_actions, + "avg_action_days": round(avg_action_days, 1), + "total_surveys": total_surveys, + "avg_survey_score": round(avg_survey_score, 2), + "nps_score": round(nps_score, 1), + "negative_surveys": negative_surveys, + "completion_rate": round(completion_rate, 1), + "total_feedback": total_feedback, + "compliments": compliments, + "suggestions": suggestions, + "avg_rating": round(avg_rating, 2), } context = { - 'kpis': kpis, - 'hospitals': hospitals, - 'selected_hospital': hospital, - - # Complaint analytics - serialize properly for JSON - 'complaint_sources': serialize_queryset_values(complaint_sources), - 'top_domains': serialize_queryset_values(top_domains), - 'top_categories': serialize_queryset_values(top_categories), - 'severity_breakdown': serialize_queryset_values(severity_breakdown), - 'status_breakdown': serialize_queryset_values(status_breakdown), - 'complaint_trend': serialize_queryset_values(complaint_trend), - - # Action analytics - 'action_sources': serialize_queryset_values(action_sources), - 'action_categories': serialize_queryset_values(action_categories), - - # Survey analytics - 'survey_types': serialize_queryset_values(survey_types), - 'survey_score_trend': serialize_queryset_values(survey_score_trend), - - # Feedback analytics - 'sentiment_breakdown': serialize_queryset_values(sentiment_breakdown), - 'feedback_categories': serialize_queryset_values(feedback_categories), - - # Department rankings - 'department_rankings': department_rankings, + "kpis": kpis, + "selected_hospital": hospital, + "complaint_sources": serialize_queryset_values(complaint_sources), + "top_domains": serialize_queryset_values(top_domains), + "top_categories": serialize_queryset_values(top_categories), + "severity_breakdown": serialize_queryset_values(severity_breakdown), + "status_breakdown": serialize_queryset_values(status_breakdown), + "complaint_trend": serialize_queryset_values(complaint_trend), + "action_sources": serialize_queryset_values(action_sources), + "action_categories": serialize_queryset_values(action_categories), + "survey_types": serialize_queryset_values(survey_types), + "survey_score_trend": serialize_queryset_values(survey_score_trend), + "sentiment_breakdown": serialize_queryset_values(sentiment_breakdown), + "feedback_categories": serialize_queryset_values(feedback_categories), + "department_rankings": department_rankings, } - return render(request, 'analytics/dashboard.html', context) + return render(request, "analytics/dashboard.html", context) @block_source_user @@ -320,34 +302,34 @@ def analytics_dashboard(request): def kpi_list(request): """KPI definitions list view""" queryset = KPI.objects.all() - + # Apply filters - category_filter = request.GET.get('category') + category_filter = request.GET.get("category") if category_filter: queryset = queryset.filter(category=category_filter) - - is_active = request.GET.get('is_active') - if is_active == 'true': + + is_active = request.GET.get("is_active") + if is_active == "true": queryset = queryset.filter(is_active=True) - elif is_active == 'false': + elif is_active == "false": queryset = queryset.filter(is_active=False) - + # Ordering - queryset = queryset.order_by('category', 'name') - + queryset = queryset.order_by("category", "name") + # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) - + context = { - 'page_obj': page_obj, - 'kpis': page_obj.object_list, - 'filters': request.GET, + "page_obj": page_obj, + "kpis": page_obj.object_list, + "filters": request.GET, } - - return render(request, 'analytics/kpi_list.html', context) + + return render(request, "analytics/kpi_list.html", context) @block_source_user @@ -355,7 +337,7 @@ def kpi_list(request): def command_center(request): """ PX Command Center - Unified Dashboard - + Comprehensive dashboard showing all PX360 metrics: - Complaints, Surveys, Actions KPIs - Interactive charts with ApexCharts @@ -363,53 +345,52 @@ def command_center(request): - Export to Excel/PDF """ user = request.user - + # Get filter parameters filters = { - 'date_range': request.GET.get('date_range', '30d'), - 'hospital': request.GET.get('hospital', ''), - 'department': request.GET.get('department', ''), - 'kpi_category': request.GET.get('kpi_category', ''), - 'custom_start': request.GET.get('custom_start', ''), - 'custom_end': request.GET.get('custom_end', ''), + "date_range": request.GET.get("date_range", "30d"), + "hospital": request.GET.get("hospital", ""), + "department": request.GET.get("department", ""), + "kpi_category": request.GET.get("kpi_category", ""), + "custom_start": request.GET.get("custom_start", ""), + "custom_end": request.GET.get("custom_end", ""), } - + # Get hospitals for filter - hospitals = Hospital.objects.filter(status='active') + hospitals = Hospital.objects.filter(status="active") if not user.is_px_admin() and user.hospital: hospitals = hospitals.filter(id=user.hospital.id) - + # Get departments for filter - departments = Department.objects.filter(status='active') - if filters.get('hospital'): - departments = departments.filter(hospital_id=filters['hospital']) + departments = Department.objects.filter(status="active") + if filters.get("hospital"): + departments = departments.filter(hospital_id=filters["hospital"]) elif not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) - + # Get initial KPIs custom_start = None custom_end = None - if filters['custom_start'] and filters['custom_end']: - custom_start = datetime.strptime(filters['custom_start'], '%Y-%m-%d') - custom_end = datetime.strptime(filters['custom_end'], '%Y-%m-%d') - + if filters["custom_start"] and filters["custom_end"]: + custom_start = datetime.strptime(filters["custom_start"], "%Y-%m-%d") + custom_end = datetime.strptime(filters["custom_end"], "%Y-%m-%d") + kpis = UnifiedAnalyticsService.get_all_kpis( user=user, - date_range=filters['date_range'], - hospital_id=filters['hospital'] if filters['hospital'] else None, - department_id=filters['department'] if filters['department'] else None, + date_range=filters["date_range"], + hospital_id=filters["hospital"] if filters["hospital"] else None, + department_id=filters["department"] if filters["department"] else None, custom_start=custom_start, - custom_end=custom_end + custom_end=custom_end, ) - + context = { - 'filters': filters, - 'hospitals': hospitals, - 'departments': departments, - 'kpis': kpis, + "filters": filters, + "departments": departments, + "kpis": kpis, } - - return render(request, 'analytics/command_center.html', context) + + return render(request, "analytics/command_center.html", context) @block_source_user @@ -417,39 +398,39 @@ def command_center(request): def command_center_api(request): """ API endpoint for Command Center data - + Returns JSON data for KPIs, charts, and tables based on filters. Used by JavaScript to dynamically update dashboard. """ - if request.method != 'GET': - return JsonResponse({'error': 'Only GET requests allowed'}, status=405) - + if request.method != "GET": + return JsonResponse({"error": "Only GET requests allowed"}, status=405) + user = request.user - + # Get filter parameters - date_range = request.GET.get('date_range', '30d') - hospital_id = request.GET.get('hospital') - department_id = request.GET.get('department') - kpi_category = request.GET.get('kpi_category') - custom_start_str = request.GET.get('custom_start') - custom_end_str = request.GET.get('custom_end') - + date_range = request.GET.get("date_range", "30d") + hospital_id = request.GET.get("hospital") + department_id = request.GET.get("department") + kpi_category = request.GET.get("kpi_category") + custom_start_str = request.GET.get("custom_start") + custom_end_str = request.GET.get("custom_end") + # Parse custom dates custom_start = None custom_end = None if custom_start_str and custom_end_str: try: - custom_start = datetime.strptime(custom_start_str, '%Y-%m-%d') - custom_end = datetime.strptime(custom_end_str, '%Y-%m-%d') + custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d") + custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d") except ValueError: pass - + # Handle hospital_id (can be integer or UUID string) hospital_id = hospital_id if hospital_id else None - + # Handle department_id (UUID string) department_id = department_id if department_id else None - + # Get KPIs kpis = UnifiedAnalyticsService.get_all_kpis( user=user, @@ -458,51 +439,62 @@ def command_center_api(request): department_id=department_id, kpi_category=kpi_category, custom_start=custom_start, - custom_end=custom_end + custom_end=custom_end, ) - + # Ensure numeric KPIs are proper Python types for JSON serialization numeric_kpis = [ - 'total_complaints', 'open_complaints', 'overdue_complaints', - 'high_severity_complaints', 'resolved_complaints', - 'total_actions', 'open_actions', 'overdue_actions', 'escalated_actions', 'resolved_actions', - 'total_surveys', 'negative_surveys', 'avg_survey_score', - 'negative_social_mentions', 'low_call_ratings', 'total_sentiment_analyses' + "total_complaints", + "open_complaints", + "overdue_complaints", + "high_severity_complaints", + "resolved_complaints", + "total_actions", + "open_actions", + "overdue_actions", + "escalated_actions", + "resolved_actions", + "total_surveys", + "negative_surveys", + "avg_survey_score", + "negative_social_mentions", + "low_call_ratings", + "total_sentiment_analyses", ] - + for key in numeric_kpis: if key in kpis: value = kpis[key] if value is None: - kpis[key] = 0.0 if key == 'avg_survey_score' else 0 + kpis[key] = 0.0 if key == "avg_survey_score" else 0 elif isinstance(value, (int, float)): # Already a number - ensure floats for specific fields - if key == 'avg_survey_score': + if key == "avg_survey_score": kpis[key] = float(value) else: # Try to convert to number try: kpis[key] = float(value) except (ValueError, TypeError): - kpis[key] = 0.0 if key == 'avg_survey_score' else 0 - + kpis[key] = 0.0 if key == "avg_survey_score" else 0 + # Handle nested trend data - if 'complaints_trend' in kpis and isinstance(kpis['complaints_trend'], dict): - trend = kpis['complaints_trend'] - trend['current'] = int(trend.get('current', 0)) - trend['previous'] = int(trend.get('previous', 0)) - trend['percentage_change'] = float(trend.get('percentage_change', 0)) - + if "complaints_trend" in kpis and isinstance(kpis["complaints_trend"], dict): + trend = kpis["complaints_trend"] + trend["current"] = int(trend.get("current", 0)) + trend["previous"] = int(trend.get("previous", 0)) + trend["percentage_change"] = float(trend.get("percentage_change", 0)) + # Get chart data chart_types = [ - 'complaints_trend', - 'complaints_by_category', - 'survey_satisfaction_trend', - 'survey_distribution', - 'department_performance', - 'physician_leaderboard' + "complaints_trend", + "complaints_by_category", + "survey_satisfaction_trend", + "survey_distribution", + "department_performance", + "physician_leaderboard", ] - + charts = {} for chart_type in chart_types: charts[chart_type] = UnifiedAnalyticsService.get_chart_data( @@ -512,64 +504,60 @@ def command_center_api(request): hospital_id=hospital_id, department_id=department_id, custom_start=custom_start, - custom_end=custom_end + custom_end=custom_end, ) - + # Get table data tables = {} - + # Overdue complaints table complaints_qs = Complaint.objects.filter(is_overdue=True) if hospital_id: complaints_qs = complaints_qs.filter(hospital_id=hospital_id) if department_id: complaints_qs = complaints_qs.filter(department_id=department_id) - + # Apply role-based filtering if not user.is_px_admin() and user.hospital: complaints_qs = complaints_qs.filter(hospital=user.hospital) if user.is_department_manager() and user.department: complaints_qs = complaints_qs.filter(department=user.department) - - tables['overdue_complaints'] = list( - complaints_qs.select_related('hospital', 'department', 'patient', 'source') - .order_by('due_at')[:20] + + tables["overdue_complaints"] = list( + complaints_qs.select_related("hospital", "department", "patient", "source") + .order_by("due_at")[:20] .values( - 'id', - 'title', - 'severity', - 'due_at', - 'complaint_source_type', - hospital_name=F('hospital__name'), - department_name=F('department__name'), - patient_full_name=Concat('patient__first_name', Value(' '), 'patient__last_name'), - source_name=F('source__name_en'), - assigned_to_full_name=Concat('assigned_to__first_name', Value(' '), 'assigned_to__last_name') + "id", + "title", + "severity", + "due_at", + "complaint_source_type", + hospital_name=F("hospital__name"), + department_name=F("department__name"), + patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"), + source_name=F("source__name_en"), + assigned_to_full_name=Concat("assigned_to__first_name", Value(" "), "assigned_to__last_name"), ) ) - + # Physician leaderboard table - physician_data = charts.get('physician_leaderboard', {}).get('metadata', []) - tables['physician_leaderboard'] = [ + physician_data = charts.get("physician_leaderboard", {}).get("metadata", []) + tables["physician_leaderboard"] = [ { - 'physician_id': p['physician_id'], - 'name': p['name'], - 'specialization': p['specialization'], - 'department': p['department'], - 'rating': float(p['rating']) if p['rating'] is not None else 0.0, - 'surveys': int(p['surveys']) if p['surveys'] is not None else 0, - 'positive': int(p['positive']) if p['positive'] is not None else 0, - 'neutral': int(p['neutral']) if p['neutral'] is not None else 0, - 'negative': int(p['negative']) if p['negative'] is not None else 0 + "physician_id": p["physician_id"], + "name": p["name"], + "specialization": p["specialization"], + "department": p["department"], + "rating": float(p["rating"]) if p["rating"] is not None else 0.0, + "surveys": int(p["surveys"]) if p["surveys"] is not None else 0, + "positive": int(p["positive"]) if p["positive"] is not None else 0, + "neutral": int(p["neutral"]) if p["neutral"] is not None else 0, + "negative": int(p["negative"]) if p["negative"] is not None else 0, } for p in physician_data ] - - return JsonResponse({ - 'kpis': kpis, - 'charts': charts, - 'tables': tables - }) + + return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables}) @block_source_user @@ -577,40 +565,40 @@ def command_center_api(request): def export_command_center(request, export_format): """ Export Command Center data to Excel or PDF - + Args: export_format: 'excel' or 'pdf' - + Returns: HttpResponse with file download """ - if export_format not in ['excel', 'pdf']: - return JsonResponse({'error': 'Invalid export format'}, status=400) - + if export_format not in ["excel", "pdf"]: + return JsonResponse({"error": "Invalid export format"}, status=400) + user = request.user - + # Get filter parameters - date_range = request.GET.get('date_range', '30d') - hospital_id = request.GET.get('hospital') - department_id = request.GET.get('department') - kpi_category = request.GET.get('kpi_category') - custom_start_str = request.GET.get('custom_start') - custom_end_str = request.GET.get('custom_end') - + date_range = request.GET.get("date_range", "30d") + hospital_id = request.GET.get("hospital") + department_id = request.GET.get("department") + kpi_category = request.GET.get("kpi_category") + custom_start_str = request.GET.get("custom_start") + custom_end_str = request.GET.get("custom_end") + # Parse custom dates custom_start = None custom_end = None if custom_start_str and custom_end_str: try: - custom_start = datetime.strptime(custom_start_str, '%Y-%m-%d') - custom_end = datetime.strptime(custom_end_str, '%Y-%m-%d') + custom_start = datetime.strptime(custom_start_str, "%Y-%m-%d") + custom_end = datetime.strptime(custom_end_str, "%Y-%m-%d") except ValueError: pass - + # Handle hospital_id and department_id (can be integer or UUID string) hospital_id = hospital_id if hospital_id else None department_id = department_id if department_id else None - + # Get all data kpis = UnifiedAnalyticsService.get_all_kpis( user=user, @@ -619,18 +607,18 @@ def export_command_center(request, export_format): department_id=department_id, kpi_category=kpi_category, custom_start=custom_start, - custom_end=custom_end + custom_end=custom_end, ) - + chart_types = [ - 'complaints_trend', - 'complaints_by_category', - 'survey_satisfaction_trend', - 'survey_distribution', - 'department_performance', - 'physician_leaderboard' + "complaints_trend", + "complaints_by_category", + "survey_satisfaction_trend", + "survey_distribution", + "department_performance", + "physician_leaderboard", ] - + charts = {} for chart_type in chart_types: charts[chart_type] = UnifiedAnalyticsService.get_chart_data( @@ -640,77 +628,64 @@ def export_command_center(request, export_format): hospital_id=hospital_id, department_id=department_id, custom_start=custom_start, - custom_end=custom_end + custom_end=custom_end, ) - + # Get table data tables = {} - + # Overdue complaints complaints_qs = Complaint.objects.filter(is_overdue=True) if hospital_id: complaints_qs = complaints_qs.filter(hospital_id=hospital_id) if department_id: complaints_qs = complaints_qs.filter(department_id=department_id) - + if not user.is_px_admin() and user.hospital: complaints_qs = complaints_qs.filter(hospital=user.hospital) if user.is_department_manager() and user.department: complaints_qs = complaints_qs.filter(department=user.department) - - tables['overdue_complaints'] = { - 'headers': ['ID', 'Title', 'Patient', 'Severity', 'Hospital', 'Department', 'Due Date'], - 'rows': list( - complaints_qs.select_related('hospital', 'department', 'patient') - .order_by('due_at')[:100] + + tables["overdue_complaints"] = { + "headers": ["ID", "Title", "Patient", "Severity", "Hospital", "Department", "Due Date"], + "rows": list( + complaints_qs.select_related("hospital", "department", "patient") + .order_by("due_at")[:100] .annotate( - patient_full_name=Concat('patient__first_name', Value(' '), 'patient__last_name'), - hospital_name=F('hospital__name'), - department_name=F('department__name') + patient_full_name=Concat("patient__first_name", Value(" "), "patient__last_name"), + hospital_name=F("hospital__name"), + department_name=F("department__name"), ) - .values_list( - 'id', - 'title', - 'patient_full_name', - 'severity', - 'hospital_name', - 'department_name', - 'due_at' - ) - ) + .values_list("id", "title", "patient_full_name", "severity", "hospital_name", "department_name", "due_at") + ), } - + # Physician leaderboard - physician_data = charts.get('physician_leaderboard', {}).get('metadata', []) - tables['physician_leaderboard'] = { - 'headers': ['Name', 'Specialization', 'Department', 'Rating', 'Surveys', 'Positive', 'Neutral', 'Negative'], - 'rows': [ + physician_data = charts.get("physician_leaderboard", {}).get("metadata", []) + tables["physician_leaderboard"] = { + "headers": ["Name", "Specialization", "Department", "Rating", "Surveys", "Positive", "Neutral", "Negative"], + "rows": [ [ - p['name'], - p['specialization'], - p['department'], - str(p['rating']), - str(p['surveys']), - str(p['positive']), - str(p['neutral']), - str(p['negative']) + p["name"], + p["specialization"], + p["department"], + str(p["rating"]), + str(p["surveys"]), + str(p["positive"]), + str(p["neutral"]), + str(p["negative"]), ] for p in physician_data - ] + ], } - + # Prepare export data - export_data = ExportService.prepare_dashboard_data( - user=user, - kpis=kpis, - charts=charts, - tables=tables - ) - + export_data = ExportService.prepare_dashboard_data(user=user, kpis=kpis, charts=charts, tables=tables) + # Export based on format - if export_format == 'excel': + if export_format == "excel": return ExportService.export_to_excel(export_data) - elif export_format == 'pdf': + elif export_format == "pdf": return ExportService.export_to_pdf(export_data) - - return JsonResponse({'error': 'Export failed'}, status=500) \ No newline at end of file + + return JsonResponse({"error": "Export failed"}, status=500) diff --git a/apps/appreciation/ui_views.py b/apps/appreciation/ui_views.py index f63b696..c18c208 100644 --- a/apps/appreciation/ui_views.py +++ b/apps/appreciation/ui_views.py @@ -140,10 +140,6 @@ def appreciation_list(request): 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) departments = Department.objects.filter(status='active') if not user.is_px_admin() and user.hospital: @@ -172,7 +168,6 @@ def appreciation_list(request): 'page_obj': page_obj, 'appreciations': page_obj.object_list, 'stats': stats, - 'hospitals': hospitals, 'departments': departments, 'categories': categories, 'status_choices': AppreciationStatus.choices, @@ -334,7 +329,6 @@ def appreciation_send(request): categories = categories.filter(Q(hospital_id=request.user.hospital.id) | Q(hospital__isnull=True)) context = { - 'hospitals': hospitals, 'categories': categories, 'visibility_choices': AppreciationVisibility.choices, } @@ -410,10 +404,6 @@ def leaderboard_view(request): 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) departments = Department.objects.filter(status='active') if not user.is_px_admin() and user.hospital: @@ -426,7 +416,6 @@ def leaderboard_view(request): context = { 'page_obj': page_obj, 'leaderboard': page_obj.object_list, - 'hospitals': hospitals, 'departments': departments, 'months': months, 'years': years, diff --git a/apps/callcenter/ui_views.py b/apps/callcenter/ui_views.py index b77d31c..5dc70a1 100644 --- a/apps/callcenter/ui_views.py +++ b/apps/callcenter/ui_views.py @@ -1,6 +1,7 @@ """ Call Center Console UI views """ + from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator @@ -22,15 +23,13 @@ from .models import CallCenterInteraction, CallRecord @login_required def interaction_list(request): """Call center interactions list view""" - queryset = CallCenterInteraction.objects.select_related( - 'patient', 'hospital', 'department', 'agent' - ) + queryset = CallCenterInteraction.objects.select_related("patient", "hospital", "department", "agent") # Apply RBAC filters user = request.user # Get selected hospital for PX Admins (from middleware) - selected_hospital = getattr(request, 'tenant_hospital', None) - + selected_hospital = getattr(request, "tenant_hospital", None) + if user.is_px_admin(): # PX Admins see all, but filter by selected hospital if set if selected_hospital: @@ -41,91 +40,83 @@ def interaction_list(request): queryset = queryset.none() # Apply filters - call_type_filter = request.GET.get('call_type') + call_type_filter = request.GET.get("call_type") if call_type_filter: queryset = queryset.filter(call_type=call_type_filter) - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) - is_low_rating = request.GET.get('is_low_rating') - if is_low_rating == 'true': + is_low_rating = request.GET.get("is_low_rating") + if is_low_rating == "true": queryset = queryset.filter(is_low_rating=True) # Search - search_query = request.GET.get('search') + search_query = request.GET.get("search") if search_query: queryset = queryset.filter( - Q(subject__icontains=search_query) | - Q(caller_name__icontains=search_query) | - Q(patient__mrn__icontains=search_query) + Q(subject__icontains=search_query) + | Q(caller_name__icontains=search_query) + | Q(patient__mrn__icontains=search_query) ) # Date range - date_from = request.GET.get('date_from') + date_from = request.GET.get("date_from") if date_from: queryset = queryset.filter(call_started_at__gte=date_from) - date_to = request.GET.get('date_to') + date_to = request.GET.get("date_to") if date_to: queryset = queryset.filter(call_started_at__lte=date_to) # Ordering - queryset = queryset.order_by('-call_started_at') + queryset = queryset.order_by("-call_started_at") # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + 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 = { - 'total': queryset.count(), - 'low_rating': queryset.filter(is_low_rating=True).count(), - 'avg_satisfaction': queryset.filter(satisfaction_rating__isnull=False).aggregate( - avg=Avg('satisfaction_rating') - )['avg'] or 0, + "total": queryset.count(), + "low_rating": queryset.filter(is_low_rating=True).count(), + "avg_satisfaction": queryset.filter(satisfaction_rating__isnull=False).aggregate( + avg=Avg("satisfaction_rating") + )["avg"] + or 0, } context = { - 'page_obj': page_obj, - 'interactions': page_obj.object_list, - 'stats': stats, - 'hospitals': hospitals, - 'filters': request.GET, + "page_obj": page_obj, + "interactions": page_obj.object_list, + "stats": stats, + "filters": request.GET, } - return render(request, 'callcenter/interaction_list.html', context) + return render(request, "callcenter/interaction_list.html", context) @login_required def interaction_detail(request, pk): """Call center interaction detail view""" interaction = get_object_or_404( - CallCenterInteraction.objects.select_related( - 'patient', 'hospital', 'department', 'agent' - ), - pk=pk + CallCenterInteraction.objects.select_related("patient", "hospital", "department", "agent"), pk=pk ) context = { - 'interaction': interaction, + "interaction": interaction, } - return render(request, 'callcenter/interaction_detail.html', context) + return render(request, "callcenter/interaction_detail.html", context) # ============================================================================ # COMPLAINT CREATION FOR CALL CENTER # ============================================================================ + @login_required @require_http_methods(["GET", "POST"]) def create_complaint(request): @@ -134,44 +125,44 @@ def create_complaint(request): Call center staff can create complaints on behalf of patients/callers. """ - if request.method == 'POST': + if request.method == "POST": try: # Get form data - patient_id = request.POST.get('patient_id', None) - hospital_id = request.POST.get('hospital_id') - department_id = request.POST.get('department_id', None) - staff_id = request.POST.get('staff_id', None) + patient_id = request.POST.get("patient_id", None) + hospital_id = request.POST.get("hospital_id") + department_id = request.POST.get("department_id", None) + staff_id = request.POST.get("staff_id", None) - title = request.POST.get('title') - description = request.POST.get('description') - category = request.POST.get('category') - subcategory = request.POST.get('subcategory', '') - priority = request.POST.get('priority') - severity = request.POST.get('severity') - encounter_id = request.POST.get('encounter_id', '') + title = request.POST.get("title") + description = request.POST.get("description") + category = request.POST.get("category") + subcategory = request.POST.get("subcategory", "") + priority = request.POST.get("priority") + severity = request.POST.get("severity") + encounter_id = request.POST.get("encounter_id", "") # Call center specific fields - caller_name = request.POST.get('caller_name', '') - caller_phone = request.POST.get('caller_phone', '') - caller_relationship = request.POST.get('caller_relationship', 'patient') + caller_name = request.POST.get("caller_name", "") + caller_phone = request.POST.get("caller_phone", "") + caller_relationship = request.POST.get("caller_relationship", "patient") # Validate required fields if not all([hospital_id, title, description, category, priority, severity]): messages.error(request, "Please fill in all required fields.") - return redirect('callcenter:create_complaint') + return redirect("callcenter:create_complaint") # If no patient selected, we need caller info if not patient_id and not caller_name: messages.error(request, "Please provide either patient or caller information.") - return redirect('callcenter:create_complaint') - + return redirect("callcenter:create_complaint") + # Get first active source for call center try: call_center_source = PXSource.objects.filter(is_active=True).first() except PXSource.DoesNotExist: messages.error(request, "No active PX sources available.") - return redirect('callcenter:create_complaint') - + return redirect("callcenter:create_complaint") + # Create complaint complaint = Complaint.objects.create( patient_id=patient_id if patient_id else None, @@ -197,47 +188,38 @@ def create_complaint(request): hospital_id=hospital_id, department_id=department_id if department_id else None, agent=request.user, - call_type='complaint', + call_type="complaint", subject=title, notes=description, metadata={ - 'complaint_id': str(complaint.id), - 'category': category, - 'severity': severity, - } + "complaint_id": str(complaint.id), + "category": category, + "severity": severity, + }, ) # Log audit AuditService.log_event( - event_type='complaint_created', + event_type="complaint_created", description=f"Complaint created via call center: {complaint.title}", user=request.user, content_object=complaint, metadata={ - 'category': complaint.category, - 'severity': complaint.severity, - 'source': 'call_center', - 'caller_name': caller_name, - } + "category": complaint.category, + "severity": complaint.severity, + "source": "call_center", + "caller_name": caller_name, + }, ) messages.success(request, f"Complaint #{complaint.id} created successfully.") - return redirect('callcenter:complaint_success', pk=complaint.id) + return redirect("callcenter:complaint_success", pk=complaint.id) except Exception as e: messages.error(request, f"Error creating complaint: {str(e)}") - return redirect('callcenter:create_complaint') + return redirect("callcenter:create_complaint") - # GET request - show form - hospitals = Hospital.objects.filter(status='active') - if not request.user.is_px_admin() and request.user.hospital: - hospitals = hospitals.filter(id=request.user.hospital.id) - - context = { - 'hospitals': hospitals, - } - - return render(request, 'callcenter/complaint_form.html', context) + return render(request, "callcenter/complaint_form.html", {}) @login_required @@ -246,19 +228,17 @@ def complaint_success(request, pk): complaint = get_object_or_404(Complaint, pk=pk) context = { - 'complaint': complaint, + "complaint": complaint, } - return render(request, 'callcenter/complaint_success.html', context) + return render(request, "callcenter/complaint_success.html", context) @login_required def complaint_list(request): """List complaints created by call center""" - queryset = Complaint.objects.filter( - complaint_source=ComplaintSource.CALL_CENTER - ).select_related( - 'patient', 'hospital', 'department', 'staff', 'assigned_to' + queryset = Complaint.objects.filter(complaint_source=ComplaintSource.CALL_CENTER).select_related( + "patient", "hospital", "department", "staff", "assigned_to" ) # Apply RBAC filters @@ -271,64 +251,58 @@ def complaint_list(request): queryset = queryset.none() # Apply filters - status_filter = request.GET.get('status') + status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) - severity_filter = request.GET.get('severity') + severity_filter = request.GET.get("severity") if severity_filter: queryset = queryset.filter(severity=severity_filter) - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) # Search - search_query = request.GET.get('search') + search_query = request.GET.get("search") if search_query: queryset = queryset.filter( - Q(title__icontains=search_query) | - Q(description__icontains=search_query) | - Q(patient__mrn__icontains=search_query) + Q(title__icontains=search_query) + | Q(description__icontains=search_query) + | Q(patient__mrn__icontains=search_query) ) # Ordering - queryset = queryset.order_by('-created_at') + queryset = queryset.order_by("-created_at") # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + 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 = { - 'total': queryset.count(), - 'open': queryset.filter(status='open').count(), - 'in_progress': queryset.filter(status='in_progress').count(), - 'resolved': queryset.filter(status='resolved').count(), + "total": queryset.count(), + "open": queryset.filter(status="open").count(), + "in_progress": queryset.filter(status="in_progress").count(), + "resolved": queryset.filter(status="resolved").count(), } context = { - 'page_obj': page_obj, - 'complaints': page_obj.object_list, - 'stats': stats, - 'hospitals': hospitals, - 'filters': request.GET, + "page_obj": page_obj, + "complaints": page_obj.object_list, + "stats": stats, + "filters": request.GET, } - return render(request, 'callcenter/complaint_list.html', context) + return render(request, "callcenter/complaint_list.html", context) # ============================================================================ # INQUIRY CREATION FOR CALL CENTER # ============================================================================ + @login_required @require_http_methods(["GET", "POST"]) def create_inquiry(request): @@ -337,34 +311,34 @@ def create_inquiry(request): Call center staff can create inquiries for general questions/requests. """ - if request.method == 'POST': + if request.method == "POST": try: # Get form data - patient_id = request.POST.get('patient_id', None) - hospital_id = request.POST.get('hospital_id') - department_id = request.POST.get('department_id', None) + patient_id = request.POST.get("patient_id", None) + hospital_id = request.POST.get("hospital_id") + department_id = request.POST.get("department_id", None) - subject = request.POST.get('subject') - message = request.POST.get('message') - category = request.POST.get('category') + subject = request.POST.get("subject") + message = request.POST.get("message") + category = request.POST.get("category") # Contact info (if no patient) - contact_name = request.POST.get('contact_name', '') - contact_phone = request.POST.get('contact_phone', '') - contact_email = request.POST.get('contact_email', '') + contact_name = request.POST.get("contact_name", "") + contact_phone = request.POST.get("contact_phone", "") + contact_email = request.POST.get("contact_email", "") # Call center specific - caller_relationship = request.POST.get('caller_relationship', 'patient') + caller_relationship = request.POST.get("caller_relationship", "patient") # Validate required fields if not all([hospital_id, subject, message, category]): messages.error(request, "Please fill in all required fields.") - return redirect('callcenter:create_inquiry') + return redirect("callcenter:create_inquiry") # If no patient, need contact info if not patient_id and not contact_name: messages.error(request, "Please provide either patient or contact information.") - return redirect('callcenter:create_inquiry') + return redirect("callcenter:create_inquiry") # Create inquiry inquiry = Inquiry.objects.create( @@ -388,45 +362,36 @@ def create_inquiry(request): hospital_id=hospital_id, department_id=department_id if department_id else None, agent=request.user, - call_type='inquiry', + call_type="inquiry", subject=subject, notes=message, metadata={ - 'inquiry_id': str(inquiry.id), - 'category': category, - } + "inquiry_id": str(inquiry.id), + "category": category, + }, ) # Log audit AuditService.log_event( - event_type='inquiry_created', + event_type="inquiry_created", description=f"Inquiry created via call center: {inquiry.subject}", user=request.user, content_object=inquiry, metadata={ - 'category': inquiry.category, - 'source': 'call_center', - 'contact_name': contact_name, - } + "category": inquiry.category, + "source": "call_center", + "contact_name": contact_name, + }, ) messages.success(request, f"Inquiry #{inquiry.id} created successfully.") - return redirect('callcenter:inquiry_success', pk=inquiry.id) + return redirect("callcenter:inquiry_success", pk=inquiry.id) except Exception as e: messages.error(request, f"Error creating inquiry: {str(e)}") - return redirect('callcenter:create_inquiry') + return redirect("callcenter:create_inquiry") - # GET request - show form - hospitals = Hospital.objects.filter(status='active') - if not request.user.is_px_admin() and request.user.hospital: - hospitals = hospitals.filter(id=request.user.hospital.id) - - context = { - 'hospitals': hospitals, - } - - return render(request, 'callcenter/inquiry_form.html', context) + return render(request, "callcenter/inquiry_form.html", {}) @login_required @@ -435,18 +400,16 @@ def inquiry_success(request, pk): inquiry = get_object_or_404(Inquiry, pk=pk) context = { - 'inquiry': inquiry, + "inquiry": inquiry, } - return render(request, 'callcenter/inquiry_success.html', context) + return render(request, "callcenter/inquiry_success.html", context) @login_required def inquiry_list(request): """List inquiries created by call center""" - queryset = Inquiry.objects.select_related( - 'patient', 'hospital', 'department', 'assigned_to', 'responded_by' - ) + queryset = Inquiry.objects.select_related("patient", "hospital", "department", "assigned_to", "responded_by") # Apply RBAC filters user = request.user @@ -458,120 +421,112 @@ def inquiry_list(request): queryset = queryset.none() # Apply filters - status_filter = request.GET.get('status') + status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) - category_filter = request.GET.get('category') + category_filter = request.GET.get("category") if category_filter: queryset = queryset.filter(category=category_filter) - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) # Search - search_query = request.GET.get('search') + search_query = request.GET.get("search") if search_query: queryset = queryset.filter( - Q(subject__icontains=search_query) | - Q(message__icontains=search_query) | - Q(contact_name__icontains=search_query) + Q(subject__icontains=search_query) + | Q(message__icontains=search_query) + | Q(contact_name__icontains=search_query) ) # Ordering - queryset = queryset.order_by('-created_at') + queryset = queryset.order_by("-created_at") # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + 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 = { - 'total': queryset.count(), - 'open': queryset.filter(status='open').count(), - 'in_progress': queryset.filter(status='in_progress').count(), - 'resolved': queryset.filter(status='resolved').count(), + "total": queryset.count(), + "open": queryset.filter(status="open").count(), + "in_progress": queryset.filter(status="in_progress").count(), + "resolved": queryset.filter(status="resolved").count(), } context = { - 'page_obj': page_obj, - 'inquiries': page_obj.object_list, - 'stats': stats, - 'hospitals': hospitals, - 'filters': request.GET, + "page_obj": page_obj, + "inquiries": page_obj.object_list, + "stats": stats, + "filters": request.GET, } - return render(request, 'callcenter/inquiry_list.html', context) + return render(request, "callcenter/inquiry_list.html", context) # ============================================================================ # AJAX/API HELPERS # ============================================================================ + @login_required def get_departments_by_hospital(request): """Get departments for a hospital (AJAX)""" - hospital_id = request.GET.get('hospital_id') + hospital_id = request.GET.get("hospital_id") if not hospital_id: - return JsonResponse({'departments': []}) + return JsonResponse({"departments": []}) - departments = Department.objects.filter( - hospital_id=hospital_id, - status='active' - ).values('id', 'name', 'name_ar') + departments = Department.objects.filter(hospital_id=hospital_id, status="active").values("id", "name", "name_ar") - return JsonResponse({'departments': list(departments)}) + return JsonResponse({"departments": list(departments)}) @login_required def get_staff_by_hospital(request): """Get staff for a hospital (AJAX)""" - hospital_id = request.GET.get('hospital_id') + hospital_id = request.GET.get("hospital_id") if not hospital_id: - return JsonResponse({'staff': []}) + return JsonResponse({"staff": []}) - staff_members = Staff.objects.filter( - hospital_id=hospital_id, - status='active' - ).values('id', 'first_name', 'last_name', 'staff_type', 'specialization') + staff_members = Staff.objects.filter(hospital_id=hospital_id, status="active").values( + "id", "first_name", "last_name", "staff_type", "specialization" + ) # Format staff names staff_list = [ { - 'id': str(s['id']), - 'name': f"Dr. {s['first_name']} {s['last_name']}" if s['staff_type'] == 'physician' else f"{s['first_name']} {s['last_name']}", - 'staff_type': s['staff_type'], - 'specialization': s['specialization'] + "id": str(s["id"]), + "name": f"Dr. {s['first_name']} {s['last_name']}" + if s["staff_type"] == "physician" + else f"{s['first_name']} {s['last_name']}", + "staff_type": s["staff_type"], + "specialization": s["specialization"], } for s in staff_members ] - return JsonResponse({'staff': staff_list}) + return JsonResponse({"staff": staff_list}) @login_required def search_patients(request): """Search patients by MRN or name (AJAX)""" - query = request.GET.get('q', '') - hospital_id = request.GET.get('hospital_id', None) + query = request.GET.get("q", "") + hospital_id = request.GET.get("hospital_id", None) if len(query) < 2: - return JsonResponse({'patients': []}) + return JsonResponse({"patients": []}) patients = Patient.objects.filter( - Q(mrn__icontains=query) | - Q(first_name__icontains=query) | - Q(last_name__icontains=query) | - Q(national_id__icontains=query) | - Q(phone__icontains=query) + Q(mrn__icontains=query) + | Q(first_name__icontains=query) + | Q(last_name__icontains=query) + | Q(national_id__icontains=query) + | Q(phone__icontains=query) ) if hospital_id: @@ -581,31 +536,32 @@ def search_patients(request): results = [ { - 'id': str(p.id), - 'mrn': p.mrn, - 'name': p.get_full_name(), - 'phone': p.phone, - 'email': p.email, - 'national_id': p.national_id, + "id": str(p.id), + "mrn": p.mrn, + "name": p.get_full_name(), + "phone": p.phone, + "email": p.email, + "national_id": p.national_id, } for p in patients ] - return JsonResponse({'patients': results}) + return JsonResponse({"patients": results}) # ============================================================================ # CALL RECORDS (CSV IMPORT) VIEWS # ============================================================================ + @login_required def call_records_list(request): """ Call records list view with stats cards. - + Shows all imported call records with filtering and search. """ - queryset = CallRecord.objects.select_related('hospital') + queryset = CallRecord.objects.select_related("hospital") # Apply RBAC filters user = request.user @@ -617,78 +573,71 @@ def call_records_list(request): queryset = queryset.none() # Apply filters - evaluated_filter = request.GET.get('evaluated') + evaluated_filter = request.GET.get("evaluated") if evaluated_filter: - queryset = queryset.filter(evaluated=evaluated_filter == 'true') + queryset = queryset.filter(evaluated=evaluated_filter == "true") - call_type_filter = request.GET.get('call_type') - if call_type_filter == 'inbound': + call_type_filter = request.GET.get("call_type") + if call_type_filter == "inbound": queryset = queryset.filter(Q(inbound_id__isnull=False) | Q(inbound_name__isnull=False)) - queryset = queryset.exclude(Q(inbound_id='') & Q(inbound_name='')) - elif call_type_filter == 'outbound': + queryset = queryset.exclude(Q(inbound_id="") & Q(inbound_name="")) + elif call_type_filter == "outbound": queryset = queryset.filter(Q(outbound_id__isnull=False) | Q(outbound_name__isnull=False)) - queryset = queryset.exclude(Q(outbound_id='') & Q(outbound_name='')) + queryset = queryset.exclude(Q(outbound_id="") & Q(outbound_name="")) - department_filter = request.GET.get('department') + department_filter = request.GET.get("department") if department_filter: queryset = queryset.filter(department__icontains=department_filter) - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) # Search - search_query = request.GET.get('search') + search_query = request.GET.get("search") if search_query: queryset = queryset.filter( - Q(first_name__icontains=search_query) | - Q(last_name__icontains=search_query) | - Q(department__icontains=search_query) | - Q(extension__icontains=search_query) | - Q(inbound_name__icontains=search_query) | - Q(outbound_name__icontains=search_query) + Q(first_name__icontains=search_query) + | Q(last_name__icontains=search_query) + | Q(department__icontains=search_query) + | Q(extension__icontains=search_query) + | Q(inbound_name__icontains=search_query) + | Q(outbound_name__icontains=search_query) ) # Date range - date_from = request.GET.get('date_from') + date_from = request.GET.get("date_from") if date_from: queryset = queryset.filter(call_start__gte=date_from) - date_to = request.GET.get('date_to') + date_to = request.GET.get("date_to") if date_to: queryset = queryset.filter(call_start__lte=date_to) # Ordering - queryset = queryset.order_by('-call_start') + queryset = queryset.order_by("-call_start") # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + 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 for cards stats = { - 'total_calls': queryset.count(), - 'total_duration': sum( - (r.call_duration_seconds or 0) for r in queryset - ), - 'inbound_calls': queryset.filter( - Q(inbound_id__isnull=False) | Q(inbound_name__isnull=False) - ).exclude(Q(inbound_id='') & Q(inbound_name='')).count(), - 'outbound_calls': queryset.filter( - Q(outbound_id__isnull=False) | Q(outbound_name__isnull=False) - ).exclude(Q(outbound_id='') & Q(outbound_name='')).count(), - 'evaluated_calls': queryset.filter(evaluated=True).count(), - 'not_evaluated_calls': queryset.filter(evaluated=False).count(), - 'avg_duration': queryset.filter( - call_duration_seconds__isnull=False - ).aggregate(avg=Avg('call_duration_seconds'))['avg'] or 0, + "total_calls": queryset.count(), + "total_duration": sum((r.call_duration_seconds or 0) for r in queryset), + "inbound_calls": queryset.filter(Q(inbound_id__isnull=False) | Q(inbound_name__isnull=False)) + .exclude(Q(inbound_id="") & Q(inbound_name="")) + .count(), + "outbound_calls": queryset.filter(Q(outbound_id__isnull=False) | Q(outbound_name__isnull=False)) + .exclude(Q(outbound_id="") & Q(outbound_name="")) + .count(), + "evaluated_calls": queryset.filter(evaluated=True).count(), + "not_evaluated_calls": queryset.filter(evaluated=False).count(), + "avg_duration": queryset.filter(call_duration_seconds__isnull=False).aggregate( + avg=Avg("call_duration_seconds") + )["avg"] + or 0, } # Format duration for display @@ -702,23 +651,22 @@ def call_records_list(request): return f"{hours}:{minutes:02d}:{secs:02d}" return f"{minutes}:{secs:02d}" - stats['total_duration_formatted'] = format_duration(stats['total_duration']) - stats['avg_duration_formatted'] = format_duration(stats['avg_duration']) + stats["total_duration_formatted"] = format_duration(stats["total_duration"]) + stats["avg_duration_formatted"] = format_duration(stats["avg_duration"]) # Get unique departments for filter dropdown - departments = CallRecord.objects.values_list('department', flat=True).distinct() + departments = CallRecord.objects.values_list("department", flat=True).distinct() departments = [d for d in departments if d] context = { - 'page_obj': page_obj, - 'call_records': page_obj.object_list, - 'stats': stats, - 'hospitals': hospitals, - 'departments': departments, - 'filters': request.GET, + "page_obj": page_obj, + "call_records": page_obj.object_list, + "stats": stats, + "departments": departments, + "filters": request.GET, } - return render(request, 'callcenter/call_records_list.html', context) + return render(request, "callcenter/call_records_list.html", context) @login_required @@ -726,20 +674,20 @@ def call_records_list(request): def import_call_records(request): """ Import call records from CSV file. - + CSV must have the same headers as the export format. """ - if request.method == 'POST': + if request.method == "POST": try: - csv_file = request.FILES.get('csv_file') + csv_file = request.FILES.get("csv_file") if not csv_file: messages.error(request, "Please select a CSV file to upload.") - return redirect('callcenter:import_call_records') + return redirect("callcenter:import_call_records") # Check file extension - if not csv_file.name.endswith('.csv'): + if not csv_file.name.endswith(".csv"): messages.error(request, "Please upload a valid CSV file.") - return redirect('callcenter:import_call_records') + return redirect("callcenter:import_call_records") import csv from datetime import datetime @@ -747,19 +695,27 @@ def import_call_records(request): import codecs # Decode the file and remove BOM if present - decoded_file = csv_file.read().decode('utf-8-sig') # utf-8-sig removes BOM + decoded_file = csv_file.read().decode("utf-8-sig") # utf-8-sig removes BOM reader = csv.DictReader(decoded_file.splitlines()) # Required headers required_headers = [ - 'Media ID', 'Media Type', 'Call Start', 'First Name', 'Last Name', - 'Extension', 'Department', 'Call End', 'Length', 'File Name' + "Media ID", + "Media Type", + "Call Start", + "First Name", + "Last Name", + "Extension", + "Department", + "Call End", + "Length", + "File Name", ] # Validate headers if reader.fieldnames is None: messages.error(request, "Invalid CSV file format.") - return redirect('callcenter:import_call_records') + return redirect("callcenter:import_call_records") # Clean headers (remove any remaining BOM or whitespace) cleaned_fieldnames = [f.strip() if f else f for f in reader.fieldnames] @@ -768,7 +724,7 @@ def import_call_records(request): missing_headers = [h for h in required_headers if h not in reader.fieldnames] if missing_headers: messages.error(request, f"Missing required headers: {', '.join(missing_headers)}") - return redirect('callcenter:import_call_records') + return redirect("callcenter:import_call_records") # Parse CSV and create records imported_count = 0 @@ -778,7 +734,7 @@ def import_call_records(request): for row_num, row in enumerate(reader, start=2): # Start at 2 (header is row 1) try: # Parse Media ID - media_id_str = row.get('Media ID', '').strip() + media_id_str = row.get("Media ID", "").strip() if not media_id_str: skipped_count += 1 continue @@ -795,7 +751,7 @@ def import_call_records(request): continue # Parse call start time - call_start_str = row.get('Call Start', '').strip() + call_start_str = row.get("Call Start", "").strip() if not call_start_str: skipped_count += 1 continue @@ -803,34 +759,34 @@ def import_call_records(request): # Try multiple datetime formats call_start = None for fmt in [ - '%m/%d/%y %H:%M', # 10/30/25 19:57 - '%m/%d/%Y %H:%M', # 10/30/2025 19:57 - '%m/%d/%y %I:%M:%S %p', # 10/30/25 7:57:48 PM - '%m/%d/%Y %I:%M:%S %p', # 10/30/2025 7:57:48 PM - '%m/%d/%y %I:%M %p', # 10/30/25 7:57 PM - '%m/%d/%Y %I:%M %p', # 10/30/2025 7:57 PM + "%m/%d/%y %H:%M", # 10/30/25 19:57 + "%m/%d/%Y %H:%M", # 10/30/2025 19:57 + "%m/%d/%y %I:%M:%S %p", # 10/30/25 7:57:48 PM + "%m/%d/%Y %I:%M:%S %p", # 10/30/2025 7:57:48 PM + "%m/%d/%y %I:%M %p", # 10/30/25 7:57 PM + "%m/%d/%Y %I:%M %p", # 10/30/2025 7:57 PM ]: try: call_start = datetime.strptime(call_start_str, fmt) break except ValueError: continue - + if not call_start: skipped_count += 1 continue # Parse call end time call_end = None - call_end_str = row.get('Call End', '').strip() + call_end_str = row.get("Call End", "").strip() if call_end_str: for fmt in [ - '%m/%d/%y %H:%M', - '%m/%d/%Y %H:%M', - '%m/%d/%y %I:%M:%S %p', - '%m/%d/%Y %I:%M:%S %p', - '%m/%d/%y %I:%M %p', - '%m/%d/%Y %I:%M %p', + "%m/%d/%y %H:%M", + "%m/%d/%Y %H:%M", + "%m/%d/%y %I:%M:%S %p", + "%m/%d/%Y %I:%M:%S %p", + "%m/%d/%y %I:%M %p", + "%m/%d/%Y %I:%M %p", ]: try: call_end = datetime.strptime(call_end_str, fmt) @@ -840,10 +796,10 @@ def import_call_records(request): # Parse call duration call_duration_seconds = None - length_str = row.get('Length', '').strip() + length_str = row.get("Length", "").strip() if length_str: try: - parts = length_str.split(':') + parts = length_str.split(":") if len(parts) == 3: # HH:MM:SS format call_duration_seconds = int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) @@ -864,33 +820,33 @@ def import_call_records(request): # Create the record CallRecord.objects.create( media_id=media_id, - media_type=row.get('Media Type', 'Calls').strip(), - chain=row.get('Chain', '').strip(), - evaluated=row.get('Evaluated', '').strip().lower() == 'true', + media_type=row.get("Media Type", "Calls").strip(), + chain=row.get("Chain", "").strip(), + evaluated=row.get("Evaluated", "").strip().lower() == "true", call_start=call_start, call_end=call_end, call_length=length_str, call_duration_seconds=call_duration_seconds, - first_name=row.get('First Name', '').strip(), - last_name=row.get('Last Name', '').strip(), - extension=row.get('Extension', '').strip(), - department=row.get('Department', '').strip(), - location=row.get('Location', '').strip(), - inbound_id=row.get('Inbound ID', '').strip(), - inbound_name=row.get('Inbound Name', '').strip(), - dnis=row.get('DNIS', '').strip(), - outbound_id=row.get('Outbound ID', '').strip(), - outbound_name=row.get('Outbound Name', '').strip(), - flag_name=row.get('Flag Name', '').strip(), - flag_value=row.get('Flag Value', '').strip(), - file_location=row.get('File Location', '').strip(), - file_name=row.get('File Name', '').strip(), - file_hash=row.get('FileHash', '').strip(), - external_ref=row.get('External Ref', '').strip(), - transfer_from=row.get('Transfer From', '').strip(), - recorded_by=row.get('Recorded By', '').strip(), - time_zone=row.get('Time Zone', '03:00:00').strip(), - recording_server_name=row.get('Recording Server Name', '').strip(), + first_name=row.get("First Name", "").strip(), + last_name=row.get("Last Name", "").strip(), + extension=row.get("Extension", "").strip(), + department=row.get("Department", "").strip(), + location=row.get("Location", "").strip(), + inbound_id=row.get("Inbound ID", "").strip(), + inbound_name=row.get("Inbound Name", "").strip(), + dnis=row.get("DNIS", "").strip(), + outbound_id=row.get("Outbound ID", "").strip(), + outbound_name=row.get("Outbound Name", "").strip(), + flag_name=row.get("Flag Name", "").strip(), + flag_value=row.get("Flag Value", "").strip(), + file_location=row.get("File Location", "").strip(), + file_name=row.get("File Name", "").strip(), + file_hash=row.get("FileHash", "").strip(), + external_ref=row.get("External Ref", "").strip(), + transfer_from=row.get("Transfer From", "").strip(), + recorded_by=row.get("Recorded By", "").strip(), + time_zone=row.get("Time Zone", "03:00:00").strip(), + recording_server_name=row.get("Recording Server Name", "").strip(), hospital=hospital, ) @@ -902,27 +858,48 @@ def import_call_records(request): messages.success( request, - f"Import completed: {imported_count} records imported, {skipped_count} skipped (duplicates/invalid), {error_count} errors." + f"Import completed: {imported_count} records imported, {skipped_count} skipped (duplicates/invalid), {error_count} errors.", ) - return redirect('callcenter:call_records_list') + return redirect("callcenter:call_records_list") except Exception as e: messages.error(request, f"Error importing CSV: {str(e)}") - return redirect('callcenter:import_call_records') + return redirect("callcenter:import_call_records") # GET request - show upload form context = { - 'sample_headers': [ - 'Media ID', 'Media Type', 'Chain', 'Evaluated', 'Call Start', - 'First Name', 'Last Name', 'Extension', 'Department', 'Location', - 'Inbound ID', 'Inbound Name', 'DNIS', 'Outbound ID', 'Outbound Name', - 'Length', 'Call End', 'Flag Name', 'Flag Value', 'File Location', - 'File Name', 'External Ref', 'FileHash', 'Transfer From', 'Recorded By', - 'Time Zone', 'Recording Server Name' + "sample_headers": [ + "Media ID", + "Media Type", + "Chain", + "Evaluated", + "Call Start", + "First Name", + "Last Name", + "Extension", + "Department", + "Location", + "Inbound ID", + "Inbound Name", + "DNIS", + "Outbound ID", + "Outbound Name", + "Length", + "Call End", + "Flag Name", + "Flag Value", + "File Location", + "File Name", + "External Ref", + "FileHash", + "Transfer From", + "Recorded By", + "Time Zone", + "Recording Server Name", ], } - return render(request, 'callcenter/import_call_records.html', context) + return render(request, "callcenter/import_call_records.html", context) @login_required @@ -933,26 +910,72 @@ def export_call_records_template(request): import csv from django.http import HttpResponse - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="call_records_template.csv"' + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="call_records_template.csv"' writer = csv.writer(response) - writer.writerow([ - 'Media ID', 'Media Type', 'Chain', 'Evaluated', 'Call Start', - 'First Name', 'Last Name', 'Extension', 'Department', 'Location', - 'Inbound ID', 'Inbound Name', 'DNIS', 'Outbound ID', 'Outbound Name', - 'Length', 'Call End', 'Flag Name', 'Flag Value', 'File Location', - 'File Name', 'External Ref', 'FileHash', 'Transfer From', 'Recorded By', - 'Time Zone', 'Recording Server Name' - ]) + writer.writerow( + [ + "Media ID", + "Media Type", + "Chain", + "Evaluated", + "Call Start", + "First Name", + "Last Name", + "Extension", + "Department", + "Location", + "Inbound ID", + "Inbound Name", + "DNIS", + "Outbound ID", + "Outbound Name", + "Length", + "Call End", + "Flag Name", + "Flag Value", + "File Location", + "File Name", + "External Ref", + "FileHash", + "Transfer From", + "Recorded By", + "Time Zone", + "Recording Server Name", + ] + ) # Add one sample row - writer.writerow([ - 'aade2430-2eb0-4e05-93eb-9567e2be07ae', 'Calls', '', 'False', - '10/30/2025 7:57:48 PM', 'Patient', 'Relation', '1379', 'Patient Relation', '', - '597979769', '', '', '', '', '00:01:11', '10/30/2025 7:59:00 PM', - '', '', 'E:\\Calls', '2025-10-30\\x1379 19.57.48.467 10-30-2025.mp3', - '12946311', '', '0', '', '03:00:00', 'ahnuzdcnqms02' - ]) + writer.writerow( + [ + "aade2430-2eb0-4e05-93eb-9567e2be07ae", + "Calls", + "", + "False", + "10/30/2025 7:57:48 PM", + "Patient", + "Relation", + "1379", + "Patient Relation", + "", + "597979769", + "", + "", + "", + "", + "00:01:11", + "10/30/2025 7:59:00 PM", + "", + "", + "E:\\Calls", + "2025-10-30\\x1379 19.57.48.467 10-30-2025.mp3", + "12946311", + "", + "0", + "", + "03:00:00", + "ahnuzdcnqms02", + ] + ) return response - diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index 1461466..87175bd 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -49,7 +49,7 @@ logger = logging.getLogger(__name__) def can_manage_complaint(user, complaint): """ Check if user can manage a complaint. - + Returns True if: - User is PX Admin - User is Hospital Admin for complaint's hospital @@ -59,22 +59,22 @@ def can_manage_complaint(user, complaint): """ if user.is_px_admin(): return True - + if user.is_hospital_admin() and user.hospital == complaint.hospital: return True - + if user.is_department_manager() and user.department == complaint.department: return True - + if complaint.assigned_to == user: return True - + # Check if user is assigned to any involved department - if hasattr(complaint, 'involved_departments'): + if hasattr(complaint, "involved_departments"): for involved in complaint.involved_departments.all(): if involved.assigned_to == user: return True - + return False @@ -92,15 +92,25 @@ def complaint_list(request): """ # Base queryset with optimizations queryset = Complaint.objects.select_related( - "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", - "source", "created_by", "domain", "category", "resolution_survey" + "patient", + "hospital", + "department", + "staff", + "assigned_to", + "resolved_by", + "closed_by", + "source", + "created_by", + "domain", + "category", + "resolution_survey", ) # Apply RBAC filters user = request.user # Get selected hospital for PX Admins (from middleware) - selected_hospital = getattr(request, 'tenant_hospital', None) - + selected_hospital = getattr(request, "tenant_hospital", None) + if user.is_px_admin(): # PX Admins see all, but filter by selected hospital if set if selected_hospital: @@ -243,7 +253,7 @@ def complaint_list(request): total_count = queryset.count() resolved_count = queryset.filter(status=ComplaintStatus.RESOLVED).count() pending_count = queryset.filter(status__in=[ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]).count() - + base_stats = { "total": total_count, "open": queryset.filter(status=ComplaintStatus.OPEN).count(), @@ -261,15 +271,12 @@ def complaint_list(request): # Get filter options from apps.px_sources.models import PXSource from apps.complaints.models import ComplaintCategory - + px_sources = PXSource.objects.filter(is_active=True) - + # Get domains for taxonomy filter - domains = ComplaintCategory.objects.filter( - level=ComplaintCategory.LevelChoices.DOMAIN, - is_active=True - ) - + domains = ComplaintCategory.objects.filter(level=ComplaintCategory.LevelChoices.DOMAIN, is_active=True) + context = { "complaints": page_obj, "stats": base_stats, @@ -299,7 +306,7 @@ def complaint_detail(request, pk): - Related surveys and journey - Linked PX actions - Workflow actions (assign, status change, add note) - + PERFORMANCE OPTIMIZATIONS: - Added missing select_related fields to main query - Annotated counts to avoid N+1 queries in template @@ -308,41 +315,64 @@ def complaint_detail(request, pk): - Prefetched explanations with attachments """ from apps.px_sources.models import SourceUser + source_user = SourceUser.objects.filter(user=request.user).first() - base_layout = 'layouts/source_user_base.html' if source_user else 'layouts/base.html' - + base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html" + # OPTIMIZED: Added missing select_related fields and annotated counts - complaint_queryset = Complaint.objects.select_related( - "patient", "hospital", "department", "staff", "assigned_to", "resolved_by", "closed_by", "resolution_survey", - "source", "created_by", "domain", "category", - # ADD: Missing foreign keys that are accessed in template - "subcategory_obj", "classification_obj", "location", "main_section", "subsection" - ).prefetch_related( - "attachments", - "updates__created_by", - "involved_departments__department", - "involved_departments__assigned_to", - "involved_staff__staff__department", - # ADD: Prefetch explanations with their attachments - Prefetch( - "explanations", - queryset=ComplaintExplanation.objects.select_related("staff").prefetch_related("attachments").order_by("-created_at") - ), - # ADD: Prefetch adverse actions with related data - Prefetch( - "adverse_actions", - queryset=ComplaintAdverseAction.objects.select_related('reported_by').prefetch_related('involved_staff') + complaint_queryset = ( + Complaint.objects.select_related( + "patient", + "hospital", + "department", + "staff", + "assigned_to", + "resolved_by", + "closed_by", + "resolution_survey", + "source", + "created_by", + "domain", + "category", + # ADD: Missing foreign keys that are accessed in template + "subcategory_obj", + "classification_obj", + "location", + "main_section", + "subsection", + ) + .prefetch_related( + "attachments", + "updates__created_by", + "involved_departments__department", + "involved_departments__assigned_to", + "involved_staff__staff__department", + # ADD: Prefetch explanations with their attachments + Prefetch( + "explanations", + queryset=ComplaintExplanation.objects.select_related("staff") + .prefetch_related("attachments") + .order_by("-created_at"), + ), + # ADD: Prefetch adverse actions with related data + Prefetch( + "adverse_actions", + queryset=ComplaintAdverseAction.objects.select_related("reported_by").prefetch_related( + "involved_staff" + ), + ), + ) + .annotate( + # ADD: Annotate counts to avoid N+1 queries in template + updates_count=Count("updates", distinct=True), + attachments_count=Count("attachments", distinct=True), + involved_departments_count=Count("involved_departments", distinct=True), + involved_staff_count=Count("involved_staff", distinct=True), + explanations_count=Count("explanations", distinct=True), + adverse_actions_count=Count("adverse_actions", distinct=True), ) - ).annotate( - # ADD: Annotate counts to avoid N+1 queries in template - updates_count=Count("updates", distinct=True), - attachments_count=Count("attachments", distinct=True), - involved_departments_count=Count("involved_departments", distinct=True), - involved_staff_count=Count("involved_staff", distinct=True), - explanations_count=Count("explanations", distinct=True), - adverse_actions_count=Count("adverse_actions", distinct=True), ) - + complaint = get_object_or_404(complaint_queryset, pk=pk) # Check access @@ -393,65 +423,63 @@ def complaint_detail(request, pk): # OPTIMIZED: Escalation targets - only query managers and direct reports escalation_targets = [] default_escalation_target = None - + if complaint.hospital: # OPTIMIZED: Only query managers and potential escalation targets # instead of ALL staff in the hospital from django.db.models import Q - + # Get potential escalation targets: # 1. The staff's direct manager (if exists) # 2. Department managers in the hospital # 3. Hospital admins in the hospital - escalation_targets_qs = Staff.objects.filter( - hospital=complaint.hospital, - status='active', - user__isnull=False, - user__is_active=True - ).filter( - # Either is the staff's manager, or is a manager/admin - Q(id=complaint.staff.report_to.id if complaint.staff and complaint.staff.report_to else None) | - Q(user__groups__name__in=['Hospital Admin', 'Department Manager']) | - Q(direct_reports__isnull=False) - ).exclude( - id=complaint.staff.id if complaint.staff else None - ).select_related( - 'user', 'department', 'report_to' - ).distinct().order_by('first_name', 'last_name') - + escalation_targets_qs = ( + Staff.objects.filter(hospital=complaint.hospital, status="active", user__isnull=False, user__is_active=True) + .filter( + # Either is the staff's manager, or is a manager/admin + Q(id=complaint.staff.report_to.id if complaint.staff and complaint.staff.report_to else None) + | Q(user__groups__name__in=["Hospital Admin", "Department Manager"]) + | Q(direct_reports__isnull=False) + ) + .exclude(id=complaint.staff.id if complaint.staff else None) + .select_related("user", "department", "report_to") + .distinct() + .order_by("first_name", "last_name") + ) + # Build list of escalation targets for staff in escalation_targets_qs: - escalation_targets.append({ - 'staff': staff, - 'has_user': True, # Already filtered for active user - 'user_id': str(staff.user.id), - 'is_manager': staff.direct_reports.exists(), - 'is_line_manager': complaint.staff and complaint.staff.report_to == staff - }) - + escalation_targets.append( + { + "staff": staff, + "has_user": True, # Already filtered for active user + "user_id": str(staff.user.id), + "is_manager": staff.direct_reports.exists(), + "is_line_manager": complaint.staff and complaint.staff.report_to == staff, + } + ) + # Sort: Line manager first, then other managers, then others - escalation_targets.sort(key=lambda x: ( - not x['is_line_manager'], - not x['is_manager'], - x['staff'].get_full_name() - )) - + escalation_targets.sort( + key=lambda x: (not x["is_line_manager"], not x["is_manager"], x["staff"].get_full_name()) + ) + # Set default to staff's line manager if exists if complaint.staff and complaint.staff.report_to: default_escalation_target = str(complaint.staff.report_to.id) - + # OPTIMIZED: Use prefetched adverse actions adverse_actions = complaint.adverse_actions.all() - + context = { - 'complaint': complaint, - 'timeline': timeline, - 'attachments': attachments, - 'px_actions': px_actions, - 'assignable_users': assignable_users, - 'status_choices': ComplaintStatus.choices, - 'base_layout': base_layout, - 'source_user': source_user, + "complaint": complaint, + "timeline": timeline, + "attachments": attachments, + "px_actions": px_actions, + "assignable_users": assignable_users, + "status_choices": ComplaintStatus.choices, + "base_layout": base_layout, + "source_user": source_user, "complaint": complaint, "timeline": timeline, "attachments": attachments, @@ -478,69 +506,68 @@ def complaint_detail(request, pk): def complaint_create(request): """Create new complaint with AI-powered classification""" from apps.complaints.forms import ComplaintForm - + # Determine base layout based on user type from apps.px_sources.models import SourceUser - source_user = SourceUser.objects.filter(user=request.user).first() - base_layout = 'layouts/source_user_base.html' if source_user else 'layouts/base.html' - - if request.method == 'POST': + source_user = SourceUser.objects.filter(user=request.user).first() + base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html" + + if request.method == "POST": # Handle form submission form = ComplaintForm(request.POST, user=request.user) - + if not form.is_valid(): # Debug: print form errors print("Form validation errors:", form.errors) messages.error(request, f"Please correct the errors: {form.errors}") context = { - 'form': form, - 'base_layout': base_layout, - 'source_user': source_user, + "form": form, + "base_layout": base_layout, + "source_user": source_user, } - return render(request, 'complaints/complaint_form.html', context) - + return render(request, "complaints/complaint_form.html", context) + try: # Create complaint with AI defaults complaint = form.save(commit=False) # Set AI-determined defaults - complaint.title = 'Complaint' # AI will generate title + complaint.title = "Complaint" # AI will generate title # category can be None, AI will determine it - complaint.subcategory = '' # AI will determine + complaint.subcategory = "" # AI will determine # Set source from form if selected, otherwise from logged-in source user - if form.cleaned_data.get('source'): + if form.cleaned_data.get("source"): # User explicitly selected a PX source - complaint.source = form.cleaned_data['source'] + complaint.source = form.cleaned_data["source"] elif source_user and source_user.source: # Source user is submitting (auto-assign their source) complaint.source = source_user.source else: # Fallback: get or create a 'staff' source from apps.px_sources.models import PXSource + try: - source_obj = PXSource.objects.get(code='staff') + source_obj = PXSource.objects.get(code="staff") except PXSource.DoesNotExist: source_obj = PXSource.objects.create( - code='staff', - name='Staff', - description='Complaints submitted by staff members' + code="staff", name="Staff", description="Complaints submitted by staff members" ) complaint.source = source_obj - complaint.priority = 'medium' # AI will update - complaint.severity = 'medium' # AI will update + complaint.priority = "medium" # AI will update + complaint.severity = "medium" # AI will update complaint.created_by = request.user - + # Generate unique reference number: CMP-YYYYMMDD-XXXXX import uuid from datetime import datetime - + today = datetime.now().strftime("%Y%m%d") random_suffix = str(uuid.uuid4().int)[:6] complaint.reference_number = f"CMP-{today}-{random_suffix}" - + complaint.save() # Create initial update @@ -560,6 +587,7 @@ def complaint_create(request): # During working hours: ALL admins notified # Outside working hours: Only ON-CALL admins notified from .tasks import notify_admins_new_complaint + notify_admins_new_complaint.delay(str(complaint.id)) # Log audit @@ -569,7 +597,7 @@ def complaint_create(request): user=request.user, content_object=complaint, metadata={ - 'severity': complaint.severity, + "severity": complaint.severity, "patient_name": complaint.patient_name, "national_id": complaint.national_id, "hospital": complaint.hospital.name if complaint.hospital else None, @@ -590,16 +618,16 @@ def complaint_create(request): # GET request - show form # Check for hospital parameter from URL (for pre-selection) initial_data = {} - hospital_id = request.GET.get('hospital') + hospital_id = request.GET.get("hospital") if hospital_id: - initial_data['hospital'] = hospital_id - + initial_data["hospital"] = hospital_id + form = ComplaintForm(user=request.user, initial=initial_data) context = { - 'form': form, - 'base_layout': base_layout, - 'source_user': source_user, + "form": form, + "base_layout": base_layout, + "source_user": source_user, # "hospitals": hospitals, } @@ -614,7 +642,10 @@ def complaint_assign(request, pk): # Check if complaint is in active status if not complaint.is_active_status: - messages.error(request, f"Cannot assign complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") + messages.error( + request, + f"Cannot assign complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", + ) return redirect("complaints:complaint_detail", pk=pk) # Check permission @@ -738,7 +769,10 @@ def complaint_add_note(request, pk): # Check if complaint is in active status if not complaint.is_active_status: - messages.error(request, f"Cannot add notes to complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") + messages.error( + request, + f"Cannot add notes to complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", + ) return redirect("complaints:complaint_detail", pk=pk) note = request.POST.get("note") @@ -761,7 +795,10 @@ def complaint_change_department(request, pk): # Check if complaint is in active status if not complaint.is_active_status: - messages.error(request, f"Cannot change department for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") + messages.error( + request, + f"Cannot change department for complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", + ) return redirect("complaints:complaint_detail", pk=pk) # Check permission @@ -827,7 +864,10 @@ def complaint_escalate(request, pk): # Check if complaint is in active status if not complaint.is_active_status: - messages.error(request, f"Cannot escalate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") + messages.error( + request, + f"Cannot escalate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", + ) return redirect("complaints:complaint_detail", pk=pk) # Check permission @@ -838,11 +878,11 @@ def complaint_escalate(request, pk): reason = request.POST.get("reason", "") escalate_to_id = request.POST.get("escalate_to", "") - + # Get the escalation target staff escalate_to_staff = None escalate_to_user = None - + if escalate_to_id: try: escalate_to_staff = Staff.objects.get(id=escalate_to_id) @@ -850,13 +890,13 @@ def complaint_escalate(request, pk): escalate_to_user = escalate_to_staff.user except Staff.DoesNotExist: pass - + # If no staff selected or not found, default to staff's manager if not escalate_to_staff and complaint.staff and complaint.staff.report_to: escalate_to_staff = complaint.staff.report_to if escalate_to_staff.user and escalate_to_staff.user.is_active: escalate_to_user = escalate_to_staff.user - + # Mark as escalated and assign to selected user complaint.escalated_at = timezone.now() if escalate_to_user: @@ -867,7 +907,7 @@ def complaint_escalate(request, pk): escalation_message = f"Complaint escalated. Reason: {reason}" if escalate_to_user: escalation_message += f" Escalated to: {escalate_to_user.get_full_name()}" - + ComplaintUpdate.objects.create( complaint=complaint, update_type="escalation", @@ -892,10 +932,11 @@ def complaint_escalate(request, pk): "escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None, }, ) - + # Send notification to the escalated user if escalate_to_user and escalate_to_user.email: from apps.notifications.services import NotificationService + try: NotificationService.send_email( email=escalate_to_user.email, @@ -925,15 +966,18 @@ This is an automated message from PX360 Complaint Management System. """, related_object=complaint, metadata={ - 'notification_type': 'complaint_escalated', - 'escalated_by': str(request.user.id), - 'reason': reason - } + "notification_type": "complaint_escalated", + "escalated_by": str(request.user.id), + "reason": reason, + }, ) except Exception as e: logger.error(f"Failed to send escalation notification: {e}") - messages.success(request, f"Complaint escalated successfully{f' to {escalate_to_user.get_full_name()}' if escalate_to_user else ''}.") + messages.success( + request, + f"Complaint escalated successfully{f' to {escalate_to_user.get_full_name()}' if escalate_to_user else ''}.", + ) return redirect("complaints:complaint_detail", pk=pk) @@ -942,7 +986,7 @@ This is an automated message from PX360 Complaint Management System. def complaint_activate(request, pk): """ Activate complaint and assign to current user. - + This allows a user to take ownership of an unassigned complaint or reassign it to themselves. """ @@ -950,18 +994,21 @@ def complaint_activate(request, pk): # Check if complaint is in active status if not complaint.is_active_status: - messages.error(request, f"Cannot activate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.") + messages.error( + request, + f"Cannot activate complaint with status '{complaint.get_status_display()}'. Complaint must be Open, In Progress, or Partially Resolved.", + ) return redirect("complaints:complaint_detail", pk=pk) # Check permission - must be able to edit the complaint user = request.user can_activate = ( - user.is_px_admin() or - user.is_hospital_admin() or - (user.is_department_manager() and complaint.department == user.department) or - (complaint.hospital == user.hospital) + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and complaint.department == user.department) + or (complaint.hospital == user.hospital) ) - + if not can_activate: messages.error(request, "You don't have permission to activate this complaint.") return redirect("complaints:complaint_detail", pk=pk) @@ -972,7 +1019,7 @@ def complaint_activate(request, pk): # Assign to current user complaint.assigned_to = user complaint.assigned_at = timezone.now() - + # Set activated_at if this is the first activation (status is OPEN) if complaint.status == ComplaintStatus.OPEN: complaint.status = ComplaintStatus.IN_PROGRESS @@ -985,7 +1032,7 @@ def complaint_activate(request, pk): assign_message = f"Complaint activated and assigned to {user.get_full_name()}" if previous_assignee: assign_message += f" (reassigned from {previous_assignee.get_full_name()})" - + ComplaintUpdate.objects.create( complaint=complaint, update_type="assignment", @@ -1239,8 +1286,8 @@ def inquiry_list(request): # Apply RBAC filters user = request.user # Get selected hospital for PX Admins (from middleware) - selected_hospital = getattr(request, 'tenant_hospital', None) - + selected_hospital = getattr(request, "tenant_hospital", None) + if user.is_px_admin(): # PX Admins see all, but filter by selected hospital if set if selected_hospital: @@ -1294,28 +1341,28 @@ def inquiry_list(request): 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 + total_count = queryset.count() + resolved_count = queryset.filter(status="resolved").count() + stats = { + "total": total_count, + "open": queryset.filter(status="open").count(), + "in_progress": queryset.filter(status="in_progress").count(), + "resolved": resolved_count, + "resolved_percentage": (resolved_count / total_count * 100) if total_count > 0 else 0, + "overdue": queryset.filter(is_overdue=True).count(), + } + + # Get departments for filter departments = Department.objects.filter(status="active") if not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) - # Statistics - stats = { - "total": queryset.count(), - "open": queryset.filter(status="open").count(), - "in_progress": queryset.filter(status="in_progress").count(), - "resolved": queryset.filter(status="resolved").count(), - } - context = { "page_obj": page_obj, "inquiries": page_obj.object_list, "stats": stats, - "hospitals": hospitals, "departments": departments, "filters": request.GET, } @@ -1335,9 +1382,10 @@ def inquiry_detail(request, pk): - Workflow actions (assign, status change, add note, respond) """ from apps.px_sources.models import SourceUser + source_user = SourceUser.objects.filter(user=request.user).first() - base_layout = 'layouts/source_user_base.html' if source_user else 'layouts/base.html' - + base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html" + inquiry = get_object_or_404( Inquiry.objects.select_related( "patient", "hospital", "department", "assigned_to", "responded_by" @@ -1375,14 +1423,14 @@ def inquiry_detail(request, pk): ] context = { - 'inquiry': inquiry, - 'timeline': timeline, - 'attachments': attachments, - 'assignable_users': assignable_users, - 'status_choices': status_choices, - 'can_edit': user.is_px_admin() or user.is_hospital_admin(), - 'base_layout': base_layout, - 'source_user': source_user, + "inquiry": inquiry, + "timeline": timeline, + "attachments": attachments, + "assignable_users": assignable_users, + "status_choices": status_choices, + "can_edit": user.is_px_admin() or user.is_hospital_admin(), + "base_layout": base_layout, + "source_user": source_user, } return render(request, "complaints/inquiry_detail.html", context) @@ -1395,21 +1443,21 @@ def inquiry_create(request): from .models import Inquiry from .forms import InquiryForm from apps.px_sources.models import SourceUser - + # Determine base layout based on user type source_user = SourceUser.objects.filter(user=request.user).first() - base_layout = 'layouts/source_user_base.html' if source_user else 'layouts/base.html' - + base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html" + if request.method == "POST": form = InquiryForm(request.POST, user=request.user) - + if form.is_valid(): try: inquiry = form.save(commit=False) - + # Set category from form inquiry.category = request.POST.get("category") - + inquiry.save() # Log audit @@ -1432,10 +1480,10 @@ def inquiry_create(request): # GET request - show form # Check for hospital parameter from URL (for pre-selection) initial_data = {} - hospital_id = request.GET.get('hospital') + hospital_id = request.GET.get("hospital") if hospital_id: - initial_data['hospital'] = hospital_id - + initial_data["hospital"] = hospital_id + form = InquiryForm(user=request.user, initial=initial_data) context = { @@ -1472,28 +1520,28 @@ def inquiry_activate(request, pk): # Update inquiry inquiry.assigned_to = user inquiry.assigned_at = timezone.now() - + # Only change status to in_progress if it's currently open if inquiry.status == "open": inquiry.status = "in_progress" - + inquiry.save(update_fields=["assigned_to", "assigned_at", "status"]) # Create update - roles_display = ', '.join(user.get_role_names()) + roles_display = ", ".join(user.get_role_names()) InquiryUpdate.objects.create( inquiry=inquiry, update_type="assignment", message=f"Inquiry activated and assigned to {user.get_full_name()} ({roles_display})", created_by=request.user, metadata={ - 'old_assignee_id': str(old_assignee.id) if old_assignee else None, - 'new_assignee_id': str(user.id), - 'assignee_roles': user.get_role_names(), - 'old_status': old_status, - 'new_status': inquiry.status, - 'activated_by_current_user': True - } + "old_assignee_id": str(old_assignee.id) if old_assignee else None, + "new_assignee_id": str(user.id), + "assignee_roles": user.get_role_names(), + "old_status": old_status, + "new_status": inquiry.status, + "activated_by_current_user": True, + }, ) # Log audit @@ -1503,11 +1551,11 @@ def inquiry_activate(request, pk): user=request.user, content_object=inquiry, metadata={ - 'old_assignee_id': str(old_assignee.id) if old_assignee else None, - 'new_assignee_id': str(user.id), - 'old_status': old_status, - 'new_status': inquiry.status - } + "old_assignee_id": str(old_assignee.id) if old_assignee else None, + "new_assignee_id": str(user.id), + "old_status": old_status, + "new_status": inquiry.status, + }, ) messages.success(request, f"Inquiry activated and assigned to you successfully.") @@ -1790,7 +1838,7 @@ def public_complaint_submit(request): # Get location hierarchy objects from apps.organizations.models import Location, MainSection, SubSection - + location = Location.objects.get(id=location_id) main_section = MainSection.objects.get(id=main_section_id) subsection = None @@ -1826,13 +1874,13 @@ def public_complaint_submit(request): contact_email=email, # Store additional information in metadata metadata={ - 'relation_to_patient': relation_to_patient, - 'patient_name': patient_name, - 'national_id': national_id, - 'incident_date': incident_date, - 'staff_name': staff_name, - 'expected_result': expected_result, - } + "relation_to_patient": relation_to_patient, + "patient_name": patient_name, + "national_id": national_id, + "incident_date": incident_date, + "staff_name": staff_name, + "expected_result": expected_result, + }, ) # Create initial update @@ -1900,10 +1948,10 @@ def public_complaint_submit(request): def public_complaint_track(request): """ Public complaint tracking page. - + Allows complainants to check the status of their complaint using the reference number. No authentication required. - + Features: - Form to enter reference number - Display complaint status when found @@ -1914,52 +1962,56 @@ def public_complaint_track(request): complaint = None error_message = None reference_number = request.GET.get("reference", "").strip() - + if request.method == "POST": reference_number = request.POST.get("reference_number", "").strip() - + if not reference_number: error_message = _("Please enter a reference number.") else: # Try to find complaint by reference number try: - complaint = Complaint.objects.select_related( - "hospital", "department", "location", "main_section", "subsection" - ).prefetch_related("updates").get(reference_number__iexact=reference_number) - + complaint = ( + Complaint.objects.select_related("hospital", "department", "location", "main_section", "subsection") + .prefetch_related("updates") + .get(reference_number__iexact=reference_number) + ) + # Check overdue status complaint.check_overdue() - + except Complaint.DoesNotExist: error_message = _("No complaint found with this reference number. Please check and try again.") - + elif reference_number: # GET request with reference parameter try: - complaint = Complaint.objects.select_related( - "hospital", "department", "location", "main_section", "subsection" - ).prefetch_related("updates").get(reference_number__iexact=reference_number) - + complaint = ( + Complaint.objects.select_related("hospital", "department", "location", "main_section", "subsection") + .prefetch_related("updates") + .get(reference_number__iexact=reference_number) + ) + # Check overdue status complaint.check_overdue() - + except Complaint.DoesNotExist: error_message = _("No complaint found with this reference number. Please check and try again.") - + # Get public updates only (exclude internal notes) public_updates = [] if complaint: public_updates = complaint.updates.filter( update_type__in=["status_change", "resolution", "communication"] ).order_by("-created_at") - + context = { "complaint": complaint, "public_updates": public_updates, "error_message": error_message, "reference_number": reference_number, } - + return render(request, "complaints/public_complaint_track.html", context) @@ -2021,7 +2073,7 @@ def api_load_categories(request): Returns both parent categories and their subcategories with parent_id. Now includes level field for 4-level hierarchy support (Domain, Category, Subcategory, Classification). No authentication required for public form. - + Updated: Always returns system-wide categories even without hospital_id, to support initial form loading. """ @@ -2034,20 +2086,16 @@ def api_load_categories(request): # Return hospital-specific and system-wide categories # Empty hospitals list = system-wide categories_queryset = ( - ComplaintCategory.objects.filter( - Q(hospitals__id=hospital_id) | Q(hospitals__isnull=True), - is_active=True - ) + ComplaintCategory.objects.filter(Q(hospitals__id=hospital_id) | Q(hospitals__isnull=True), is_active=True) .distinct() .order_by("level", "order", "name_en") ) else: # Return all system-wide categories (empty hospitals list) # This allows form to load domains on initial page load - categories_queryset = ComplaintCategory.objects.filter( - Q(hospitals__isnull=True), - is_active=True - ).order_by("level", "order", "name_en") + categories_queryset = ComplaintCategory.objects.filter(Q(hospitals__isnull=True), is_active=True).order_by( + "level", "order", "name_en" + ) # Get all categories with parent_id, level, domain_type, and descriptions categories = categories_queryset.values( @@ -2172,17 +2220,11 @@ def sla_config_list(request): 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) - from apps.core.models import SeverityChoices, PriorityChoices context = { "page_obj": page_obj, "sla_configs": page_obj.object_list, - "hospitals": hospitals, "severity_choices": SeverityChoices.choices, "priority_choices": PriorityChoices.choices, "filters": request.GET, @@ -2385,15 +2427,9 @@ def escalation_rule_list(request): 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, "escalation_rules": page_obj.object_list, - "hospitals": hospitals, "filters": request.GET, } @@ -2591,15 +2627,9 @@ def complaint_threshold_list(request): 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, "thresholds": page_obj.object_list, - "hospitals": hospitals, "filters": request.GET, } @@ -2752,17 +2782,17 @@ def complaint_threshold_delete(request, pk): return redirect("complaints:complaint_threshold_list") - # ============================================================================ # Involved Departments and Staff Views # ============================================================================ + @login_required @require_http_methods(["GET", "POST"]) def involved_department_add(request, complaint_pk): """ Add an involved department to a complaint. - + Allows assigning multiple departments with different roles: - Primary: Main responsible department - Secondary/Supporting: Assisting departments @@ -2770,31 +2800,27 @@ def involved_department_add(request, complaint_pk): - Investigating: Leading the investigation """ complaint = get_object_or_404(Complaint, pk=complaint_pk) - + # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - + if request.method == "POST": - form = ComplaintInvolvedDepartmentForm( - request.POST, - complaint=complaint, - user=user - ) - + form = ComplaintInvolvedDepartmentForm(request.POST, complaint=complaint, user=user) + if form.is_valid(): involved_dept = form.save(commit=False) involved_dept.complaint = complaint involved_dept.added_by = user - + # Set assignment timestamp if assigned if involved_dept.assigned_to and not involved_dept.assigned_at: involved_dept.assigned_at = timezone.now() - + involved_dept.save() - + # Log the update ComplaintUpdate.objects.create( complaint=complaint, @@ -2802,7 +2828,7 @@ def involved_department_add(request, complaint_pk): message=f"Department added: {involved_dept.department.name} ({involved_dept.get_role_display()})", created_by=user, ) - + # Audit log AuditService.log_event( event_type="complaint_department_added", @@ -2816,26 +2842,24 @@ def involved_department_add(request, complaint_pk): "is_primary": involved_dept.is_primary, }, ) - + messages.success( - request, - _("Department '%(dept)s' added successfully as %(role)s.") % { - 'dept': involved_dept.department.name, - 'role': involved_dept.get_role_display() - } + request, + _("Department '%(dept)s' added successfully as %(role)s.") + % {"dept": involved_dept.department.name, "role": involved_dept.get_role_display()}, ) return redirect("complaints:complaint_detail", pk=complaint.pk) else: messages.error(request, _("Please correct the errors below.")) else: form = ComplaintInvolvedDepartmentForm(complaint=complaint, user=user) - + context = { "form": form, "complaint": complaint, "title": _("Add Involved Department"), } - + return render(request, "complaints/involved_department_form.html", context) @@ -2846,35 +2870,30 @@ def involved_department_edit(request, pk): Edit an involved department's details. """ from .models import ComplaintInvolvedDepartment - + involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint - + # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - + if request.method == "POST": - form = ComplaintInvolvedDepartmentForm( - request.POST, - instance=involved_dept, - complaint=complaint, - user=user - ) - + form = ComplaintInvolvedDepartmentForm(request.POST, instance=involved_dept, complaint=complaint, user=user) + if form.is_valid(): # Check if assignment is being changed old_assigned_to = involved_dept.assigned_to involved_dept = form.save(commit=False) - + # Set assignment timestamp if newly assigned if involved_dept.assigned_to and not old_assigned_to: involved_dept.assigned_at = timezone.now() - + involved_dept.save() - + # Log the update ComplaintUpdate.objects.create( complaint=complaint, @@ -2882,25 +2901,21 @@ def involved_department_edit(request, pk): message=f"Department updated: {involved_dept.department.name} ({involved_dept.get_role_display()})", created_by=user, ) - + messages.success(request, _("Department involvement updated successfully.")) return redirect("complaints:complaint_detail", pk=complaint.pk) else: messages.error(request, _("Please correct the errors below.")) else: - form = ComplaintInvolvedDepartmentForm( - instance=involved_dept, - complaint=complaint, - user=user - ) - + form = ComplaintInvolvedDepartmentForm(instance=involved_dept, complaint=complaint, user=user) + context = { "form": form, "complaint": complaint, "involved_dept": involved_dept, "title": _("Edit Involved Department"), } - + return render(request, "complaints/involved_department_form.html", context) @@ -2911,19 +2926,19 @@ def involved_department_remove(request, pk): Remove an involved department from a complaint. """ from .models import ComplaintInvolvedDepartment - + involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint department_name = involved_dept.department.name - + # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - + involved_dept.delete() - + # Log the update ComplaintUpdate.objects.create( complaint=complaint, @@ -2931,7 +2946,7 @@ def involved_department_remove(request, pk): message=f"Department removed: {department_name}", created_by=user, ) - + # Audit log AuditService.log_event( event_type="complaint_department_removed", @@ -2939,7 +2954,7 @@ def involved_department_remove(request, pk): user=user, content_object=complaint, ) - + messages.success(request, _("Department removed successfully.")) return redirect("complaints:complaint_detail", pk=complaint.pk) @@ -2951,29 +2966,26 @@ def involved_department_response(request, pk): Submit a department's response to the complaint. """ from .models import ComplaintInvolvedDepartment - + involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) complaint = involved_dept.complaint user = request.user - + # Check permission - must be assigned to this department or have complaint management rights - can_respond = ( - involved_dept.assigned_to == user or - can_manage_complaint(user, complaint) - ) - + can_respond = involved_dept.assigned_to == user or can_manage_complaint(user, complaint) + if not can_respond: messages.error(request, _("You don't have permission to submit a response for this department.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - + form = DepartmentResponseForm(request.POST, instance=involved_dept) - + if form.is_valid(): involved_dept = form.save(commit=False) involved_dept.response_submitted = True involved_dept.response_submitted_at = timezone.now() involved_dept.save() - + # Log the update ComplaintUpdate.objects.create( complaint=complaint, @@ -2981,11 +2993,11 @@ def involved_department_response(request, pk): message=f"Response submitted by {involved_dept.department.name}", created_by=user, ) - + messages.success(request, _("Department response submitted successfully.")) else: messages.error(request, _("Please provide a valid response.")) - + return redirect("complaints:complaint_detail", pk=complaint.pk) @@ -2994,7 +3006,7 @@ def involved_department_response(request, pk): def involved_staff_add(request, complaint_pk): """ Add an involved staff member to a complaint. - + Allows assigning multiple staff members with different roles: - Accused/Involved: Staff member involved in the incident - Witness: Staff member who witnessed the incident @@ -3004,26 +3016,22 @@ def involved_staff_add(request, complaint_pk): - Coordinator: Coordination role """ complaint = get_object_or_404(Complaint, pk=complaint_pk) - + # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - + if request.method == "POST": - form = ComplaintInvolvedStaffForm( - request.POST, - complaint=complaint, - user=user - ) - + form = ComplaintInvolvedStaffForm(request.POST, complaint=complaint, user=user) + if form.is_valid(): involved_staff = form.save(commit=False) involved_staff.complaint = complaint involved_staff.added_by = user involved_staff.save() - + # Log the update ComplaintUpdate.objects.create( complaint=complaint, @@ -3031,7 +3039,7 @@ def involved_staff_add(request, complaint_pk): message=f"Staff added: {involved_staff.staff} ({involved_staff.get_role_display()})", created_by=user, ) - + # Audit log AuditService.log_event( event_type="complaint_staff_added", @@ -3044,26 +3052,24 @@ def involved_staff_add(request, complaint_pk): "role": involved_staff.role, }, ) - + messages.success( request, - _("Staff member '%(staff)s' added successfully as %(role)s.") % { - 'staff': involved_staff.staff, - 'role': involved_staff.get_role_display() - } + _("Staff member '%(staff)s' added successfully as %(role)s.") + % {"staff": involved_staff.staff, "role": involved_staff.get_role_display()}, ) return redirect("complaints:complaint_detail", pk=complaint.pk) else: messages.error(request, _("Please correct the errors below.")) else: form = ComplaintInvolvedStaffForm(complaint=complaint, user=user) - + context = { "form": form, "complaint": complaint, "title": _("Add Involved Staff"), } - + return render(request, "complaints/involved_staff_form.html", context) @@ -3074,27 +3080,22 @@ def involved_staff_edit(request, pk): Edit an involved staff member's details. """ from .models import ComplaintInvolvedStaff - + involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk) complaint = involved_staff.complaint - + # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - + if request.method == "POST": - form = ComplaintInvolvedStaffForm( - request.POST, - instance=involved_staff, - complaint=complaint, - user=user - ) - + form = ComplaintInvolvedStaffForm(request.POST, instance=involved_staff, complaint=complaint, user=user) + if form.is_valid(): form.save() - + # Log the update ComplaintUpdate.objects.create( complaint=complaint, @@ -3102,25 +3103,21 @@ def involved_staff_edit(request, pk): message=f"Staff updated: {involved_staff.staff} ({involved_staff.get_role_display()})", created_by=user, ) - + messages.success(request, _("Staff involvement updated successfully.")) return redirect("complaints:complaint_detail", pk=complaint.pk) else: messages.error(request, _("Please correct the errors below.")) else: - form = ComplaintInvolvedStaffForm( - instance=involved_staff, - complaint=complaint, - user=user - ) - + form = ComplaintInvolvedStaffForm(instance=involved_staff, complaint=complaint, user=user) + context = { "form": form, "complaint": complaint, "involved_staff": involved_staff, "title": _("Edit Involved Staff"), } - + return render(request, "complaints/involved_staff_form.html", context) @@ -3131,19 +3128,19 @@ def involved_staff_remove(request, pk): Remove an involved staff member from a complaint. """ from .models import ComplaintInvolvedStaff - + involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk) complaint = involved_staff.complaint staff_name = str(involved_staff.staff) - + # Check permission user = request.user if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to manage this complaint.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - + involved_staff.delete() - + # Log the update ComplaintUpdate.objects.create( complaint=complaint, @@ -3151,7 +3148,7 @@ def involved_staff_remove(request, pk): message=f"Staff removed: {staff_name}", created_by=user, ) - + # Audit log AuditService.log_event( event_type="complaint_staff_removed", @@ -3159,7 +3156,7 @@ def involved_staff_remove(request, pk): user=user, content_object=complaint, ) - + messages.success(request, _("Staff member removed successfully.")) return redirect("complaints:complaint_detail", pk=complaint.pk) @@ -3171,29 +3168,28 @@ def involved_staff_explanation(request, pk): Submit an explanation as an involved staff member. """ from .models import ComplaintInvolvedStaff - + involved_staff = get_object_or_404(ComplaintInvolvedStaff, pk=pk) complaint = involved_staff.complaint user = request.user - + # Check permission - must be the staff member themselves or have management rights can_submit = ( - (involved_staff.staff.user == user if hasattr(involved_staff.staff, 'user') else False) or - can_manage_complaint(user, complaint) - ) - + involved_staff.staff.user == user if hasattr(involved_staff.staff, "user") else False + ) or can_manage_complaint(user, complaint) + if not can_submit: messages.error(request, _("You don't have permission to submit an explanation for this staff member.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - + form = StaffExplanationForm(request.POST, instance=involved_staff) - + if form.is_valid(): involved_staff = form.save(commit=False) involved_staff.explanation_received = True involved_staff.explanation_received_at = timezone.now() involved_staff.save() - + # Log the update ComplaintUpdate.objects.create( complaint=complaint, @@ -3201,11 +3197,11 @@ def involved_staff_explanation(request, pk): message=f"Explanation submitted by {involved_staff.staff}", created_by=user, ) - + messages.success(request, _("Explanation submitted successfully.")) else: messages.error(request, _("Please provide a valid explanation.")) - + return redirect("complaints:complaint_detail", pk=complaint.pk) @@ -3213,11 +3209,12 @@ def involved_staff_explanation(request, pk): # Complaint Adverse Action Views # ============================================================================= + @login_required def adverse_action_list(request): """ List all adverse actions related to complaints. - + Features: - Filter by status, severity, action type - Search by complaint reference or description @@ -3227,53 +3224,53 @@ def adverse_action_list(request): user = request.user if not user.is_px_admin() and not user.is_hospital_admin(): messages.error(request, _("You don't have permission to view adverse actions.")) - return redirect('complaints:complaint_list') - + return redirect("complaints:complaint_list") + # Base queryset queryset = ComplaintAdverseAction.objects.select_related( - 'complaint', 'complaint__hospital', 'reported_by' - ).prefetch_related('involved_staff') - + "complaint", "complaint__hospital", "reported_by" + ).prefetch_related("involved_staff") + # Apply filters - status_filter = request.GET.get('status') + status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) - - severity_filter = request.GET.get('severity') + + severity_filter = request.GET.get("severity") if severity_filter: queryset = queryset.filter(severity=severity_filter) - - action_type_filter = request.GET.get('action_type') + + action_type_filter = request.GET.get("action_type") if action_type_filter: queryset = queryset.filter(action_type=action_type_filter) - + # Filter by hospital for hospital admins if user.is_hospital_admin() and user.hospital: queryset = queryset.filter(complaint__hospital=user.hospital) - + # Search - search_query = request.GET.get('search') + search_query = request.GET.get("search") if search_query: queryset = queryset.filter( - Q(complaint__reference_number__icontains=search_query) | - Q(description__icontains=search_query) | - Q(patient_impact__icontains=search_query) + Q(complaint__reference_number__icontains=search_query) + | Q(description__icontains=search_query) + | Q(patient_impact__icontains=search_query) ) - + # Pagination - paginator = Paginator(queryset.order_by('-incident_date'), 25) - page_number = request.GET.get('page') + paginator = Paginator(queryset.order_by("-incident_date"), 25) + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + context = { - 'page_obj': page_obj, - 'status_choices': ComplaintAdverseAction.VerificationStatus.choices, - 'severity_choices': ComplaintAdverseAction.SeverityLevel.choices, - 'action_type_choices': ComplaintAdverseAction.ActionType.choices, - 'filters': request.GET, + "page_obj": page_obj, + "status_choices": ComplaintAdverseAction.VerificationStatus.choices, + "severity_choices": ComplaintAdverseAction.SeverityLevel.choices, + "action_type_choices": ComplaintAdverseAction.ActionType.choices, + "filters": request.GET, } - - return render(request, 'complaints/adverse_action_list.html', context) + + return render(request, "complaints/adverse_action_list.html", context) @login_required @@ -3283,32 +3280,36 @@ def adverse_action_add(request, complaint_pk): """ complaint = get_object_or_404(Complaint, pk=complaint_pk) user = request.user - + # Check permission if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to add adverse actions to this complaint.")) - return redirect('complaints:complaint_detail', pk=complaint_pk) - - if request.method == 'POST': + return redirect("complaints:complaint_detail", pk=complaint_pk) + + if request.method == "POST": try: # Parse form data - action_type = request.POST.get('action_type') - severity = request.POST.get('severity') - description = request.POST.get('description', '').strip() - patient_impact = request.POST.get('patient_impact', '').strip() - incident_date = request.POST.get('incident_date') - location = request.POST.get('location', '').strip() - involved_staff_ids = request.POST.getlist('involved_staff') - + action_type = request.POST.get("action_type") + severity = request.POST.get("severity") + description = request.POST.get("description", "").strip() + patient_impact = request.POST.get("patient_impact", "").strip() + incident_date = request.POST.get("incident_date") + location = request.POST.get("location", "").strip() + involved_staff_ids = request.POST.getlist("involved_staff") + if not description: messages.error(request, _("Description is required.")) - return render(request, 'complaints/adverse_action_form.html', { - 'complaint': complaint, - 'action_type_choices': ComplaintAdverseAction.ActionType.choices, - 'severity_choices': ComplaintAdverseAction.SeverityLevel.choices, - 'staff_list': Staff.objects.filter(hospital=complaint.hospital, is_active=True), - }) - + return render( + request, + "complaints/adverse_action_form.html", + { + "complaint": complaint, + "action_type_choices": ComplaintAdverseAction.ActionType.choices, + "severity_choices": ComplaintAdverseAction.SeverityLevel.choices, + "staff_list": Staff.objects.filter(hospital=complaint.hospital, is_active=True), + }, + ) + # Create adverse action adverse_action = ComplaintAdverseAction.objects.create( complaint=complaint, @@ -3319,49 +3320,51 @@ def adverse_action_add(request, complaint_pk): incident_date=incident_date or timezone.now(), location=location, reported_by=user, - status=ComplaintAdverseAction.VerificationStatus.REPORTED + status=ComplaintAdverseAction.VerificationStatus.REPORTED, ) - + # Add involved staff if involved_staff_ids: adverse_action.involved_staff.set(involved_staff_ids) - + # Create timeline entry ComplaintUpdate.objects.create( complaint=complaint, - update_type='note', + update_type="note", message=f"Adverse action reported: {adverse_action.get_action_type_display()}", created_by=user, ) - + # Log audit AuditService.log_event( - event_type='adverse_action_created', + event_type="adverse_action_created", description=f"Adverse action added to complaint {complaint.reference_number}: {adverse_action.get_action_type_display()}", user=user, content_object=adverse_action, metadata={ - 'complaint_id': str(complaint.id), - 'action_type': action_type, - 'severity': severity, - } + "complaint_id": str(complaint.id), + "action_type": action_type, + "severity": severity, + }, ) - + messages.success(request, _("Adverse action reported successfully.")) - return redirect('complaints:complaint_detail', pk=complaint_pk) - + return redirect("complaints:complaint_detail", pk=complaint_pk) + except Exception as e: logger.error(f"Error creating adverse action: {str(e)}", exc_info=True) messages.error(request, f"Error creating adverse action: {str(e)}") - + context = { - 'complaint': complaint, - 'action_type_choices': ComplaintAdverseAction.ActionType.choices, - 'severity_choices': ComplaintAdverseAction.SeverityLevel.choices, - 'staff_list': Staff.objects.filter(hospital=complaint.hospital, is_active=True) if complaint.hospital else Staff.objects.filter(is_active=True), + "complaint": complaint, + "action_type_choices": ComplaintAdverseAction.ActionType.choices, + "severity_choices": ComplaintAdverseAction.SeverityLevel.choices, + "staff_list": Staff.objects.filter(hospital=complaint.hospital, is_active=True) + if complaint.hospital + else Staff.objects.filter(is_active=True), } - - return render(request, 'complaints/adverse_action_form.html', context) + + return render(request, "complaints/adverse_action_form.html", context) @login_required @@ -3369,63 +3372,62 @@ def adverse_action_edit(request, pk): """ Edit an existing adverse action. """ - adverse_action = get_object_or_404( - ComplaintAdverseAction.objects.select_related('complaint'), - pk=pk - ) + adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk) complaint = adverse_action.complaint user = request.user - + # Check permission if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to edit this adverse action.")) - return redirect('complaints:complaint_detail', pk=complaint.id) - - if request.method == 'POST': + return redirect("complaints:complaint_detail", pk=complaint.id) + + if request.method == "POST": try: # Update fields - adverse_action.action_type = request.POST.get('action_type', adverse_action.action_type) - adverse_action.severity = request.POST.get('severity', adverse_action.severity) - adverse_action.description = request.POST.get('description', adverse_action.description).strip() - adverse_action.patient_impact = request.POST.get('patient_impact', adverse_action.patient_impact).strip() - adverse_action.location = request.POST.get('location', adverse_action.location).strip() - - incident_date = request.POST.get('incident_date') + adverse_action.action_type = request.POST.get("action_type", adverse_action.action_type) + adverse_action.severity = request.POST.get("severity", adverse_action.severity) + adverse_action.description = request.POST.get("description", adverse_action.description).strip() + adverse_action.patient_impact = request.POST.get("patient_impact", adverse_action.patient_impact).strip() + adverse_action.location = request.POST.get("location", adverse_action.location).strip() + + incident_date = request.POST.get("incident_date") if incident_date: adverse_action.incident_date = incident_date - + # Update involved staff - involved_staff_ids = request.POST.getlist('involved_staff') + involved_staff_ids = request.POST.getlist("involved_staff") if involved_staff_ids: adverse_action.involved_staff.set(involved_staff_ids) - + adverse_action.save() - + # Log audit AuditService.log_event( - event_type='adverse_action_updated', + event_type="adverse_action_updated", description=f"Adverse action updated for complaint {complaint.reference_number}", user=user, content_object=adverse_action, ) - + messages.success(request, _("Adverse action updated successfully.")) - return redirect('complaints:complaint_detail', pk=complaint.id) - + return redirect("complaints:complaint_detail", pk=complaint.id) + except Exception as e: logger.error(f"Error updating adverse action: {str(e)}", exc_info=True) messages.error(request, f"Error updating adverse action: {str(e)}") - + context = { - 'adverse_action': adverse_action, - 'complaint': complaint, - 'action_type_choices': ComplaintAdverseAction.ActionType.choices, - 'severity_choices': ComplaintAdverseAction.SeverityLevel.choices, - 'staff_list': Staff.objects.filter(hospital=complaint.hospital, is_active=True) if complaint.hospital else Staff.objects.filter(is_active=True), - 'selected_staff': list(adverse_action.involved_staff.values_list('id', flat=True)), + "adverse_action": adverse_action, + "complaint": complaint, + "action_type_choices": ComplaintAdverseAction.ActionType.choices, + "severity_choices": ComplaintAdverseAction.SeverityLevel.choices, + "staff_list": Staff.objects.filter(hospital=complaint.hospital, is_active=True) + if complaint.hospital + else Staff.objects.filter(is_active=True), + "selected_staff": list(adverse_action.involved_staff.values_list("id", flat=True)), } - - return render(request, 'complaints/adverse_action_form.html', context) + + return render(request, "complaints/adverse_action_form.html", context) @login_required @@ -3433,75 +3435,72 @@ def adverse_action_update_status(request, pk): """ Update the status of an adverse action (investigation, resolution). """ - adverse_action = get_object_or_404( - ComplaintAdverseAction.objects.select_related('complaint'), - pk=pk - ) + adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk) complaint = adverse_action.complaint user = request.user - + # Check permission if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to update this adverse action.")) - return redirect('complaints:complaint_detail', pk=complaint.id) - - if request.method == 'POST': + return redirect("complaints:complaint_detail", pk=complaint.id) + + if request.method == "POST": try: - new_status = request.POST.get('status') - investigation_notes = request.POST.get('investigation_notes', '').strip() - resolution = request.POST.get('resolution', '').strip() - + new_status = request.POST.get("status") + investigation_notes = request.POST.get("investigation_notes", "").strip() + resolution = request.POST.get("resolution", "").strip() + old_status = adverse_action.status adverse_action.status = new_status - + if investigation_notes: adverse_action.investigation_notes = investigation_notes - + if resolution: adverse_action.resolution = resolution - + # Handle status-specific updates if new_status == ComplaintAdverseAction.VerificationStatus.UNDER_INVESTIGATION: adverse_action.investigated_by = user adverse_action.investigated_at = timezone.now() - + elif new_status == ComplaintAdverseAction.VerificationStatus.RESOLVED: adverse_action.resolved_by = user adverse_action.resolved_at = timezone.now() - + elif new_status == ComplaintAdverseAction.VerificationStatus.VERIFIED: adverse_action.investigated_by = user adverse_action.investigated_at = timezone.now() - + adverse_action.save() - + # Create timeline entry ComplaintUpdate.objects.create( complaint=complaint, - update_type='note', + update_type="note", message=f"Adverse action status changed from {adverse_action.get_status_display()} to {dict(ComplaintAdverseAction.VerificationStatus.choices)[new_status]}", created_by=user, ) - + # Log audit AuditService.log_event( - event_type='adverse_action_status_changed', + event_type="adverse_action_status_changed", description=f"Adverse action status changed from {old_status} to {new_status} for complaint {complaint.reference_number}", user=user, content_object=adverse_action, metadata={ - 'old_status': old_status, - 'new_status': new_status, - } + "old_status": old_status, + "new_status": new_status, + }, ) - + messages.success(request, _("Adverse action status updated successfully.")) - + except Exception as e: logger.error(f"Error updating adverse action status: {str(e)}", exc_info=True) messages.error(request, f"Error updating status: {str(e)}") - - return redirect('complaints:complaint_detail', pk=complaint.id) + + return redirect("complaints:complaint_detail", pk=complaint.id) @login_required @@ -3509,47 +3508,44 @@ def adverse_action_escalate(request, pk): """ Escalate an adverse action to management. """ - adverse_action = get_object_or_404( - ComplaintAdverseAction.objects.select_related('complaint'), - pk=pk - ) + adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk) complaint = adverse_action.complaint user = request.user - + # Check permission if not can_manage_complaint(user, complaint): messages.error(request, _("You don't have permission to escalate this adverse action.")) - return redirect('complaints:complaint_detail', pk=complaint.id) - - if request.method == 'POST': + return redirect("complaints:complaint_detail", pk=complaint.id) + + if request.method == "POST": try: adverse_action.is_escalated = True adverse_action.escalated_at = timezone.now() adverse_action.save() - + # Create timeline entry ComplaintUpdate.objects.create( complaint=complaint, - update_type='escalation', + update_type="escalation", message=f"Adverse action escalated: {adverse_action.get_action_type_display()}", created_by=user, ) - + # Log audit AuditService.log_event( - event_type='adverse_action_escalated', + event_type="adverse_action_escalated", description=f"Adverse action escalated for complaint {complaint.reference_number}", user=user, content_object=adverse_action, ) - + messages.success(request, _("Adverse action escalated successfully.")) - + except Exception as e: logger.error(f"Error escalating adverse action: {str(e)}", exc_info=True) messages.error(request, f"Error escalating: {str(e)}") - - return redirect('complaints:complaint_detail', pk=complaint.id) + + return redirect("complaints:complaint_detail", pk=complaint.id) @login_required @@ -3557,40 +3553,37 @@ def adverse_action_delete(request, pk): """ Delete an adverse action. """ - adverse_action = get_object_or_404( - ComplaintAdverseAction.objects.select_related('complaint'), - pk=pk - ) + adverse_action = get_object_or_404(ComplaintAdverseAction.objects.select_related("complaint"), pk=pk) complaint = adverse_action.complaint user = request.user - + # Check permission - only PX Admin or Hospital Admin can delete if not (user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital)): messages.error(request, _("You don't have permission to delete adverse actions.")) - return redirect('complaints:complaint_detail', pk=complaint.id) - - if request.method == 'POST': + return redirect("complaints:complaint_detail", pk=complaint.id) + + if request.method == "POST": try: complaint_pk = complaint.id - + # Log before deletion AuditService.log_event( - event_type='adverse_action_deleted', + event_type="adverse_action_deleted", description=f"Adverse action deleted from complaint {complaint.reference_number}: {adverse_action.get_action_type_display()}", user=user, metadata={ - 'complaint_id': str(complaint.id), - 'action_type': adverse_action.action_type, - 'description': adverse_action.description[:100], - } + "complaint_id": str(complaint.id), + "action_type": adverse_action.action_type, + "description": adverse_action.description[:100], + }, ) - + adverse_action.delete() messages.success(request, _("Adverse action deleted successfully.")) - return redirect('complaints:complaint_detail', pk=complaint_pk) - + return redirect("complaints:complaint_detail", pk=complaint_pk) + except Exception as e: logger.error(f"Error deleting adverse action: {str(e)}", exc_info=True) messages.error(request, f"Error deleting: {str(e)}") - - return redirect('complaints:complaint_detail', pk=complaint.id) + + return redirect("complaints:complaint_detail", pk=complaint.id) diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index 6d743e2..e508658 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -1083,7 +1083,6 @@ def admin_evaluation(request): ).distinct().select_related('hospital', 'department') context = { - 'hospitals': hospitals, 'departments': departments, 'staff_list': staff_queryset, 'selected_hospital_id': hospital_id, diff --git a/apps/feedback/views.py b/apps/feedback/views.py index c27e540..c77c595 100644 --- a/apps/feedback/views.py +++ b/apps/feedback/views.py @@ -148,10 +148,6 @@ def feedback_list(request): 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) departments = Department.objects.filter(status='active') if not user.is_px_admin() and user.hospital: @@ -179,7 +175,6 @@ def feedback_list(request): 'page_obj': page_obj, 'feedbacks': page_obj.object_list, 'stats': stats, - 'hospitals': hospitals, 'departments': departments, 'assignable_users': assignable_users, 'status_choices': FeedbackStatus.choices, diff --git a/apps/journeys/ui_views.py b/apps/journeys/ui_views.py index 39c9604..3be33cf 100644 --- a/apps/journeys/ui_views.py +++ b/apps/journeys/ui_views.py @@ -105,10 +105,6 @@ def journey_instance_list(request): 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) departments = Department.objects.filter(status='active') if not user.is_px_admin() and user.hospital: @@ -125,7 +121,6 @@ def journey_instance_list(request): 'page_obj': page_obj, 'journeys': page_obj.object_list, 'stats': stats, - 'hospitals': hospitals, 'departments': departments, 'filters': request.GET, } @@ -221,15 +216,10 @@ def journey_template_list(request): 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, } diff --git a/apps/physicians/ui_views.py b/apps/physicians/ui_views.py index 3229ce5..f2f3f28 100644 --- a/apps/physicians/ui_views.py +++ b/apps/physicians/ui_views.py @@ -1,6 +1,7 @@ """ Physicians Console UI views - Server-rendered templates for physician management """ + from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Avg, Count, Q, Sum @@ -26,9 +27,11 @@ def physician_list(request): """ # Base queryset with optimizations - only show staff marked as physicians # Include both: staff with physician=True (from rating imports) OR staff_type='physician' - queryset = Staff.objects.filter( - Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN) - ).select_related('hospital', 'department').distinct() + queryset = ( + Staff.objects.filter(Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN)) + .select_related("hospital", "department") + .distinct() + ) # Apply RBAC filters user = request.user @@ -40,50 +43,48 @@ def physician_list(request): queryset = queryset.none() # Apply filters - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(hospital_id=hospital_filter) - department_filter = request.GET.get('department') + department_filter = request.GET.get("department") if department_filter: queryset = queryset.filter(department_id=department_filter) - specialization_filter = request.GET.get('specialization') + specialization_filter = request.GET.get("specialization") if specialization_filter: queryset = queryset.filter(specialization__icontains=specialization_filter) - status_filter = request.GET.get('status', 'active') + status_filter = request.GET.get("status", "active") if status_filter: queryset = queryset.filter(status=status_filter) # Search - search_query = request.GET.get('search') + search_query = request.GET.get("search") if search_query: queryset = queryset.filter( - Q(first_name__icontains=search_query) | - Q(last_name__icontains=search_query) | - Q(license_number__icontains=search_query) | - Q(specialization__icontains=search_query) + Q(first_name__icontains=search_query) + | Q(last_name__icontains=search_query) + | Q(license_number__icontains=search_query) + | Q(specialization__icontains=search_query) ) # Ordering - order_by = request.GET.get('order_by', 'last_name') + order_by = request.GET.get("order_by", "last_name") queryset = queryset.order_by(order_by) # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get current month ratings for displayed physicians now = timezone.now() physician_ids = [p.id for p in page_obj.object_list] current_ratings = PhysicianMonthlyRating.objects.filter( - staff_id__in=physician_ids, - year=now.year, - month=now.month - ).select_related('staff') + staff_id__in=physician_ids, year=now.year, month=now.month + ).select_related("staff") # Create rating lookup ratings_dict = {r.staff_id: r for r in current_ratings} @@ -92,217 +93,8 @@ def physician_list(request): for physician in page_obj.object_list: physician.current_rating = ratings_dict.get(physician.id) - # 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) - departments = Department.objects.filter(status='active') - if not user.is_px_admin() and user.hospital: - departments = departments.filter(hospital=user.hospital) - - # Get unique specializations (only from physicians) - specializations = Staff.objects.filter( - Q(physician=True) | Q(staff_type=Staff.StaffType.PHYSICIAN) - ).values_list('specialization', flat=True).distinct().order_by('specialization') - - # Statistics - stats = { - 'total': queryset.count(), - 'active': queryset.filter(status='active').count(), - } - - context = { - 'page_obj': page_obj, - 'physicians': page_obj.object_list, - 'stats': stats, - 'hospitals': hospitals, - 'departments': departments, - 'specializations': specializations, - 'filters': request.GET, - 'current_year': now.year, - 'current_month': now.month, - } - - return render(request, 'physicians/physician_list.html', context) - - -@login_required -def physician_detail(request, pk): - """ - Physician detail view with performance metrics. - - Features: - - Full physician details - - Current month rating - - Year-to-date performance - - Monthly ratings history (last 12 months) - - Performance trends - """ - physician = get_object_or_404( - Staff.objects.select_related('hospital', 'department'), - pk=pk - ) - - # Check permission - user = request.user - if not user.is_px_admin() and user.hospital: - if physician.hospital != user.hospital: - from django.http import Http404 - raise Http404("Physician not found") - - now = timezone.now() - current_year = now.year - current_month = now.month - - # Get current month rating - current_month_rating = PhysicianMonthlyRating.objects.filter( - staff=physician, - year=current_year, - month=current_month - ).first() - - # Get previous month rating - prev_month = current_month - 1 if current_month > 1 else 12 - prev_year = current_year if current_month > 1 else current_year - 1 - previous_month_rating = PhysicianMonthlyRating.objects.filter( - staff=physician, - year=prev_year, - month=prev_month - ).first() - - # Get year-to-date stats - ytd_ratings = PhysicianMonthlyRating.objects.filter( - staff=physician, - year=current_year - ) - - ytd_stats = ytd_ratings.aggregate( - avg_rating=Avg('average_rating'), - total_surveys=Count('id') - ) - - # Get last 12 months ratings - ratings_history = PhysicianMonthlyRating.objects.filter( - staff=physician - ).order_by('-year', '-month')[:12] - - # Get best and worst months from all ratings (not just last 12 months) - all_ratings = PhysicianMonthlyRating.objects.filter(staff=physician) - best_month = all_ratings.order_by('-average_rating').first() - worst_month = all_ratings.order_by('average_rating').first() - - # Determine trend - trend = 'stable' - trend_percentage = 0 - if current_month_rating and previous_month_rating: - diff = float(current_month_rating.average_rating - previous_month_rating.average_rating) - if previous_month_rating.average_rating > 0: - trend_percentage = (diff / float(previous_month_rating.average_rating)) * 100 - - if diff > 0.1: - trend = 'improving' - elif diff < -0.1: - trend = 'declining' - - context = { - 'physician': physician, - 'current_month_rating': current_month_rating, - 'previous_month_rating': previous_month_rating, - 'ytd_average': ytd_stats['avg_rating'], - 'ytd_surveys': ytd_stats['total_surveys'], - 'ratings_history': ratings_history, - 'best_month': best_month, - 'worst_month': worst_month, - 'trend': trend, - 'trend_percentage': abs(trend_percentage), - 'current_year': current_year, - 'current_month': current_month, - } - - return render(request, 'physicians/physician_detail.html', context) - - -@login_required -def leaderboard(request): - """ - Physician leaderboard view. - - Features: - - Top-rated physicians for selected period - - Filters (hospital, department, month/year) - - Ranking with trends - - Performance distribution - """ - # Get parameters - now = timezone.now() - year = int(request.GET.get('year', now.year)) - month = int(request.GET.get('month', now.month)) - hospital_filter = request.GET.get('hospital') - department_filter = request.GET.get('department') - limit = int(request.GET.get('limit', 20)) - - # Build queryset - only include staff marked as physicians - queryset = PhysicianMonthlyRating.objects.filter( - year=year, - month=month, - staff__physician=True - ).select_related('staff', 'staff__hospital', 'staff__department') - - # Apply RBAC filters - user = request.user - if not user.is_px_admin() and user.hospital: - queryset = queryset.filter(staff__hospital=user.hospital) - - # Apply filters - if hospital_filter: - queryset = queryset.filter(staff__hospital_id=hospital_filter) - - if department_filter: - queryset = queryset.filter(staff__department_id=department_filter) - - # Order by rating - queryset = queryset.order_by('-average_rating')[:limit] - - # Get previous month for trend - prev_month = month - 1 if month > 1 else 12 - prev_year = year if month > 1 else year - 1 - - # Build leaderboard with trends - leaderboard = [] - for rank, rating in enumerate(queryset, start=1): - # Get previous month rating for trend - prev_rating = PhysicianMonthlyRating.objects.filter( - staff=rating.staff, - year=prev_year, - month=prev_month - ).first() - - trend = 'stable' - trend_value = 0 - if prev_rating: - diff = float(rating.average_rating - prev_rating.average_rating) - trend_value = diff - if diff > 0.1: - trend = 'up' - elif diff < -0.1: - trend = 'down' - - leaderboard.append({ - 'rank': rank, - 'rating': rating, - 'physician': rating.staff, - 'trend': trend, - 'trend_value': trend_value, - 'prev_rating': prev_rating - }) - - # 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) - - departments = Department.objects.filter(status='active') + departments = Department.objects.filter(status="active") if not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) @@ -312,9 +104,7 @@ def leaderboard(request): all_ratings = all_ratings.filter(staff__hospital=user.hospital) stats = all_ratings.aggregate( - total_physicians=Count('id'), - average_rating=Avg('average_rating'), - total_surveys=Count('total_surveys') + total_physicians=Count("id"), average_rating=Avg("average_rating"), total_surveys=Count("total_surveys") ) # Distribution @@ -324,22 +114,17 @@ def leaderboard(request): poor = all_ratings.filter(average_rating__lt=2.5).count() context = { - 'leaderboard': leaderboard, - 'year': year, - 'month': month, - 'hospitals': hospitals, - 'departments': departments, - 'filters': request.GET, - 'stats': stats, - 'distribution': { - 'excellent': excellent, - 'good': good, - 'average': average, - 'poor': poor - } + "leaderboard": leaderboard, + "year": year, + "month": month, + "hospitals": hospitals, + "departments": departments, + "filters": request.GET, + "stats": stats, + "distribution": {"excellent": excellent, "good": good, "average": average, "poor": poor}, } - return render(request, 'physicians/leaderboard.html', context) + return render(request, "physicians/leaderboard.html", context) @login_required @@ -356,18 +141,11 @@ def physician_ratings_dashboard(request): - Top physicians table """ now = timezone.now() - year = int(request.GET.get('year', now.year)) - month = int(request.GET.get('month', now.month)) - hospital_filter = request.GET.get('hospital') - department_filter = request.GET.get('department') + year = int(request.GET.get("year", now.year)) + month = int(request.GET.get("month", now.month)) + hospital_filter = request.GET.get("hospital") + department_filter = request.GET.get("department") - # Get filter options - user = request.user - hospitals = Hospital.objects.filter(status='active') - departments = Department.objects.filter(status='active') - - if not user.is_px_admin() and user.hospital: - hospitals = hospitals.filter(id=user.hospital.id) departments = departments.filter(hospital=user.hospital) # Get available years (2024 to current year) @@ -376,13 +154,13 @@ def physician_ratings_dashboard(request): years.reverse() # Most recent first context = { - 'years': years, - 'hospitals': hospitals, - 'departments': departments, - 'filters': request.GET, + "years": years, + "hospitals": hospitals, + "departments": departments, + "filters": request.GET, } - return render(request, 'physicians/physician_ratings_dashboard.html', context) + return render(request, "physicians/physician_ratings_dashboard.html", context) @login_required @@ -394,15 +172,15 @@ def physician_ratings_dashboard_api(request): """ try: now = timezone.now() - year = int(request.GET.get('year', now.year)) - month = int(request.GET.get('month', now.month)) - hospital_filter = request.GET.get('hospital') - department_filter = request.GET.get('department') + year = int(request.GET.get("year", now.year)) + month = int(request.GET.get("month", now.month)) + hospital_filter = request.GET.get("hospital") + department_filter = request.GET.get("department") # Base queryset - only include staff marked as physicians - queryset = PhysicianMonthlyRating.objects.filter( - staff__physician=True - ).select_related('staff', 'staff__hospital', 'staff__department') + queryset = PhysicianMonthlyRating.objects.filter(staff__physician=True).select_related( + "staff", "staff__hospital", "staff__department" + ) # Apply RBAC filters user = request.user @@ -420,9 +198,9 @@ def physician_ratings_dashboard_api(request): # 1. Statistics stats = current_period.aggregate( - total_physicians=Count('id', distinct=True), - average_rating=Avg('average_rating'), - total_surveys=Sum('total_surveys') + total_physicians=Count("id", distinct=True), + average_rating=Avg("average_rating"), + total_surveys=Sum("total_surveys"), ) excellent_count = current_period.filter(average_rating__gte=4.5).count() @@ -437,15 +215,16 @@ def physician_ratings_dashboard_api(request): y -= 1 period_data = queryset.filter(year=y, month=m).aggregate( - avg=Avg('average_rating'), - surveys=Sum('total_surveys') + avg=Avg("average_rating"), surveys=Sum("total_surveys") ) - trend_data.append({ - 'period': f'{y}-{m:02d}', - 'average_rating': float(period_data['avg'] or 0), - 'total_surveys': period_data['surveys'] or 0 - }) + trend_data.append( + { + "period": f"{y}-{m:02d}", + "average_rating": float(period_data["avg"] or 0), + "total_surveys": period_data["surveys"] or 0, + } + ) # 3. Rating Distribution excellent = current_period.filter(average_rating__gte=4.5).count() @@ -453,85 +232,83 @@ def physician_ratings_dashboard_api(request): average = current_period.filter(average_rating__gte=2.5, average_rating__lt=3.5).count() poor = current_period.filter(average_rating__lt=2.5).count() - distribution = { - 'excellent': excellent, - 'good': good, - 'average': average, - 'poor': poor - } + distribution = {"excellent": excellent, "good": good, "average": average, "poor": poor} # 4. Department Comparison (top 10) - dept_data = current_period.values('staff__department__name').annotate( - average_rating=Avg('average_rating'), - total_surveys=Sum('total_surveys'), - physician_count=Count('id', distinct=True) - ).filter(staff__department__isnull=False).order_by('-average_rating')[:10] + dept_data = ( + current_period.values("staff__department__name") + .annotate( + average_rating=Avg("average_rating"), + total_surveys=Sum("total_surveys"), + physician_count=Count("id", distinct=True), + ) + .filter(staff__department__isnull=False) + .order_by("-average_rating")[:10] + ) departments = [ { - 'name': item['staff__department__name'] or 'Unknown', - 'average_rating': float(item['average_rating'] or 0), - 'total_surveys': item['total_surveys'] or 0 + "name": item["staff__department__name"] or "Unknown", + "average_rating": float(item["average_rating"] or 0), + "total_surveys": item["total_surveys"] or 0, } for item in dept_data ] # 5. Sentiment Analysis sentiment = current_period.aggregate( - positive=Sum('positive_count'), - neutral=Sum('neutral_count'), - negative=Sum('negative_count') + positive=Sum("positive_count"), neutral=Sum("neutral_count"), negative=Sum("negative_count") ) - total_sentiment = (sentiment['positive'] or 0) + (sentiment['neutral'] or 0) + (sentiment['negative'] or 0) + total_sentiment = (sentiment["positive"] or 0) + (sentiment["neutral"] or 0) + (sentiment["negative"] or 0) if total_sentiment > 0: sentiment_pct = { - 'positive': ((sentiment['positive'] or 0) / total_sentiment) * 100, - 'neutral': ((sentiment['neutral'] or 0) / total_sentiment) * 100, - 'negative': ((sentiment['negative'] or 0) / total_sentiment) * 100 + "positive": ((sentiment["positive"] or 0) / total_sentiment) * 100, + "neutral": ((sentiment["neutral"] or 0) / total_sentiment) * 100, + "negative": ((sentiment["negative"] or 0) / total_sentiment) * 100, } else: - sentiment_pct = {'positive': 0, 'neutral': 0, 'negative': 0} + sentiment_pct = {"positive": 0, "neutral": 0, "negative": 0} # 6. Top 10 Physicians - top_physicians = current_period.select_related( - 'staff', 'staff__hospital', 'staff__department' - ).order_by('-average_rating', '-total_surveys')[:10] + top_physicians = current_period.select_related("staff", "staff__hospital", "staff__department").order_by( + "-average_rating", "-total_surveys" + )[:10] physicians_list = [ { - 'id': rating.staff.id, - 'name': rating.staff.get_full_name(), - 'license_number': rating.staff.license_number, - 'specialization': rating.staff.specialization or '-', - 'department': rating.staff.department.name if rating.staff.department else '-', - 'hospital': rating.staff.hospital.name if rating.staff.hospital else '-', - 'rating': float(rating.average_rating), - 'surveys': rating.total_surveys + "id": rating.staff.id, + "name": rating.staff.get_full_name(), + "license_number": rating.staff.license_number, + "specialization": rating.staff.specialization or "-", + "department": rating.staff.department.name if rating.staff.department else "-", + "hospital": rating.staff.hospital.name if rating.staff.hospital else "-", + "rating": float(rating.average_rating), + "surveys": rating.total_surveys, } for rating in top_physicians ] - return JsonResponse({ - 'statistics': { - 'total_physicians': stats['total_physicians'] or 0, - 'average_rating': float(stats['average_rating'] or 0), - 'total_surveys': stats['total_surveys'] or 0, - 'excellent_count': excellent_count - }, - 'trend': trend_data, - 'distribution': distribution, - 'departments': departments, - 'sentiment': sentiment_pct, - 'top_physicians': physicians_list - }) + return JsonResponse( + { + "statistics": { + "total_physicians": stats["total_physicians"] or 0, + "average_rating": float(stats["average_rating"] or 0), + "total_surveys": stats["total_surveys"] or 0, + "excellent_count": excellent_count, + }, + "trend": trend_data, + "distribution": distribution, + "departments": departments, + "sentiment": sentiment_pct, + "top_physicians": physicians_list, + } + ) except Exception as e: import traceback - return JsonResponse({ - 'error': str(e), - 'traceback': traceback.format_exc() - }, status=500) + + return JsonResponse({"error": str(e), "traceback": traceback.format_exc()}, status=500) @login_required @@ -546,9 +323,9 @@ def ratings_list(request): - Pagination """ # Base queryset - only include staff marked as physicians - queryset = PhysicianMonthlyRating.objects.filter( - staff__physician=True - ).select_related('staff', 'staff__hospital', 'staff__department') + queryset = PhysicianMonthlyRating.objects.filter(staff__physician=True).select_related( + "staff", "staff__hospital", "staff__department" + ) # Apply RBAC filters user = request.user @@ -556,67 +333,63 @@ def ratings_list(request): queryset = queryset.filter(staff__hospital=user.hospital) # Apply filters - physician_filter = request.GET.get('physician') + physician_filter = request.GET.get("physician") if physician_filter: queryset = queryset.filter(staff_id=physician_filter) - hospital_filter = request.GET.get('hospital') + hospital_filter = request.GET.get("hospital") if hospital_filter: queryset = queryset.filter(staff__hospital_id=hospital_filter) - department_filter = request.GET.get('department') + department_filter = request.GET.get("department") if department_filter: queryset = queryset.filter(staff__department_id=department_filter) - year_filter = request.GET.get('year') + year_filter = request.GET.get("year") if year_filter: queryset = queryset.filter(year=int(year_filter)) - month_filter = request.GET.get('month') + month_filter = request.GET.get("month") if month_filter: queryset = queryset.filter(month=int(month_filter)) # Search - search_query = request.GET.get('search') + search_query = request.GET.get("search") if search_query: queryset = queryset.filter( - Q(staff__first_name__icontains=search_query) | - Q(staff__last_name__icontains=search_query) | - Q(staff__license_number__icontains=search_query) + Q(staff__first_name__icontains=search_query) + | Q(staff__last_name__icontains=search_query) + | Q(staff__license_number__icontains=search_query) ) # Ordering - order_by = request.GET.get('order_by', '-year,-month,-average_rating') - queryset = queryset.order_by(*order_by.split(',')) + order_by = request.GET.get("order_by", "-year,-month,-average_rating") + queryset = queryset.order_by(*order_by.split(",")) # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + 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) - departments = Department.objects.filter(status='active') + departments = Department.objects.filter(status="active") if not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) # Get available years - years = PhysicianMonthlyRating.objects.values_list('year', flat=True).distinct().order_by('-year') + years = PhysicianMonthlyRating.objects.values_list("year", flat=True).distinct().order_by("-year") context = { - 'page_obj': page_obj, - 'ratings': page_obj.object_list, - 'hospitals': hospitals, - 'departments': departments, - 'years': years, - 'filters': request.GET, + "page_obj": page_obj, + "ratings": page_obj.object_list, + "hospitals": hospitals, + "departments": departments, + "years": years, + "filters": request.GET, } - return render(request, 'physicians/ratings_list.html', context) + return render(request, "physicians/ratings_list.html", context) @login_required @@ -632,16 +405,14 @@ def specialization_overview(request): """ # Get parameters now = timezone.now() - year = int(request.GET.get('year', now.year)) - month = int(request.GET.get('month', now.month)) - hospital_filter = request.GET.get('hospital') + year = int(request.GET.get("year", now.year)) + month = int(request.GET.get("month", now.month)) + hospital_filter = request.GET.get("hospital") # Base queryset - only include staff marked as physicians - queryset = PhysicianMonthlyRating.objects.filter( - year=year, - month=month, - staff__physician=True - ).select_related('staff', 'staff__hospital', 'staff__department') + queryset = PhysicianMonthlyRating.objects.filter(year=year, month=month, staff__physician=True).select_related( + "staff", "staff__hospital", "staff__department" + ) # Apply RBAC filters user = request.user @@ -655,60 +426,57 @@ def specialization_overview(request): # Aggregate by specialization specialization_data = {} for rating in queryset: - spec = rating.staff.specialization if spec not in specialization_data: specialization_data[spec] = { - 'specialization': spec, - 'physicians': [], - 'total_physicians': 0, - 'total_surveys': 0, - 'total_positive': 0, - 'total_neutral': 0, - 'total_negative': 0, - 'ratings_sum': 0, + "specialization": spec, + "physicians": [], + "total_physicians": 0, + "total_surveys": 0, + "total_positive": 0, + "total_neutral": 0, + "total_negative": 0, + "ratings_sum": 0, } - specialization_data[spec]['physicians'].append(rating) - specialization_data[spec]['total_physicians'] += 1 - specialization_data[spec]['total_surveys'] += rating.total_surveys - specialization_data[spec]['total_positive'] += rating.positive_count - specialization_data[spec]['total_neutral'] += rating.neutral_count - specialization_data[spec]['total_negative'] += rating.negative_count - specialization_data[spec]['ratings_sum'] += float(rating.average_rating) + specialization_data[spec]["physicians"].append(rating) + specialization_data[spec]["total_physicians"] += 1 + specialization_data[spec]["total_surveys"] += rating.total_surveys + specialization_data[spec]["total_positive"] += rating.positive_count + specialization_data[spec]["total_neutral"] += rating.neutral_count + specialization_data[spec]["total_negative"] += rating.negative_count + specialization_data[spec]["ratings_sum"] += float(rating.average_rating) # Calculate averages specializations = [] for spec, data in specialization_data.items(): - avg_rating = data['ratings_sum'] / data['total_physicians'] if data['total_physicians'] > 0 else 0 - specializations.append({ - 'specialization': spec, - 'total_physicians': data['total_physicians'], - 'average_rating': round(avg_rating, 2), - 'total_surveys': data['total_surveys'], - 'positive_count': data['total_positive'], - 'neutral_count': data['total_neutral'], - 'negative_count': data['total_negative'], - 'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True) - }) + avg_rating = data["ratings_sum"] / data["total_physicians"] if data["total_physicians"] > 0 else 0 + specializations.append( + { + "specialization": spec, + "total_physicians": data["total_physicians"], + "average_rating": round(avg_rating, 2), + "total_surveys": data["total_surveys"], + "positive_count": data["total_positive"], + "neutral_count": data["total_neutral"], + "negative_count": data["total_negative"], + "physicians": sorted(data["physicians"], key=lambda x: x.average_rating, reverse=True), + } + ) # Sort by average rating - specializations.sort(key=lambda x: x['average_rating'], reverse=True) + specializations.sort(key=lambda x: x["average_rating"], reverse=True) - # 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 = { - 'specializations': specializations, - 'year': year, - 'month': month, - 'hospitals': hospitals, - 'filters': request.GET, + "specializations": specializations, + "year": year, + "month": month, + "hospitals": hospitals, + "filters": request.GET, } - return render(request, 'physicians/specialization_overview.html', context) + return render(request, "physicians/specialization_overview.html", context) @login_required @@ -724,16 +492,14 @@ def department_overview(request): """ # Get parameters now = timezone.now() - year = int(request.GET.get('year', now.year)) - month = int(request.GET.get('month', now.month)) - hospital_filter = request.GET.get('hospital') + year = int(request.GET.get("year", now.year)) + month = int(request.GET.get("month", now.month)) + hospital_filter = request.GET.get("hospital") # Base queryset - only include staff marked as physicians - queryset = PhysicianMonthlyRating.objects.filter( - year=year, - month=month, - staff__physician=True - ).select_related('staff', 'staff__hospital', 'staff__department') + queryset = PhysicianMonthlyRating.objects.filter(year=year, month=month, staff__physician=True).select_related( + "staff", "staff__hospital", "staff__department" + ) # Apply RBAC filters user = request.user @@ -754,53 +520,51 @@ def department_overview(request): dept_key = str(dept.id) if dept_key not in department_data: department_data[dept_key] = { - 'department': dept, - 'physicians': [], - 'total_physicians': 0, - 'total_surveys': 0, - 'total_positive': 0, - 'total_neutral': 0, - 'total_negative': 0, - 'ratings_sum': 0, + "department": dept, + "physicians": [], + "total_physicians": 0, + "total_surveys": 0, + "total_positive": 0, + "total_neutral": 0, + "total_negative": 0, + "ratings_sum": 0, } - department_data[dept_key]['physicians'].append(rating) - department_data[dept_key]['total_physicians'] += 1 - department_data[dept_key]['total_surveys'] += rating.total_surveys - department_data[dept_key]['total_positive'] += rating.positive_count - department_data[dept_key]['total_neutral'] += rating.neutral_count - department_data[dept_key]['total_negative'] += rating.negative_count - department_data[dept_key]['ratings_sum'] += float(rating.average_rating) + department_data[dept_key]["physicians"].append(rating) + department_data[dept_key]["total_physicians"] += 1 + department_data[dept_key]["total_surveys"] += rating.total_surveys + department_data[dept_key]["total_positive"] += rating.positive_count + department_data[dept_key]["total_neutral"] += rating.neutral_count + department_data[dept_key]["total_negative"] += rating.negative_count + department_data[dept_key]["ratings_sum"] += float(rating.average_rating) # Calculate averages departments = [] for dept_key, data in department_data.items(): - avg_rating = data['ratings_sum'] / data['total_physicians'] if data['total_physicians'] > 0 else 0 - departments.append({ - 'department': data['department'], - 'total_physicians': data['total_physicians'], - 'average_rating': round(avg_rating, 2), - 'total_surveys': data['total_surveys'], - 'positive_count': data['total_positive'], - 'neutral_count': data['total_neutral'], - 'negative_count': data['total_negative'], - 'physicians': sorted(data['physicians'], key=lambda x: x.average_rating, reverse=True) - }) + avg_rating = data["ratings_sum"] / data["total_physicians"] if data["total_physicians"] > 0 else 0 + departments.append( + { + "department": data["department"], + "total_physicians": data["total_physicians"], + "average_rating": round(avg_rating, 2), + "total_surveys": data["total_surveys"], + "positive_count": data["total_positive"], + "neutral_count": data["total_neutral"], + "negative_count": data["total_negative"], + "physicians": sorted(data["physicians"], key=lambda x: x.average_rating, reverse=True), + } + ) # Sort by average rating - departments.sort(key=lambda x: x['average_rating'], reverse=True) + departments.sort(key=lambda x: x["average_rating"], reverse=True) - # 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 = { - 'departments': departments, - 'year': year, - 'month': month, - 'hospitals': hospitals, - 'filters': request.GET, + "departments": departments, + "year": year, + "month": month, + "hospitals": hospitals, + "filters": request.GET, } - return render(request, 'physicians/department_overview.html', context) + return render(request, "physicians/department_overview.html", context) diff --git a/apps/projects/ui_views.py b/apps/projects/ui_views.py index e086ce9..5bb6c85 100644 --- a/apps/projects/ui_views.py +++ b/apps/projects/ui_views.py @@ -83,7 +83,6 @@ def project_list(request): context = { 'page_obj': page_obj, 'projects': page_obj.object_list, - 'hospitals': hospitals, 'stats': stats, 'filters': request.GET, } diff --git a/apps/surveys/ui_views.py b/apps/surveys/ui_views.py index 62dba44..139681b 100644 --- a/apps/surveys/ui_views.py +++ b/apps/surveys/ui_views.py @@ -1,6 +1,7 @@ """ 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 @@ -16,18 +17,25 @@ from apps.core.services import AuditService from apps.core.decorators import block_source_user from apps.organizations.models import Department, Hospital -from .forms import ManualSurveySendForm, SurveyQuestionFormSet, SurveyTemplateForm, ManualPhoneSurveySendForm, BulkCSVSurveySendForm +from .forms import ( + ManualSurveySendForm, + SurveyQuestionFormSet, + SurveyTemplateForm, + ManualPhoneSurveySendForm, + BulkCSVSurveySendForm, +) from .services import SurveyDeliveryService from .models import SurveyInstance, SurveyTemplate, SurveyQuestion from .tasks import send_satisfaction_feedback from datetime import datetime + @block_source_user @login_required def survey_instance_list(request): """ Survey instances list view with filters. - + Features: - Server-side pagination - Filters (status, journey type, hospital, date range) @@ -37,17 +45,14 @@ def survey_instance_list(request): # Source Users don't have access to surveys if request.user.is_source_user(): from django.core.exceptions import PermissionDenied + raise PermissionDenied("Source users do not have access to surveys.") - + # Base queryset with optimizations queryset = SurveyInstance.objects.select_related( - 'survey_template', - 'patient', - 'journey_instance__journey_template' - ).prefetch_related( - 'responses__question' - ) - + "survey_template", "patient", "journey_instance__journey_template" + ).prefetch_related("responses__question") + # Apply RBAC filters user = request.user if user.is_px_admin(): @@ -58,65 +63,59 @@ def survey_instance_list(request): queryset = queryset.filter(survey_template__hospital=user.hospital) else: queryset = queryset.none() - + # Apply filters - status_filter = request.GET.get('status') + status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) - - survey_type = request.GET.get('survey_type') + + 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': + + is_negative = request.GET.get("is_negative") + if is_negative == "true": queryset = queryset.filter(is_negative=True) - - hospital_filter = request.GET.get('hospital') + + 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') + 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) + 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') + 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') + + 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') + 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)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + 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 = { - 'surveys': page_obj, - 'hospitals': hospitals, - 'filters': request.GET, + "surveys": page_obj, + "filters": request.GET, } - - return render(request, 'surveys/instance_list.html', context) + + return render(request, "surveys/instance_list.html", context) @block_source_user @@ -124,7 +123,7 @@ def survey_instance_list(request): def survey_instance_detail(request, pk): """ Survey instance detail view with responses. - + Features: - Full survey details - All responses @@ -135,99 +134,88 @@ def survey_instance_detail(request, pk): """ survey = get_object_or_404( SurveyInstance.objects.select_related( - 'survey_template', - 'patient', - 'journey_instance__journey_template' - ).prefetch_related( - 'responses__question' - ), - pk=pk + "survey_template", "patient", "journey_instance__journey_template" + ).prefetch_related("responses__question"), + pk=pk, ) - + # Get responses - responses = survey.responses.all().order_by('question__order') - + 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 - + 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] - + 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') - + 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': [ + "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) + "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'] - ] + if opt["responses__choice_value"] + ], } - elif response.question.question_type == 'rating': + 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') - ) + 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 + "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, + "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) + + return render(request, "surveys/instance_detail.html", context) @block_source_user @login_required def survey_template_list(request): """Survey templates list view""" - queryset = SurveyTemplate.objects.select_related('hospital').prefetch_related('questions') - + queryset = SurveyTemplate.objects.select_related("hospital").prefetch_related("questions") + # Apply RBAC filters user = request.user if user.is_px_admin(): # PX Admins see templates for their selected hospital (from session) - tenant_hospital = getattr(request, 'tenant_hospital', None) + tenant_hospital = getattr(request, "tenant_hospital", None) if tenant_hospital: queryset = queryset.filter(hospital=tenant_hospital) else: @@ -237,44 +225,38 @@ def survey_template_list(request): queryset = queryset.filter(hospital=user.hospital) else: queryset = queryset.none() - + # Apply filters - survey_type = request.GET.get('survey_type') + survey_type = request.GET.get("survey_type") if survey_type: queryset = queryset.filter(survey_type=survey_type) - - hospital_filter = request.GET.get('hospital') + + 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': + + is_active = request.GET.get("is_active") + if is_active == "true": queryset = queryset.filter(is_active=True) - elif is_active == 'false': + elif is_active == "false": queryset = queryset.filter(is_active=False) - + # Ordering - queryset = queryset.order_by('hospital', 'survey_type', 'name') - + queryset = queryset.order_by("hospital", "survey_type", "name") + # Pagination - page_size = int(request.GET.get('page_size', 25)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + 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, + "page_obj": page_obj, + "templates": page_obj.object_list, + "filters": request.GET, } - - return render(request, 'surveys/template_list.html', context) + + return render(request, "surveys/template_list.html", context) @block_source_user @@ -285,73 +267,68 @@ def survey_template_create(request): 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': + return redirect("surveys:template_list") + + if request.method == "POST": form = SurveyTemplateForm(request.POST, user=user) 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) + return redirect("surveys:template_detail", pk=template.pk) else: form = SurveyTemplateForm(user=user) formset = SurveyQuestionFormSet() - + context = { - 'form': form, - 'formset': formset, + "form": form, + "formset": formset, } - - return render(request, 'surveys/template_form.html', context) + + return render(request, "surveys/template_form.html", context) @block_source_user @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 - ) - + 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') - + return redirect("surveys:template_list") + # Get statistics total_instances = template.instances.count() - completed_instances = template.instances.filter(status='completed').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 - + 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), - } + "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) + + return render(request, "surveys/template_detail.html", context) @block_source_user @@ -359,35 +336,35 @@ def survey_template_detail(request, pk): 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': + return redirect("surveys:template_list") + + if request.method == "POST": form = SurveyTemplateForm(request.POST, instance=template, user=user) 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) + return redirect("surveys:template_detail", pk=template.pk) else: form = SurveyTemplateForm(instance=template, user=user) formset = SurveyQuestionFormSet(instance=template) - + context = { - 'form': form, - 'formset': formset, - 'template': template, + "form": form, + "formset": formset, + "template": template, } - - return render(request, 'surveys/template_form.html', context) + + return render(request, "surveys/template_form.html", context) @block_source_user @@ -395,25 +372,25 @@ def survey_template_edit(request, pk): 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': + 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') - + return redirect("surveys:template_list") + context = { - 'template': template, + "template": template, } - - return render(request, 'surveys/template_confirm_delete.html', context) + + return render(request, "surveys/template_confirm_delete.html", context) @block_source_user @@ -422,32 +399,32 @@ def survey_template_delete(request, pk): 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) - + 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) - + 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' - + 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) - + return redirect("surveys:instance_detail", pk=pk) + try: # Update survey survey.patient_contacted = True @@ -455,31 +432,36 @@ def survey_log_patient_contact(request, pk): 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' - ]) - + 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', + 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 - } + "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) + + return redirect("surveys:instance_detail", pk=pk) @block_source_user @@ -487,7 +469,7 @@ def survey_log_patient_contact(request, pk): 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) @@ -496,17 +478,12 @@ def survey_comments_list(request): - 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='' + 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(): @@ -517,79 +494,65 @@ def survey_comments_list(request): queryset = queryset.filter(survey_template__hospital=user.hospital) else: queryset = queryset.none() - + # Apply filters - sentiment_filter = request.GET.get('sentiment') + sentiment_filter = request.GET.get("sentiment") if sentiment_filter: - queryset = queryset.filter( - comment_analysis__sentiment=sentiment_filter - ) - - survey_type = request.GET.get('survey_type') + 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') + + 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') + 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'], + "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 - ) - + queryset = queryset.filter(metadata__patient_type__in=codes) + # Search - search_query = request.GET.get('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) + 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') + 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') + + 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') + 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)) + page_size = int(request.GET.get("page_size", 25)) paginator = Paginator(queryset, page_size) - page_number = request.GET.get('page', 1) + 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='' - ) - + stats_queryset = SurveyInstance.objects.filter(status="completed", comment__isnull=False).exclude(comment="") + # Apply same RBAC filters to stats if user.is_px_admin(): pass @@ -599,146 +562,156 @@ def survey_comments_list(request): 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() - + 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', + "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() + 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)}, + { + "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': [], + "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', [])]: + 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) - + 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'}, + "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') - + 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'] + "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'}, + "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'} - + 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, + "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, + "page_obj": page_obj, + "surveys": page_obj.object_list, + "stats": stats, + "filters": request.GET, + "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) + + return render(request, "surveys/comment_list.html", context) @block_source_user @@ -747,48 +720,48 @@ def survey_comments_list(request): 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) - + 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) - + 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) - + 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) - + 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, + request, "Satisfaction feedback form is being sent to the patient. " - "They will receive a link to provide their feedback." + "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) + + return redirect("surveys:instance_detail", pk=pk) @block_source_user @@ -796,7 +769,7 @@ def survey_send_satisfaction_feedback(request, pk): def manual_survey_send(request): """ Manually send a survey to a patient or staff member. - + Features: - Select survey template - Choose recipient type (patient/staff) @@ -806,110 +779,111 @@ def manual_survey_send(request): - 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': + 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', '') - + 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': + + 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': + 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': + 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}) - + 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, + 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, + recipient_email=contact_info if delivery_channel == "email" else None, + recipient_phone=contact_info if delivery_channel == "sms" else None, status=SurveyStatus.SENT, metadata={ - 'sent_manually': True, - 'sent_by': str(user.id), - 'custom_message': custom_message, - 'recipient_type': recipient_type - } + "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', + 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 - } + "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}" + f"Survey ID: {survey_instance.id}", ) - return redirect('surveys:instance_detail', pk=survey_instance.pk) + 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}) - + 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}) + return render(request, "surveys/manual_send.html", {"form": form}) else: form = ManualSurveySendForm(user) - + context = { - 'form': form, + "form": form, } - - return render(request, 'surveys/manual_send.html', context) + + return render(request, "surveys/manual_send.html", context) @block_source_user @@ -917,90 +891,90 @@ def manual_survey_send(request): def manual_survey_send_phone(request): """ Send survey to a manually entered phone number. - + Features: - Enter phone number directly - Optional recipient name - Send via SMS only """ 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': + return redirect("surveys:instance_list") + + if request.method == "POST": form = ManualPhoneSurveySendForm(user, request.POST) - + if form.is_valid(): try: - survey_template = form.cleaned_data['survey_template'] - phone_number = form.cleaned_data['phone_number'] - recipient_name = form.cleaned_data.get('recipient_name', '') - custom_message = form.cleaned_data.get('custom_message', '') - + survey_template = form.cleaned_data["survey_template"] + phone_number = form.cleaned_data["phone_number"] + recipient_name = form.cleaned_data.get("recipient_name", "") + custom_message = form.cleaned_data.get("custom_message", "") + # Create survey instance from .models import SurveyStatus - + survey_instance = SurveyInstance.objects.create( survey_template=survey_template, hospital=survey_template.hospital, - delivery_channel='sms', + delivery_channel="sms", recipient_phone=phone_number, status=SurveyStatus.SENT, metadata={ - 'sent_manually': True, - 'sent_by': str(user.id), - 'custom_message': custom_message, - 'recipient_name': recipient_name, - 'recipient_type': 'manual_phone' - } + "sent_manually": True, + "sent_by": str(user.id), + "custom_message": custom_message, + "recipient_name": recipient_name, + "recipient_type": "manual_phone", + }, ) - + # Send survey success = SurveyDeliveryService.deliver_survey(survey_instance) - + if success: # Log audit AuditService.log_event( - event_type='survey_sent_manually_phone', + event_type="survey_sent_manually_phone", description=f"Survey sent manually to phone {phone_number} by {user.get_full_name()}", user=user, content_object=survey_instance, metadata={ - 'survey_template': survey_template.name, - 'phone_number': phone_number, - 'recipient_name': recipient_name, - 'custom_message': custom_message - } + "survey_template": survey_template.name, + "phone_number": phone_number, + "recipient_name": recipient_name, + "custom_message": custom_message, + }, ) - + display_name = recipient_name if recipient_name else phone_number messages.success( - request, - f"Survey sent successfully to {display_name} via SMS. Survey ID: {survey_instance.id}" + request, f"Survey sent successfully to {display_name} via SMS. Survey ID: {survey_instance.id}" ) - return redirect('surveys:instance_detail', pk=survey_instance.pk) + 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_phone.html', {'form': form}) - + return render(request, "surveys/manual_send_phone.html", {"form": form}) + except Exception as e: import logging + logger = logging.getLogger(__name__) logger.error(f"Error sending survey to phone: {str(e)}", exc_info=True) messages.error(request, f"Error sending survey: {str(e)}") - return render(request, 'surveys/manual_send_phone.html', {'form': form}) + return render(request, "surveys/manual_send_phone.html", {"form": form}) else: form = ManualPhoneSurveySendForm(user) - + context = { - 'form': form, + "form": form, } - - return render(request, 'surveys/manual_send_phone.html', context) + + return render(request, "surveys/manual_send_phone.html", context) @block_source_user @@ -1008,12 +982,12 @@ def manual_survey_send_phone(request): def manual_survey_send_csv(request): """ Bulk send surveys via CSV upload. - + CSV Format: phone_number,name(optional) +966501234567,John Doe +966501234568,Jane Smith - + Features: - Upload CSV with phone numbers - Optional names for each recipient @@ -1022,104 +996,105 @@ def manual_survey_send_csv(request): import csv import io from django.utils.translation import gettext as _ - + 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': + return redirect("surveys:instance_list") + + if request.method == "POST": form = BulkCSVSurveySendForm(user, request.POST, request.FILES) - + if form.is_valid(): try: - survey_template = form.cleaned_data['survey_template'] - csv_file = form.cleaned_data['csv_file'] - custom_message = form.cleaned_data.get('custom_message', '') - + survey_template = form.cleaned_data["survey_template"] + csv_file = form.cleaned_data["csv_file"] + custom_message = form.cleaned_data.get("custom_message", "") + # Parse CSV - decoded_file = csv_file.read().decode('utf-8-sig') # Handle BOM + decoded_file = csv_file.read().decode("utf-8-sig") # Handle BOM io_string = io.StringIO(decoded_file) reader = csv.reader(io_string) - + # Skip header if present first_row = next(reader, None) if not first_row: messages.error(request, "CSV file is empty.") - return render(request, 'surveys/manual_send_csv.html', {'form': form}) - + return render(request, "surveys/manual_send_csv.html", {"form": form}) + # Check if first row is header or data rows = [] - if first_row[0].strip().lower() in ['phone', 'phone_number', 'mobile', 'number', 'tel']: + if first_row[0].strip().lower() in ["phone", "phone_number", "mobile", "number", "tel"]: # First row is header, use remaining rows pass else: # First row is data rows.append(first_row) - + # Read remaining rows for row in reader: if row and row[0].strip(): # Skip empty rows rows.append(row) - + if not rows: messages.error(request, "No valid phone numbers found in CSV.") - return render(request, 'surveys/manual_send_csv.html', {'form': form}) - + return render(request, "surveys/manual_send_csv.html", {"form": form}) + # Process each row from .models import SurveyStatus - + success_count = 0 failed_count = 0 failed_numbers = [] created_instances = [] - + import logging + logger = logging.getLogger(__name__) - + for row in rows: try: - phone_number = row[0].strip() if len(row) > 0 else '' - recipient_name = row[1].strip() if len(row) > 1 else '' - + phone_number = row[0].strip() if len(row) > 0 else "" + recipient_name = row[1].strip() if len(row) > 1 else "" + # Clean phone number - phone_number = phone_number.replace(' ', '').replace('-', '').replace('(', '').replace(')', '') - + phone_number = phone_number.replace(" ", "").replace("-", "").replace("(", "").replace(")", "") + logger.info(f"Processing row: phone={phone_number}, name={recipient_name}") - + # Skip empty or invalid - if not phone_number or not phone_number.startswith('+'): + if not phone_number or not phone_number.startswith("+"): failed_count += 1 failed_numbers.append(f"{phone_number} (invalid format - must start with +)") logger.warning(f"Invalid phone format: {phone_number}") continue - + # Create survey instance survey_instance = SurveyInstance.objects.create( survey_template=survey_template, hospital=survey_template.hospital, - delivery_channel='sms', + delivery_channel="sms", recipient_phone=phone_number, status=SurveyStatus.SENT, metadata={ - 'sent_manually': True, - 'sent_by': str(user.id), - 'custom_message': custom_message, - 'recipient_name': recipient_name, - 'recipient_type': 'csv_upload', - 'csv_row': row - } + "sent_manually": True, + "sent_by": str(user.id), + "custom_message": custom_message, + "recipient_name": recipient_name, + "recipient_type": "csv_upload", + "csv_row": row, + }, ) - + logger.info(f"Created survey instance: {survey_instance.id}") - + # Send survey success = SurveyDeliveryService.deliver_survey(survey_instance) - + logger.info(f"Survey delivery result for {phone_number}: success={success}") - + if success: success_count += 1 created_instances.append(survey_instance) @@ -1127,64 +1102,61 @@ def manual_survey_send_csv(request): failed_count += 1 failed_numbers.append(f"{phone_number} (delivery failed - check logs)") survey_instance.delete() - + except Exception as e: failed_count += 1 - phone_display = phone_number if 'phone_number' in locals() else 'unknown' + phone_display = phone_number if "phone_number" in locals() else "unknown" failed_numbers.append(f"{phone_display} (exception: {str(e)})") logger.error(f"Exception processing row {row}: {str(e)}", exc_info=True) - + # Log audit for bulk operation AuditService.log_event( - event_type='survey_sent_manually_csv', + event_type="survey_sent_manually_csv", description=f"Bulk survey send via CSV: {success_count} successful, {failed_count} failed by {user.get_full_name()}", user=user, metadata={ - 'survey_template': survey_template.name, - 'success_count': success_count, - 'failed_count': failed_count, - 'total_count': len(rows), - 'custom_message': custom_message - } + "survey_template": survey_template.name, + "success_count": success_count, + "failed_count": failed_count, + "total_count": len(rows), + "custom_message": custom_message, + }, ) - + # Show results if success_count > 0 and failed_count == 0: - messages.success( - request, - f"Successfully sent {success_count} surveys from CSV." - ) + messages.success(request, f"Successfully sent {success_count} surveys from CSV.") elif success_count > 0 and failed_count > 0: messages.warning( request, - f"Sent {success_count} surveys successfully. {failed_count} failed. Check failed numbers: {', '.join(failed_numbers[:5])}{'...' if len(failed_numbers) > 5 else ''}" + f"Sent {success_count} surveys successfully. {failed_count} failed. Check failed numbers: {', '.join(failed_numbers[:5])}{'...' if len(failed_numbers) > 5 else ''}", ) else: messages.error( - request, - f"All {failed_count} surveys failed to send. Check the phone numbers and try again." + request, f"All {failed_count} surveys failed to send. Check the phone numbers and try again." ) - + # Redirect to list view with filter for these surveys if created_instances: - return redirect('surveys:instance_list') + return redirect("surveys:instance_list") else: - return render(request, 'surveys/manual_send_csv.html', {'form': form}) - + return render(request, "surveys/manual_send_csv.html", {"form": form}) + except Exception as e: import logging + logger = logging.getLogger(__name__) logger.error(f"Error processing CSV upload: {str(e)}", exc_info=True) messages.error(request, f"Error processing CSV: {str(e)}") - return render(request, 'surveys/manual_send_csv.html', {'form': form}) + return render(request, "surveys/manual_send_csv.html", {"form": form}) else: form = BulkCSVSurveySendForm(user) - + context = { - 'form': form, + "form": form, } - - return render(request, 'surveys/manual_send_csv.html', context) + + return render(request, "surveys/manual_send_csv.html", context) @block_source_user @@ -1192,7 +1164,7 @@ def manual_survey_send_csv(request): def survey_analytics_reports(request): """ Survey analytics reports management page. - + Features: - List all available reports - View report details @@ -1202,16 +1174,16 @@ def survey_analytics_reports(request): """ 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') - + return redirect("surveys:instance_list") + + output_dir = getattr(settings, "SURVEY_REPORTS_DIR", "reports") + # Get all reports reports = [] if os.path.exists(output_dir): @@ -1219,44 +1191,46 @@ def survey_analytics_reports(request): 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, - }) - + 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) - + 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']) - + 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, + "reports": reports, + "stats": { + "total": total_reports, + "json": json_reports, + "html": html_reports, + "markdown": markdown_reports, }, } - - return render(request, 'surveys/analytics_reports.html', context) + + return render(request, "surveys/analytics_reports.html", context) @block_source_user @@ -1264,7 +1238,7 @@ def survey_analytics_reports(request): 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 @@ -1272,66 +1246,66 @@ def survey_analytics_report_view(request, filename): """ 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') + 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') - + return redirect("surveys:analytics_reports") + if not os.path.exists(filepath): messages.error(request, "Report file not found.") - return redirect('surveys:analytics_reports') - + 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' - + 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: + 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), + "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) - + + 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), + "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) + + return render(request, "surveys/analytics_report_info.html", context) @block_source_user @@ -1343,42 +1317,37 @@ def survey_analytics_report_download(request, filename): 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') + 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') - + return redirect("surveys:analytics_reports") + if not os.path.exists(filepath): messages.error(request, "Report file not found.") - return redirect('surveys:analytics_reports') - + 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' - + 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 - ) + return FileResponse(open(filepath, "rb"), content_type=content_type, as_attachment=True, filename=filename) @block_source_user @@ -1386,78 +1355,75 @@ def survey_analytics_report_download(request, filename): 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') + 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') - + return redirect("surveys:analytics_reports") + if not os.path.exists(filepath): messages.error(request, "Report file not found.") - return redirect('surveys:analytics_reports') - + 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 - + 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')): + if filename.endswith((".html", ".json")): response = FileResponse( - open(filepath, 'rb'), + open(filepath, "rb"), content_type=content_type, - as_attachment=False # Display inline + 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;" + 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: + 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), + "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) - + 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 - ) + return FileResponse(open(filepath, "rb"), content_type=content_type, as_attachment=True, filename=filename) @block_source_user @@ -1469,49 +1435,49 @@ def survey_analytics_report_delete(request, filename): """ 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') + 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') - + return redirect("surveys:analytics_reports") + if not os.path.exists(filepath): messages.error(request, "Report file not found.") - return redirect('surveys:analytics_reports') - + 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') + + return redirect("surveys:analytics_reports") def _human_readable_size(size_bytes): """Convert bytes to human readable format""" - for unit in ['B', 'KB', 'MB', 'GB']: + 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 # ============================================================================ + @block_source_user @login_required def enhanced_survey_reports_list(request): @@ -1520,42 +1486,48 @@ def enhanced_survey_reports_list(request): """ 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') - + 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_'): + 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') + 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))) - }) - + 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, + "report_sets": report_sets, } - - return render(request, 'surveys/enhanced_reports_list.html', context) + + return render(request, "surveys/enhanced_reports_list.html", context) @block_source_user @@ -1566,35 +1538,35 @@ def enhanced_survey_report_view(request, dir_name): """ 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') + 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') - + 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') - + return redirect("surveys:enhanced_reports_list") + # Serve the index.html file - index_path = os.path.join(dir_path, 'index.html') + 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: + with open(index_path, "r", encoding="utf-8") as f: content = f.read() - return HttpResponse(content, content_type='text/html') - + return HttpResponse(content, content_type="text/html") + messages.error(request, "Index file not found.") - return redirect('surveys:enhanced_reports_list') + return redirect("surveys:enhanced_reports_list") @block_source_user @@ -1606,39 +1578,41 @@ def enhanced_survey_report_file(request, dir_name, filename): 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') + 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') - + return redirect("surveys:enhanced_reports_list") + if not os.path.exists(filepath): messages.error(request, "File not found.") - return redirect('surveys:enhanced_reports_list') - + return redirect("surveys:enhanced_reports_list") + # Serve file based on type - if filename.endswith('.html'): - with open(filepath, 'r', encoding='utf-8') as f: + if filename.endswith(".html"): + with open(filepath, "r", encoding="utf-8") as f: content = f.read() - response = HttpResponse(content, content_type='text/html') + 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;" + 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') + elif filename.endswith(".json"): + return FileResponse(open(filepath, "rb"), content_type="application/json") else: - return FileResponse(open(filepath, 'rb'), as_attachment=True) + return FileResponse(open(filepath, "rb"), as_attachment=True) @block_source_user @@ -1649,19 +1623,19 @@ def generate_enhanced_report_ui(request): """ 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') - + 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 @@ -1671,42 +1645,35 @@ def generate_enhanced_report_ui(request): 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() + start_dt = datetime.strptime(start_date, "%Y-%m-%d").date() if end_date: - end_dt = datetime.strptime(end_date, '%Y-%m-%d').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!" - ) - + + 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) - + 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) + 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)