diff --git a/commcare_connect/reports/urls.py b/commcare_connect/reports/urls.py index cd05d994..de9ca690 100644 --- a/commcare_connect/reports/urls.py +++ b/commcare_connect/reports/urls.py @@ -9,4 +9,5 @@ path("delivery_stats", view=views.DeliveryStatsReportView.as_view(), name="delivery_stats_report"), path("api/visit_map_data/", views.visit_map_data, name="visit_map_data"), path("api/dashboard_stats/", views.dashboard_stats_api, name="dashboard_stats_api"), + path("api/dashboard_charts/", views.dashboard_charts_api, name="dashboard_charts_api"), ] diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index 7e25f92b..47e105b1 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -8,7 +8,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.db.models import Max, Q, Sum +from django.db.models import Count, Max, Q, Sum from django.http import JsonResponse from django.shortcuts import render from django.urls import reverse @@ -418,3 +418,109 @@ def dashboard_stats_api(request): "percent_verified": f"{percent_verified:.1f}%", } ) + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def dashboard_charts_api(request): + filterset = DashboardFilters(request.GET) + queryset = UserVisit.objects.all() + # Use the filtered queryset if available, else use last 30 days + if filterset.is_valid(): + queryset = filterset.filter_queryset(queryset) + from_date = filterset.form.cleaned_data["from_date"] + to_date = filterset.form.cleaned_data["to_date"] + else: + to_date = datetime.now().date() + from_date = to_date - timedelta(days=30) + queryset = queryset.filter(visit_date__gte=from_date, visit_date__lte=to_date) + + return JsonResponse( + { + "time_series": _get_time_series_data(queryset, from_date, to_date), + "program_pie": _get_program_pie_data(queryset), + "status_pie": _get_status_pie_data(queryset), + } + ) + + +def _get_time_series_data(queryset, from_date, to_date): + """Example output: + { + "labels": ["Jan 01", "Jan 02", "Jan 03"], + "datasets": [ + { + "name": "Program A", + "data": [5, 3, 7] + }, + { + "name": "Program B", + "data": [2, 4, 1] + } + ] + } + """ + # Get visits over time by program + visits_by_program_time = ( + queryset.values("visit_date", "opportunity__delivery_type__name") + .annotate(count=Count("id")) + .order_by("visit_date", "opportunity__delivery_type__name") + ) + + # Process time series data + program_data = {} + for visit in visits_by_program_time: + program_name = visit["opportunity__delivery_type__name"] + if program_name not in program_data: + program_data[program_name] = {} + program_data[program_name][visit["visit_date"]] = visit["count"] + + # Create labels and datasets for time series + labels = [] + time_datasets = [] + current_date = from_date + + while current_date <= to_date: + labels.append(current_date.strftime("%b %d")) + current_date += timedelta(days=1) + + for program_name in program_data.keys(): + data = [] + current_date = from_date + while current_date <= to_date: + data.append(program_data[program_name].get(current_date, 0)) + current_date += timedelta(days=1) + + time_datasets.append({"name": program_name or "Unknown", "data": data}) + + return {"labels": labels, "datasets": time_datasets} + + +def _get_program_pie_data(queryset): + """Example output: + { + "labels": ["Program A", "Program B", "Unknown"], + "data": [10, 5, 2] + } + """ + visits_by_program = ( + queryset.values("opportunity__delivery_type__name").annotate(count=Count("id")).order_by("-count") + ) + return { + "labels": [item["opportunity__delivery_type__name"] or "Unknown" for item in visits_by_program], + "data": [item["count"] for item in visits_by_program], + } + + +def _get_status_pie_data(queryset): + """Example output: + { + "labels": ["Approved", "Pending", "Rejected", "Unknown"], + "data": [15, 8, 4, 1] + } + """ + visits_by_status = queryset.values("status").annotate(count=Count("id")).order_by("-count") + return { + "labels": [item["status"] or "Unknown" for item in visits_by_status], + "data": [item["count"] for item in visits_by_status], + } diff --git a/commcare_connect/static/js/dashboard.js b/commcare_connect/static/js/dashboard.js index d73535b3..53ca2b65 100644 --- a/commcare_connect/static/js/dashboard.js +++ b/commcare_connect/static/js/dashboard.js @@ -128,5 +128,198 @@ function donutSegment(start, end, r, r0, color) { }" fill="${color}" opacity="0.85" stroke="#1f2937" stroke-width="1" />`; } +const chartColors = [ + { border: 'rgb(75, 192, 192)', background: 'rgba(75, 192, 192, 0.8)' }, + { border: 'rgb(255, 99, 132)', background: 'rgba(255, 99, 132, 0.8)' }, + { border: 'rgb(255, 205, 86)', background: 'rgba(255, 205, 86, 0.8)' }, + { border: 'rgb(54, 162, 235)', background: 'rgba(54, 162, 235, 0.8)' }, +]; + +const statusColors = { + approved: { + background: 'rgba(74, 222, 128, 0.8)', + border: 'rgb(74, 222, 128)', + }, + rejected: { + background: 'rgba(248, 113, 113, 0.8)', + border: 'rgb(248, 113, 113)', + }, + pending: { + background: 'rgba(251, 191, 36, 0.8)', + border: 'rgb(251, 191, 36)', + }, +}; + +function createTimeSeriesChart(ctx, data) { + return new Chart(ctx, { + type: 'bar', + data: { + labels: data.labels, + datasets: data.datasets.map((dataset, index) => ({ + label: dataset.name, + data: dataset.data, + borderColor: chartColors[index % chartColors.length].border, + backgroundColor: chartColors[index % chartColors.length].background, + borderWidth: 1, + })), + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + tooltip: { + mode: 'index', + intersect: false, + }, + }, + scales: { + x: { + stacked: true, + title: { + display: true, + text: 'Date', + }, + }, + y: { + stacked: true, + beginAtZero: true, + title: { + display: true, + text: 'Number of Visits', + }, + }, + }, + }, + }); +} + +function createProgramPieChart(ctx, data) { + // Check if there's no data or empty data + if (!data?.data?.length) { + return new Chart(ctx, { + type: 'pie', + data: { + labels: ['No data'], + datasets: [ + { + data: [1], + backgroundColor: ['rgba(156, 163, 175, 0.3)'], + borderColor: ['rgb(156, 163, 175)'], + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, + color: 'rgb(156, 163, 175)', + }, + }, + }, + }, + }); + } + + return new Chart(ctx, { + type: 'pie', + data: { + labels: data.labels, + datasets: [ + { + data: data.data, + backgroundColor: chartColors.map((c) => c.background), + borderColor: chartColors.map((c) => c.border), + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, + }, + }, + }, + }, + }); +} + +function createStatusPieChart(ctx, data) { + // Check if there's no data or empty data + if (!data?.data?.length) { + return new Chart(ctx, { + type: 'pie', + data: { + labels: ['No data'], + datasets: [ + { + data: [1], + backgroundColor: ['rgba(156, 163, 175, 0.3)'], + borderColor: ['rgb(156, 163, 175)'], + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, + color: 'rgb(156, 163, 175)', + }, + }, + }, + }, + }); + } + + return new Chart(ctx, { + type: 'pie', + data: { + labels: data.labels, + datasets: [ + { + data: data.data, + backgroundColor: data.labels.map( + (status) => + statusColors[status]?.background || 'rgba(156, 163, 175, 0.8)', + ), + borderColor: data.labels.map( + (status) => statusColors[status]?.border || 'rgb(156, 163, 175)', + ), + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, + }, + }, + }, + }, + }); +} + window.updateMarkers = updateMarkers; window.createDonutChart = createDonutChart; +window.createTimeSeriesChart = createTimeSeriesChart; +window.createProgramPieChart = createProgramPieChart; +window.createStatusPieChart = createStatusPieChart; diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index bafdd0aa..5fe5a5b2 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -6,6 +6,7 @@ {% block javascript %} {{ block.super }} + {% endblock %} {% block content %}