Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

admin dashboard updates #429

Merged
merged 42 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3068d29
change center to africa
czue Nov 12, 2024
fbbafd1
add ability to enable/disable clustering by url param
czue Nov 12, 2024
1d06e14
wip: switch to chart clusters
czue Nov 12, 2024
7720e2f
cleanup
czue Nov 12, 2024
6ee83a5
cleanup
czue Nov 12, 2024
1bbdd80
delete unused click handler
czue Nov 12, 2024
3a57fe5
format file
czue Nov 12, 2024
9ccadbb
wip: loading state
czue Nov 12, 2024
447bd62
debug statements
czue Nov 12, 2024
f3026d3
improve set data / loading issues
czue Nov 12, 2024
8455804
style tweaks
czue Nov 12, 2024
4bae7e3
remove cluster argument
czue Nov 12, 2024
882c2aa
fix formatting
czue Nov 12, 2024
cb1d729
consistent colors
czue Nov 12, 2024
08a6f31
better comments
czue Nov 12, 2024
6b18685
default to only 30 days of data
czue Nov 12, 2024
b164959
whitespace
czue Nov 12, 2024
2d8cd8f
add minimum cluster size
czue Nov 12, 2024
8896d5f
increase maxZoom
czue Nov 12, 2024
91f0f0b
extract some functions to external javascript
czue Nov 12, 2024
4b30441
extract updateMarkers
czue Nov 12, 2024
ee37856
remove logging statments
czue Nov 12, 2024
8330b10
fix tests
czue Nov 12, 2024
fa092aa
put map in a container
czue Nov 12, 2024
f64430a
dummy chart implementation
czue Nov 13, 2024
c9c76c6
fix sizing, responsiveness
czue Nov 13, 2024
aabb39f
smaller cluster radius
czue Nov 13, 2024
bcce9ac
add api for graphs
czue Nov 13, 2024
1cc46a3
get program breakdown kinda working
czue Nov 13, 2024
38e0f13
add "unknown" label
czue Nov 13, 2024
19870ab
improve color overlaps
czue Nov 13, 2024
f5746e5
make it a bar chart
czue Nov 13, 2024
ac9ac10
mock out spots for pie charts
czue Nov 13, 2024
2eb33e0
implement pie charts
czue Nov 13, 2024
14be29b
style tweaks
czue Nov 13, 2024
6748eae
use a better name
czue Nov 13, 2024
c02a374
refactor each chart to its own function
czue Nov 13, 2024
684143e
add doc strings
czue Nov 13, 2024
fb11e70
externalize js to js file
czue Nov 13, 2024
9a426ce
add better empty states
czue Nov 13, 2024
89893bc
Merge pull request #431 from dimagi/cz/charts
czue Nov 13, 2024
4ad4ac4
Merge branch 'main' into cz/dashboard-tweaks
czue Nov 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions commcare_connect/reports/tests/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def all(self):
assert feature1["geometry"]["coordinates"] == [10.123, 20.456]
assert feature1["properties"]["status"] == "approved"
assert feature1["properties"]["other_field"] == "value1"
assert feature1["properties"]["color"] == "#00FF00"
assert feature1["properties"]["color"] == "#4ade80"

# Check the second feature
feature2 = geojson["features"][1]
Expand All @@ -115,7 +115,7 @@ def all(self):
assert feature2["geometry"]["coordinates"] == [30.789, 40.012]
assert feature2["properties"]["status"] == "rejected"
assert feature2["properties"]["other_field"] == "value2"
assert feature2["properties"]["color"] == "#FF0000"
assert feature2["properties"]["color"] == "#f87171"

# Check that the other cases are not included
assert all(f["properties"]["other_field"] not in ["value3", "value4", "value5"] for f in geojson["features"])
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"),
]
117 changes: 111 additions & 6 deletions 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 @@ -204,7 +204,7 @@ def __init__(self, *args, **kwargs):

# Set default dates
today = date.today()
default_from = today - timedelta(days=90)
default_from = today - timedelta(days=30)

# Set the default values
self.data["to_date"] = today.strftime("%Y-%m-%d")
Expand All @@ -223,7 +223,6 @@ class Meta:
@user_passes_test(lambda u: u.is_superuser)
def program_dashboard_report(request):
filterset = DashboardFilters(request.GET)

return render(
request,
"reports/dashboard.html",
Expand Down Expand Up @@ -258,8 +257,8 @@ def visit_map_data(request):
def _results_to_geojson(results):
geojson = {"type": "FeatureCollection", "features": []}
status_to_color = {
"approved": "#00FF00",
"rejected": "#FF0000",
"approved": "#4ade80",
"rejected": "#f87171",
}
for i, result in enumerate(results.all()):
location_str = result.get("location_str")
Expand Down Expand Up @@ -287,7 +286,7 @@ def _results_to_geojson(results):
key: value for key, value in result.items() if key not in ["gps_location_lat", "gps_location_long"]
},
}
color = status_to_color.get(result.get("status", ""), "#FFFF00")
color = status_to_color.get(result.get("status", ""), "#fbbf24")
feature["properties"]["color"] = color
geojson["features"].append(feature)

Expand Down Expand Up @@ -419,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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How confident are you that you want UserVisit rather than CompletedWork (user visits can be combined to form a single completed work, which is the unit of payment). I think we typically view Completed Works as a unit of delivery instead of the form, since it can account for things a visit that includes 2 forms filled out at the same time that together mark a delivery.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's all using the same base queryset which I got from the superset dashboard's SQL, so I think it's right? Or at least, it's consistent with the superset view and itself.

But can definitely revisit this logic once the dashboard has been vetted by the team more.

# 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],
}
Loading
Loading