HH/apps/analytics/ui_views.py
2026-04-09 13:46:34 +03:00

1007 lines
38 KiB
Python

"""
Analytics Console UI views
"""
from datetime import datetime
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.core.paginator import Paginator
from django.db.models import Avg, Count, F, Q, Value
from django.db.models.functions import Concat
from django.http import JsonResponse
from django.shortcuts import render
from apps.complaints.models import Complaint
from apps.organizations.models import Department, Hospital
from apps.px_action_center.models import PXAction
from apps.surveys.models import SurveyInstance
from apps.physicians.models import PhysicianMonthlyRating
from .models import KPI, KPIValue
from .services import UnifiedAnalyticsService, ExportService
from .services.ai_analytics import (
ExecutiveSummaryGenerator,
EarlyWarningSystem,
ComplaintVolumeForecaster,
SLABreachPredictor,
ActionRecommendationEngine,
)
from apps.core.decorators import block_source_user
import json
def serialize_queryset_values(queryset):
"""Properly serialize QuerySet values to JSON string."""
if queryset is None:
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
row[key] = str(value)
# Convert Python None to JavaScript null
elif value is None:
row[key] = None
else:
row[key] = value
result.append(row)
return json.dumps(result, default=str)
@block_source_user
@login_required
def analytics_dashboard(request):
"""
Analytics dashboard with KPIs and charts.
Comprehensive dashboard showing:
- KPI cards with current values for Complaints, Actions, Surveys, Feedback
- Trend charts
- Department rankings
- Source distribution
- Status breakdown
"""
from apps.feedback.models import Feedback
from django.utils import timezone
from datetime import timedelta
from django.db.models.functions import TruncDate, TruncMonth
user = request.user
# Build cache key based on user and hospital
cache_key = f"analytics_dashboard_{user.id}_{request.GET.get('hospital', 'all')}"
cached = cache.get(cache_key)
if cached:
return render(request, "analytics/dashboard.html", cached)
# Get hospital filter
hospital_filter = request.GET.get("hospital")
if hospital_filter:
hospital = Hospital.objects.filter(id=hospital_filter).first()
elif user.is_px_admin() and hasattr(request, "tenant_hospital") and request.tenant_hospital:
hospital = request.tenant_hospital
elif user.hospital:
hospital = user.hospital
else:
hospital = None
# Base querysets
complaints_queryset = Complaint.objects.all()
actions_queryset = PXAction.objects.all()
surveys_queryset = SurveyInstance.objects.filter(status="completed")
feedback_queryset = Feedback.objects.all()
if hospital:
complaints_queryset = complaints_queryset.filter(hospital=hospital)
actions_queryset = actions_queryset.filter(hospital=hospital)
surveys_queryset = surveys_queryset.filter(survey_template__hospital=hospital)
feedback_queryset = feedback_queryset.filter(hospital=hospital)
# ============ 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()
overdue_complaints = complaints_queryset.filter(is_overdue=True).count()
# Complaint source types (internal vs external)
internal_complaints = complaints_queryset.filter(complaint_source_type="internal").count()
external_complaints = complaints_queryset.filter(complaint_source_type="external").count()
# Complaint sources (by PXSource name)
complaint_sources = (
complaints_queryset.filter(source__isnull=False)
.values("source__name_en")
.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]
)
# Complaint categories (Level 2)
top_categories = (
complaints_queryset.filter(category__isnull=False)
.values("category__name_en")
.annotate(count=Count("id"))
.order_by("-count")[:5]
)
# Complaint severity - build explicit counts for template
severity_counts = complaints_queryset.values("severity").annotate(count=Count("id"))
severity_map = {item["severity"]: item["count"] for item in severity_counts}
critical_complaints = severity_map.get("critical", 0)
high_complaints = severity_map.get("high", 0)
medium_complaints = severity_map.get("medium", 0)
low_complaints = severity_map.get("low", 0)
# Severity breakdown for JSON
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")
# ============ 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()
overdue_actions = actions_queryset.filter(is_overdue=True).count()
pending_actions = actions_queryset.filter(status="pending_approval").count()
# Action sources
action_sources = (
actions_queryset.filter(source_type__isnull=False)
.values("source_type")
.annotate(count=Count("id"))
.order_by("-count")[:6]
)
# Action categories - build explicit counts
action_categories = (
actions_queryset.exclude(category="").values("category").annotate(count=Count("id")).order_by("-count")[:5]
)
action_category_map = {item["category"]: item["count"] for item in action_categories}
training_actions = action_category_map.get("training", 0)
process_actions = action_category_map.get("process_improvement", 0)
policy_actions = action_category_map.get("policy", 0)
facility_actions = action_category_map.get("facility", 0)
other_actions = action_category_map.get("other", 0)
# ============ SURVEYS KPIs ============
total_surveys = surveys_queryset.count()
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()
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]
# ============ FEEDBACK KPIs ============
total_feedback = feedback_queryset.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")
# Feedback categories
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
# ============ 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")
)
# Survey score trend - last 6 months for chart
six_months_ago = timezone.now() - timedelta(days=180)
survey_score_trend_6m = (
surveys_queryset.filter(completed_at__gte=six_months_ago)
.annotate(month=TruncMonth("completed_at"))
.values("month")
.annotate(avg_score=Avg("total_score"))
.order_by("month")
)
# Build survey trend array for last 6 months (pad with zeros if missing)
from calendar import month_name
now = timezone.now()
survey_trend_values = []
survey_trend_labels = []
for i in range(5, -1, -1):
target_month = now.month - i
target_year = now.year
while target_month <= 0:
target_month += 12
target_year -= 1
survey_trend_labels.append(month_name[target_month][:3])
# Find matching data point
found = None
for item in survey_score_trend_6m:
if item["month"].month == target_month and item["month"].year == target_year:
found = round(item["avg_score"], 2) if item["avg_score"] else 0
break
survey_trend_values.append(found if found is not None else 0)
# ============ 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]
)
# Build department_stats list with resolution rate calculation
department_stats = []
for dept in department_rankings:
dept_complaints = (
complaints_queryset.filter(department=dept).count()
if hospital
else Complaint.objects.filter(department=dept).count()
)
dept_actions = (
actions_queryset.filter(department=dept).count()
if hospital
else PXAction.objects.filter(department=dept).count()
)
dept_resolved = (
complaints_queryset.filter(department=dept, status__in=["resolved", "closed"]).count()
if hospital
else Complaint.objects.filter(department=dept, status__in=["resolved", "closed"]).count()
)
resolution_rate = round((dept_resolved / dept_complaints * 100), 1) if dept_complaints > 0 else 0
department_stats.append(
{
"name_en": dept.name_en if hasattr(dept, "name_en") else str(dept),
"name_ar": dept.name_ar
if hasattr(dept, "name_ar")
else (dept.name_en if hasattr(dept, "name_en") else str(dept)),
"complaints": dept_complaints,
"actions": dept_actions,
"survey_avg": round(dept.avg_score, 2) if dept.avg_score else 0,
"resolution_rate": resolution_rate,
}
)
# ============ 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
)
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"]
if avg_resolution_hours:
avg_resolution_hours = avg_resolution_hours.total_seconds() / 3600
else:
avg_resolution_hours = 0
else:
avg_resolution_hours = 0
# Average action completion time
closed_actions_with_time = actions_queryset.filter(
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"]
if avg_action_days:
avg_action_days = avg_action_days.days
else:
avg_action_days = 0
else:
avg_action_days = 0
# ============ 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")
).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)
if nps_surveys.exists():
promoters = nps_surveys.filter(total_score__gte=9).count()
detractors = nps_surveys.filter(total_score__lte=6).count()
total_nps = nps_surveys.count()
nps_score = ((promoters - detractors) / total_nps * 100) if total_nps > 0 else 0
else:
nps_score = 0
kpis = {
"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,
"internal_complaints": internal_complaints,
"external_complaints": external_complaints,
"critical_complaints": critical_complaints,
"high_complaints": high_complaints,
"medium_complaints": medium_complaints,
"low_complaints": low_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,
"pending_actions": pending_actions,
"overdue_actions": overdue_actions,
"training_actions": training_actions,
"process_actions": process_actions,
"policy_actions": policy_actions,
"facility_actions": facility_actions,
"other_actions": other_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),
"survey_trend_1": survey_trend_values[0] if len(survey_trend_values) > 0 else 0,
"survey_trend_2": survey_trend_values[1] if len(survey_trend_values) > 1 else 0,
"survey_trend_3": survey_trend_values[2] if len(survey_trend_values) > 2 else 0,
"survey_trend_4": survey_trend_values[3] if len(survey_trend_values) > 3 else 0,
"survey_trend_5": survey_trend_values[4] if len(survey_trend_values) > 4 else 0,
"survey_trend_6": survey_trend_values[5] if len(survey_trend_values) > 5 else 0,
}
# ============ AI-POWERED ANALYTICS ============
hospital_id = str(hospital.id) if hospital else None
# Trigger async Celery tasks to refresh cache in background
from .tasks import (
generate_executive_summary_task,
generate_action_recommendations_task,
)
generate_executive_summary_task.delay(user_id=str(user.id), hospital_id=hospital_id, period="30d")
generate_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
# 1. Executive Summary — read from cache (populated by Celery or fallback)
exec_summary = ExecutiveSummaryGenerator.generate(user, hospital_id=hospital_id, period="30d")
# 2. Early Warning System
early_warnings = EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5)
# 3. Complaint Volume Forecast
complaint_forecast = ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30)
# 4. SLA Breach Predictions
sla_breach_predictions = SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10)
# 5. Action Recommendations — read from cache
action_recommendations = ActionRecommendationEngine.generate_recommendations(user, hospital_id=hospital_id, limit=5)
context = {
"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_6m),
"sentiment_breakdown": serialize_queryset_values(sentiment_breakdown),
"feedback_categories": serialize_queryset_values(feedback_categories),
"department_rankings": department_rankings,
"department_stats": department_stats,
"survey_trend_labels": json.dumps(survey_trend_labels),
# AI-powered features
"exec_summary": exec_summary,
"early_warnings": early_warnings,
"complaint_forecast": complaint_forecast,
"sla_breach_predictions": sla_breach_predictions,
"action_recommendations": action_recommendations,
}
# Clear old cache (the new data isn't in the old cache entries)
cache.delete(cache_key)
return render(request, "analytics/dashboard.html", context)
@block_source_user
@login_required
def refresh_ai_analytics(request):
"""
API endpoint: Trigger async AI analytics refresh and return status.
POST to trigger, GET to check if cache is fresh.
"""
if request.method == "POST":
from .tasks import (
generate_executive_summary_task,
generate_action_recommendations_task,
precompute_dashboard_cache_task,
)
hospital_id = request.POST.get("hospital") or request.GET.get("hospital")
user = request.user
# Trigger async tasks
generate_executive_summary_task.delay(
user_id=str(user.id), hospital_id=hospital_id, period="30d", force_refresh=True
)
generate_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
# Also clear caches so next page load triggers fresh computation
cache.delete(f"exec_summary_{user.id}_{hospital_id}_30d")
cache.delete(f"action_recommendations_{user.id}_{hospital_id}_5")
return JsonResponse(
{"status": "triggered", "message": "AI analytics refresh queued. Results will be available in ~30 seconds."}
)
# GET — check cache freshness
hospital_id = request.GET.get("hospital") or (
str(request.tenant_hospital.id) if hasattr(request, "tenant_hospital") and request.tenant_hospital else None
)
user = request.user
summary_cached = cache.get(f"exec_summary_{user.id}_{hospital_id}_30d")
recommendations_cached = cache.get(f"action_recommendations_{user.id}_{hospital_id}_5")
return JsonResponse(
{
"cached": {
"executive_summary": summary_cached is not None,
"action_recommendations": recommendations_cached is not None,
},
"risk_level": summary_cached.get("risk_level", "unknown") if summary_cached else None,
}
)
@block_source_user
@login_required
def refresh_dashboard_cache(request):
"""
API endpoint: Trigger dashboard cache refresh on demand.
POST to trigger refresh, returns immediately with task status.
"""
if request.method != "POST":
return JsonResponse({"error": "POST method required"}, status=405)
from .tasks import precompute_dashboard_cache_task
user = request.user
# Trigger async cache refresh
task = precompute_dashboard_cache_task.delay()
# Clear user's dashboard cache so next load gets fresh data
cache.delete(f"analytics_dashboard_{user.id}_all")
if hasattr(request, "tenant_hospital") and request.tenant_hospital:
cache.delete(f"analytics_dashboard_{user.id}_{request.tenant_hospital.id}")
return JsonResponse({
"status": "triggered",
"message": "Dashboard cache refresh queued. Please reload the page in a few seconds.",
"task_id": str(task.id),
})
@block_source_user
@login_required
def kpi_list(request):
"""KPI definitions list view"""
queryset = KPI.objects.all()
# Apply filters
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":
queryset = queryset.filter(is_active=True)
elif is_active == "false":
queryset = queryset.filter(is_active=False)
# Ordering
queryset = queryset.order_by("category", "name")
# Pagination
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
context = {
"page_obj": page_obj,
"kpis": page_obj.object_list,
"filters": request.GET,
}
return render(request, "analytics/kpi_list.html", context)
@block_source_user
@login_required
def command_center(request):
"""
PX Command Center - Unified Dashboard
Comprehensive dashboard showing all PX360 metrics:
- Complaints, Surveys, Actions KPIs
- Interactive charts with ApexCharts
- Department and Physician rankings
- 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", ""),
}
# 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)
# Get departments for filter
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")
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,
custom_start=custom_start,
custom_end=custom_end,
)
# Initial AI data for server-side render
from .services.ai_analytics import (
ExecutiveSummaryGenerator,
EarlyWarningSystem,
ComplaintVolumeForecaster,
SLABreachPredictor,
ActionRecommendationEngine,
)
hospital_id = filters["hospital"] if filters["hospital"] else None
department_id = filters["department"] if filters["department"] else None
if not hospital_id and user.is_px_admin():
tenant = getattr(request, "tenant_hospital", None)
if tenant:
hospital_id = str(tenant.id)
# Trigger async refresh
from .tasks import generate_executive_summary_task, generate_action_recommendations_task
generate_executive_summary_task.delay(
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id, period=filters["date_range"]
)
generate_action_recommendations_task.delay(
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
)
context = {
"filters": filters,
"departments": departments,
"kpis": kpis,
"exec_summary": ExecutiveSummaryGenerator.generate(
user, hospital_id=hospital_id, department_id=department_id, period=filters["date_range"]
),
"early_warnings": EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5),
"complaint_forecast": ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30),
"sla_breach_predictions": SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10),
"action_recommendations": ActionRecommendationEngine.generate_recommendations(
user, hospital_id=hospital_id, department_id=department_id, limit=5
),
}
return render(request, "analytics/command_center.html", context)
@block_source_user
@login_required
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)
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")
# 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")
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
if not hospital_id and user.is_px_admin():
tenant = getattr(request, "tenant_hospital", None)
if tenant:
hospital_id = str(tenant.id)
# Get KPIs
kpis = UnifiedAnalyticsService.get_all_kpis(
user=user,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
kpi_category=kpi_category,
custom_start=custom_start,
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",
]
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
elif isinstance(value, (int, float)):
# Already a number - ensure floats for specific fields
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
# 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))
# Get chart data
chart_types = [
"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(
user=user,
chart_type=chart_type,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
custom_start=custom_start,
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]
.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"),
)
)
# Physician leaderboard table
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,
}
for p in physician_data
]
# ============ AI-POWERED ANALYTICS ============
from .services.ai_analytics import (
ExecutiveSummaryGenerator,
EarlyWarningSystem,
ComplaintVolumeForecaster,
SLABreachPredictor,
ActionRecommendationEngine,
)
# Trigger async Celery tasks for background refresh
from .tasks import (
generate_executive_summary_task,
generate_action_recommendations_task,
)
generate_executive_summary_task.delay(
user_id=str(user.id),
hospital_id=hospital_id,
department_id=department_id,
period=date_range.replace("d", "") if date_range.endswith("d") else "30d",
)
generate_action_recommendations_task.delay(
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
)
# AI features — read from cache (populated by Celery precompute or on-demand)
ai_data = {
"executive_summary": ExecutiveSummaryGenerator.generate(
user, hospital_id=hospital_id, department_id=department_id, period=date_range
),
"early_warnings": EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5),
"complaint_forecast": ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30),
"sla_breach_predictions": SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10),
"action_recommendations": ActionRecommendationEngine.generate_recommendations(
user, hospital_id=hospital_id, department_id=department_id, limit=5
),
}
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables, "ai": ai_data})
@block_source_user
@login_required
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)
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")
# 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")
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,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
kpi_category=kpi_category,
custom_start=custom_start,
custom_end=custom_end,
)
chart_types = [
"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(
user=user,
chart_type=chart_type,
date_range=date_range,
hospital_id=hospital_id,
department_id=department_id,
custom_start=custom_start,
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]
.annotate(
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")
),
}
# Physician leaderboard
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"]),
]
for p in physician_data
],
}
# Prepare export data
export_data = ExportService.prepare_dashboard_data(user=user, kpis=kpis, charts=charts, tables=tables)
# Export based on format
if export_format == "excel":
return ExportService.export_to_excel(export_data)
elif export_format == "pdf":
return ExportService.export_to_pdf(export_data)
return JsonResponse({"error": "Export failed"}, status=500)