diff --git a/commcare_connect/connect_id_client/main.py b/commcare_connect/connect_id_client/main.py index 1f5307a1..7f117d9f 100644 --- a/commcare_connect/connect_id_client/main.py +++ b/commcare_connect/connect_id_client/main.py @@ -54,8 +54,11 @@ def add_credential(organization: Organization, credential: str, users: list[str] return -def fetch_credentials(): - response = _make_request(GET, "/users/fetch_credentials") +def fetch_credentials(org_slug=None): + params = {} + if org_slug: + params["org_slug"] = org_slug + response = _make_request(GET, "/users/fetch_credentials", params=params) data = response.json() return [Credential(**c) for c in data["credentials"]] diff --git a/commcare_connect/opportunity/admin.py b/commcare_connect/opportunity/admin.py index 6bb084a5..86839d6e 100644 --- a/commcare_connect/opportunity/admin.py +++ b/commcare_connect/opportunity/admin.py @@ -48,6 +48,7 @@ class OpportunityAccessAdmin(admin.ModelAdmin): form = OpportunityAccessCreationForm list_display = ["get_opp_name", "get_username"] actions = ["clear_user_progress"] + search_fields = ["user__username"] @admin.display(description="Opportunity Name") def get_opp_name(self, obj): diff --git a/commcare_connect/opportunity/forms.py b/commcare_connect/opportunity/forms.py index 3e472450..7c567d89 100644 --- a/commcare_connect/opportunity/forms.py +++ b/commcare_connect/opportunity/forms.py @@ -33,7 +33,8 @@ class OpportunityUserInviteForm(forms.Form): def __init__(self, *args, **kwargs): - credentials = connect_id_client.fetch_credentials() + org_slug = kwargs.pop("org_slug", None) + credentials = connect_id_client.fetch_credentials(org_slug) super().__init__(*args, **kwargs) self.helper = FormHelper(self) @@ -73,7 +74,10 @@ def clean_users(self): return split_users -class OpportunityChangeForm(forms.ModelForm, OpportunityUserInviteForm): +class OpportunityChangeForm( + OpportunityUserInviteForm, + forms.ModelForm, +): class Meta: model = Opportunity fields = [ diff --git a/commcare_connect/opportunity/tests/test_forms.py b/commcare_connect/opportunity/tests/test_forms.py index a8f422be..58f6db95 100644 --- a/commcare_connect/opportunity/tests/test_forms.py +++ b/commcare_connect/opportunity/tests/test_forms.py @@ -5,8 +5,8 @@ import pytest from factory.fuzzy import FuzzyDate, FuzzyText -from commcare_connect.opportunity.forms import OpportunityCreationForm -from commcare_connect.opportunity.tests.factories import ApplicationFactory +from commcare_connect.opportunity.forms import OpportunityChangeForm, OpportunityCreationForm +from commcare_connect.opportunity.tests.factories import ApplicationFactory, CommCareAppFactory, OpportunityFactory class TestOpportunityCreationForm: @@ -111,3 +111,197 @@ def test_save(self, user, organization): ) form.is_valid() form.save() + + +@pytest.mark.django_db +class TestOpportunityChangeForm: + @pytest.fixture(autouse=True) + def setup_credentials_mock(self, monkeypatch): + self.mock_credentials = [ + type("Credential", (), {"slug": "cert1", "name": "Work for test"}), + type("Credential", (), {"slug": "cert2", "name": "Work for test"}), + ] + monkeypatch.setattr( + "commcare_connect.connect_id_client.fetch_credentials", lambda org_slug: self.mock_credentials + ) + + @pytest.fixture + def valid_opportunity(self, organization): + return OpportunityFactory( + organization=organization, + active=True, + learn_app=CommCareAppFactory(cc_app_id="test_learn_app"), + deliver_app=CommCareAppFactory(cc_app_id="test_deliver_app"), + name="Test Opportunity", + description="Test Description", + short_description="Short Description", + currency="USD", + is_test=False, + end_date=datetime.date.today() + datetime.timedelta(days=30), + ) + + @pytest.fixture + def base_form_data(self, valid_opportunity): + return { + "name": "Updated Opportunity", + "description": "Updated Description", + "short_description": "Updated Short Description", + "active": True, + "currency": "EUR", + "is_test": False, + "delivery_type": valid_opportunity.delivery_type.id, + "additional_users": 5, + "end_date": (datetime.date.today() + datetime.timedelta(days=60)).isoformat(), + "users": "+1234567890\n+9876543210", + "filter_country": "US", + "filter_credential": "cert1", + } + + def test_form_initialization(self, valid_opportunity, organization): + form = OpportunityChangeForm(instance=valid_opportunity, org_slug=organization.slug) + expected_fields = { + "name", + "description", + "short_description", + "active", + "currency", + "is_test", + "delivery_type", + "additional_users", + "end_date", + "users", + "filter_country", + "filter_credential", + } + assert all(field in form.fields for field in expected_fields) + + expected_initial = { + "name": valid_opportunity.name, + "description": valid_opportunity.description, + "short_description": valid_opportunity.short_description, + "active": valid_opportunity.active, + "currency": valid_opportunity.currency, + "is_test": valid_opportunity.is_test, + "delivery_type": valid_opportunity.delivery_type.id, + "end_date": valid_opportunity.end_date.isoformat(), + "filter_country": [""], + "filter_credential": [""], + } + assert all(form.initial.get(key) == value for key, value in expected_initial.items()) + + @pytest.mark.parametrize( + "field", + [ + "name", + "description", + "short_description", + "currency", + ], + ) + def test_required_fields(self, valid_opportunity, organization, field, base_form_data): + data = base_form_data.copy() + data[field] = "" + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + assert not form.is_valid() + assert field in form.errors + + @pytest.mark.parametrize( + "test_data", + [ + pytest.param( + { + "field": "additional_users", + "value": "invalid", + "error_expected": True, + "error_message": "Enter a whole number.", + }, + id="invalid_additional_users", + ), + pytest.param( + { + "field": "end_date", + "value": "invalid-date", + "error_expected": True, + "error_message": "Enter a valid date.", + }, + id="invalid_end_date", + ), + pytest.param( + { + "field": "users", + "value": " +1234567890 \n +9876543210 ", + "error_expected": False, + "expected_clean": ["+1234567890", "+9876543210"], + }, + id="valid_users_with_whitespace", + ), + ], + ) + def test_field_validation(self, valid_opportunity, organization, base_form_data, test_data): + data = base_form_data.copy() + data[test_data["field"]] = test_data["value"] + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + if test_data["error_expected"]: + assert not form.is_valid() + assert test_data["error_message"] in str(form.errors[test_data["field"]]) + else: + assert form.is_valid() + if "expected_clean" in test_data: + assert form.cleaned_data[test_data["field"]] == test_data["expected_clean"] + + @pytest.mark.parametrize( + "app_scenario", + [ + pytest.param( + { + "active_app_ids": ("unique_app1", "unique_app2"), + "new_app_ids": ("different_app1", "different_app2"), + "expected_valid": True, + }, + id="unique_apps", + ), + pytest.param( + { + "active_app_ids": ("shared_app1", "shared_app2"), + "new_app_ids": ("shared_app1", "shared_app2"), + "expected_valid": False, + }, + id="reused_apps", + ), + ], + ) + def test_app_reuse_validation(self, organization, base_form_data, app_scenario): + OpportunityFactory( + organization=organization, + active=True, + learn_app=CommCareAppFactory(cc_app_id=app_scenario["active_app_ids"][0]), + deliver_app=CommCareAppFactory(cc_app_id=app_scenario["active_app_ids"][1]), + ) + + inactive_opp = OpportunityFactory( + organization=organization, + active=False, + learn_app=CommCareAppFactory(cc_app_id=app_scenario["new_app_ids"][0]), + deliver_app=CommCareAppFactory(cc_app_id=app_scenario["new_app_ids"][1]), + ) + + form = OpportunityChangeForm(data=base_form_data, instance=inactive_opp, org_slug=organization.slug) + + assert form.is_valid() == app_scenario["expected_valid"] + if not app_scenario["expected_valid"]: + assert "Cannot reactivate opportunity with reused applications" in str(form.errors["active"]) + + @pytest.mark.parametrize( + "data_updates,expected_valid", + [ + ({"currency": "USD", "additional_users": 5}, True), + ({"currency": "EUR", "additional_users": 10}, True), + ({"currency": "INVALID", "additional_users": 5}, False), + ({"currency": "USD", "additional_users": -5}, True), + ], + ) + def test_valid_combinations(self, valid_opportunity, organization, base_form_data, data_updates, expected_valid): + data = base_form_data.copy() + data.update(data_updates) + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + assert form.is_valid() == expected_valid diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index e9083536..a53c0050 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -212,6 +212,11 @@ class OpportunityEdit(OrganizationUserMemberRoleMixin, UpdateView): def get_success_url(self): return reverse("opportunity:detail", args=(self.request.org.slug, self.object.id)) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org_slug"] = self.request.org.slug + return kwargs + def form_valid(self, form): opportunity = form.instance opportunity.modified_by = self.request.user.email @@ -1079,7 +1084,7 @@ def import_catchment_area(request, org_slug=None, pk=None): @org_member_required def opportunity_user_invite(request, org_slug=None, pk=None): opportunity = get_object_or_404(Opportunity, organization=request.org, id=pk) - form = OpportunityUserInviteForm(data=request.POST or None) + form = OpportunityUserInviteForm(data=request.POST or None, org_slug=request.org.slug) if form.is_valid(): users = form.cleaned_data["users"] filter_country = form.cleaned_data["filter_country"] diff --git a/commcare_connect/organization/views.py b/commcare_connect/organization/views.py index f2e5da30..fce5002c 100644 --- a/commcare_connect/organization/views.py +++ b/commcare_connect/organization/views.py @@ -47,7 +47,7 @@ def organization_home(request, org_slug): if not form: form = OrganizationChangeForm(instance=org) - credentials = connect_id_client.fetch_credentials() + credentials = connect_id_client.fetch_credentials(org_slug=request.org.slug) credential_name = f"Worked for {org.name}" if not any(c.name == credential_name for c in credentials): credentials.append(Credential(name=credential_name, slug=slugify(credential_name))) @@ -96,7 +96,7 @@ def accept_invite(request, org_slug, invite_id): @require_POST def add_credential_view(request, org_slug): org = get_object_or_404(Organization, slug=org_slug) - credentials = connect_id_client.fetch_credentials() + credentials = connect_id_client.fetch_credentials(org_slug=request.org.slug) credential_name = f"Worked for {org.name}" if not any(c.name == credential_name for c in credentials): credentials.append(Credential(name=credential_name, slug=slugify(credential_name))) diff --git a/commcare_connect/reports/tests/test_reports.py b/commcare_connect/reports/tests/test_reports.py index f85ae168..441090f7 100644 --- a/commcare_connect/reports/tests/test_reports.py +++ b/commcare_connect/reports/tests/test_reports.py @@ -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] @@ -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"]) 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 c91e8789..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 @@ -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") @@ -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", @@ -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") @@ -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) @@ -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() + # 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 new file mode 100644 index 00000000..53ca2b65 --- /dev/null +++ b/commcare_connect/static/js/dashboard.js @@ -0,0 +1,325 @@ +console.log('dashboard.js loaded'); + +// colors to use for the categories +// soft green, yellow, red +const visitColors = ['#4ade80', '#fbbf24', '#f87171']; + +// after the GeoJSON data is loaded, update markers on the screen on every frame +// objects for caching and keeping track of HTML marker objects (for performance) +const markers = {}; +let markersOnScreen = {}; + +function updateMarkers(map) { + const newMarkers = {}; + const features = map.querySourceFeatures('visits'); + + // for every cluster on the screen, create an HTML marker for it (if we didn't yet), + // and add it to the map if it's not there already + for (const feature of features) { + const coords = feature.geometry.coordinates; + const props = feature.properties; + if (!props.cluster) continue; + const id = props.cluster_id; + + let marker = markers[id]; + if (!marker) { + const el = createDonutChart( + { + ...props, + cluster_id: id, // Make sure cluster_id is passed + coordinates: coords, // Pass the coordinates + }, + map, + ); + marker = markers[id] = new mapboxgl.Marker({ + element: el, + }).setLngLat(coords); + } + newMarkers[id] = marker; + + if (!markersOnScreen[id]) marker.addTo(map); + } + // for every marker we've added previously, remove those that are no longer visible + for (const id in markersOnScreen) { + if (!newMarkers[id]) markersOnScreen[id].remove(); + } + markersOnScreen = newMarkers; +} + +// Function to create a donut chart +function createDonutChart(props, map) { + const offsets = []; + const counts = [props.approved, props.pending, props.rejected]; + let total = 0; + for (const count of counts) { + offsets.push(total); + total += count; + } + const fontSize = + total >= 1000 ? 22 : total >= 100 ? 20 : total >= 10 ? 18 : 16; + const r = total >= 1000 ? 50 : total >= 100 ? 32 : total >= 10 ? 24 : 18; + const r0 = Math.round(r * 0.8); + const w = r * 2; + + let html = `