diff --git a/commcare_connect/reports/queries.py b/commcare_connect/reports/queries.py new file mode 100644 index 00000000..ed9ddf91 --- /dev/null +++ b/commcare_connect/reports/queries.py @@ -0,0 +1,34 @@ +from django.db.models import F +from django.db.models.fields.json import KT + + +def get_visit_map_queryset(base_queryset): + return ( + base_queryset.annotate( + deliver_unit_name=F("deliver_unit__name"), + username_connectid=KT("form_json__metadata__username"), + timestart_str=KT("form_json__metadata__timeStart"), + timeend_str=KT("form_json__metadata__timeEnd"), + location_str=KT("form_json__metadata__location"), + ) + .select_related("deliver_unit", "opportunity", "opportunity__delivery_type", "opportunity__organization") + .values( + "opportunity_id", + "opportunity__delivery_type__name", + "opportunity__delivery_type__slug", + "opportunity__organization__slug", + "opportunity__organization__name", + "xform_id", + "visit_date", + "username_connectid", + "deliver_unit_name", + "entity_id", + "status", + "flagged", + "flag_reason", + "reason", + "timestart_str", + "timeend_str", + "location_str", + ) + ) diff --git a/commcare_connect/reports/tests/test_reports.py b/commcare_connect/reports/tests/test_reports.py index 25710f41..f85ae168 100644 --- a/commcare_connect/reports/tests/test_reports.py +++ b/commcare_connect/reports/tests/test_reports.py @@ -65,19 +65,32 @@ def test_delivery_stats(opportunity: Opportunity): def test_results_to_geojson(): + class MockQuerySet: + def __init__(self, results): + self.results = results + + def all(self): + return self.results + # Test input - results = [ - {"gps_location_long": "10.123", "gps_location_lat": "20.456", "status": "approved", "other_field": "value1"}, - {"gps_location_long": "30.789", "gps_location_lat": "40.012", "status": "rejected", "other_field": "value2"}, - {"gps_location_long": "invalid", "gps_location_lat": "50.678", "status": "unknown", "other_field": "value3"}, - {"status": "approved", "other_field": "value4"}, # Case where lat/lon are not present - { # Case where lat/lon are null - "gps_location_long": None, - "gps_location_lat": None, - "status": "rejected", - "other_field": "value5", - }, - ] + results = MockQuerySet( + [ + {"location_str": "20.456 10.123 0 0", "status": "approved", "other_field": "value1"}, + {"location_str": "40.012 30.789", "status": "rejected", "other_field": "value2"}, + {"location_str": "invalid location", "status": "unknown", "other_field": "value3"}, + {"location_str": "bad location", "status": "unknown", "other_field": "value4"}, + { + "location_str": None, + "status": "approved", + "other_field": "value5", + }, # Case where lat/lon are not present + { # Case where lat/lon are null + "location_str": None, + "status": "rejected", + "other_field": "value5", + }, + ] + ) # Call the function geojson = _results_to_geojson(results) diff --git a/commcare_connect/reports/urls.py b/commcare_connect/reports/urls.py index 0b780e73..cd05d994 100644 --- a/commcare_connect/reports/urls.py +++ b/commcare_connect/reports/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path("program_dashboard", views.program_dashboard_report, name="program_dashboard_report"), - path("api/visit_map_data/", views.visit_map_data, name="visit_map_data"), 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"), ] diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index 9cea2c50..c91e8789 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -1,12 +1,13 @@ -from datetime import date, datetime +from datetime import date, datetime, timedelta import django_filters import django_tables2 as tables +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Column, Layout, Row from django import forms 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 import connection from django.db.models import Max, Q, Sum from django.http import JsonResponse from django.shortcuts import render @@ -16,7 +17,9 @@ from django_filters.views import FilterView from commcare_connect.cache import quickcache -from commcare_connect.opportunity.models import CompletedWork, CompletedWorkStatus, DeliveryType, Payment +from commcare_connect.opportunity.models import CompletedWork, CompletedWorkStatus, DeliveryType, Payment, UserVisit +from commcare_connect.organization.models import Organization +from commcare_connect.reports.queries import get_visit_map_queryset from .tables import AdminReportTable @@ -151,14 +154,83 @@ def _get_table_data_for_quarter(quarter, delivery_type, group_by_delivery_type=F return data +class DashboardFilters(django_filters.FilterSet): + program = django_filters.ModelChoiceFilter( + queryset=DeliveryType.objects.all(), + field_name="opportunity__delivery_type", + label="Program", + empty_label="All Programs", + required=False, + ) + organization = django_filters.ModelChoiceFilter( + queryset=Organization.objects.all(), + field_name="opportunity__organization", + label="Organization", + empty_label="All Organizations", + required=False, + ) + from_date = django_filters.DateTimeFilter( + widget=forms.DateInput(attrs={"type": "date"}), + field_name="visit_date", + lookup_expr="gt", + label="From Date", + required=False, + ) + to_date = django_filters.DateTimeFilter( + widget=forms.DateInput(attrs={"type": "date"}), + field_name="visit_date", + lookup_expr="lte", + label="To Date", + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.helper = FormHelper() + self.form.helper.form_class = "form-inline" + self.form.helper.layout = Layout( + Row( + Column("program", css_class="col-md-3"), + Column("organization", css_class="col-md-3"), + Column("from_date", css_class="col-md-3"), + Column("to_date", css_class="col-md-3"), + ) + ) + + # Set default values if no data is provided + if not self.data: + # Create a mutable copy of the QueryDict + self.data = self.data.copy() if self.data else {} + + # Set default dates + today = date.today() + default_from = today - timedelta(days=90) + + # Set the default values + self.data["to_date"] = today.strftime("%Y-%m-%d") + self.data["from_date"] = default_from.strftime("%Y-%m-%d") + + # Force the form to bind with the default data + self.form.is_bound = True + self.form.data = self.data + + class Meta: + model = UserVisit + fields = ["program", "organization", "from_date", "to_date"] + + @login_required -@user_passes_test(lambda user: user.is_superuser) -@require_GET +@user_passes_test(lambda u: u.is_superuser) def program_dashboard_report(request): + filterset = DashboardFilters(request.GET) + return render( request, "reports/dashboard.html", - context={"mapbox_token": settings.MAPBOX_TOKEN}, + context={ + "mapbox_token": settings.MAPBOX_TOKEN, + "filter": filterset, + }, ) @@ -166,20 +238,18 @@ def program_dashboard_report(request): @user_passes_test(lambda user: user.is_superuser) @require_GET def visit_map_data(request): - with connection.cursor() as cursor: - # Read the SQL file - with open("commcare_connect/reports/sql/visit_map.sql") as sql_file: - sql_query = sql_file.read() + filterset = DashboardFilters(request.GET) + + # Use the filtered queryset to calculate stats - # Execute the query - cursor.execute(sql_query) + queryset = UserVisit.objects.all() + if filterset.is_valid(): + queryset = filterset.filter_queryset(queryset) - # Fetch all results - columns = [col[0] for col in cursor.description] - results = [dict(zip(columns, row)) for row in cursor.fetchall()] + queryset = get_visit_map_queryset(queryset) # Convert to GeoJSON - geojson = _results_to_geojson(results) + geojson = _results_to_geojson(queryset) # Return the GeoJSON as JSON response return JsonResponse(geojson, safe=False) @@ -191,14 +261,20 @@ def _results_to_geojson(results): "approved": "#00FF00", "rejected": "#FF0000", } - for result in results: + for i, result in enumerate(results.all()): + location_str = result.get("location_str") # Check if both latitude and longitude are not None and can be converted to float - if result.get("gps_location_long") and result.get("gps_location_lat"): - try: - longitude = float(result["gps_location_long"]) - latitude = float(result["gps_location_lat"]) - except ValueError: - # Skip this result if conversion to float fails + if location_str: + split_location = location_str.split(" ") + if len(split_location) >= 2: + try: + longitude = float(split_location[1]) + latitude = float(split_location[0]) + except ValueError: + # Skip this result if conversion to float fails + continue + else: + # Or if the location string is not in the expected format continue feature = { @@ -317,3 +393,29 @@ def object_list(self): data = _get_table_data_for_quarter(q, delivery_type, group_by_delivery_type) table_data += data return table_data + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def dashboard_stats_api(request): + filterset = DashboardFilters(request.GET) + + # Use the filtered queryset to calculate stats + queryset = UserVisit.objects.all() + if filterset.is_valid(): + queryset = filterset.filter_queryset(queryset) + + # Example stats calculation (adjust based on your needs) + active_users = queryset.values("opportunity_access__user").distinct().count() + total_visits = queryset.count() + verified_visits = queryset.filter(status=CompletedWorkStatus.approved).count() + percent_verified = round(float(verified_visits / total_visits) * 100, 1) if total_visits > 0 else 0 + + return JsonResponse( + { + "total_visits": total_visits, + "active_users": active_users, + "verified_visits": verified_visits, + "percent_verified": f"{percent_verified:.1f}%", + } + ) diff --git a/commcare_connect/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 3e164d3a..e1973c7d 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -4,152 +4,277 @@ {% load django_tables2 %} {% block title %}Admin Dashboard{% endblock %} {% block content %} -

Visit Dashboard

-
- - +

Program Dashboard

+
+
+
+ {% crispy filter.form %} +
+
+
+
+
+
+
+ 0 + + +   + +
+
Active FLWs
+
+
+
+
+
+
+
+ 0 + + +   + +
+
Total Visits
+
+
+
+
+
+
+
+ 0 + + +   + +
+
Verified Visits
+
+
+
+
+
+
+
+ 0 + + +   + +
+
Percent Verified
+
+
+
+
+
+
+
+

Service Delivery Map

+
+
+
{% endblock content %} {% block inline_javascript %} {{ block.super }} - {{ user_visits|json_script:"userVisits" }} - + + {% endblock %}