Skip to content

Commit

Permalink
Merge pull request #431 from dimagi/cz/charts
Browse files Browse the repository at this point in the history
Add charts to the dahboard
  • Loading branch information
czue authored Nov 13, 2024
2 parents fa092aa + 9a426ce commit 89893bc
Show file tree
Hide file tree
Showing 4 changed files with 377 additions and 4 deletions.
1 change: 1 addition & 0 deletions commcare_connect/reports/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
108 changes: 107 additions & 1 deletion commcare_connect/reports/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
}
193 changes: 193 additions & 0 deletions commcare_connect/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading

0 comments on commit 89893bc

Please sign in to comment.