diff --git a/commcare_connect/opportunity/migrations/0062_opportunityaccess_invited_date.py b/commcare_connect/opportunity/migrations/0062_opportunityaccess_invited_date.py new file mode 100644 index 00000000..12ef7484 --- /dev/null +++ b/commcare_connect/opportunity/migrations/0062_opportunityaccess_invited_date.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2024-11-28 07:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("opportunity", "0061_opportunityclaimlimit_end_date_paymentunit_end_date_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="opportunityaccess", + name="invited_date", + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index 5f8c2d45..6214d3fc 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -244,6 +244,7 @@ class OpportunityAccess(models.Model): suspended = models.BooleanField(default=False) suspension_date = models.DateTimeField(null=True, blank=True) suspension_reason = models.CharField(max_length=300, null=True, blank=True) + invited_date = models.DateTimeField(auto_now_add=True, editable=False, null=True) class Meta: indexes = [models.Index(fields=["invite_id"])] diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py new file mode 100644 index 00000000..0e83471e --- /dev/null +++ b/commcare_connect/program/helpers.py @@ -0,0 +1,125 @@ +from django.db.models import ( + Avg, + Case, + Count, + DurationField, + ExpressionWrapper, + F, + FloatField, + OuterRef, + Q, + Subquery, + Value, + When, +) +from django.db.models.functions import Cast, Round + +from commcare_connect.opportunity.models import UserVisit, VisitValidationStatus +from commcare_connect.program.models import ManagedOpportunity, Program + +EXCLUDED_STATUS = [ + VisitValidationStatus.over_limit, + VisitValidationStatus.trial, +] + +FILTER_FOR_VALID_VISIT_DATE = ~Q(opportunityaccess__uservisit__status__in=EXCLUDED_STATUS) + + +def calculate_safe_percentage(numerator, denominator): + return Case( + When(**{denominator: 0}, then=Value(0)), # Handle division by zero + default=Round(Cast(F(numerator), FloatField()) / Cast(F(denominator), FloatField()) * 100, 2), + output_field=FloatField(), + ) + + +def get_annotated_managed_opportunity(program: Program): + earliest_visits = ( + UserVisit.objects.filter( + opportunity_access=OuterRef("opportunityaccess"), + user=OuterRef("opportunityaccess__uservisit__user"), + ) + .exclude(status__in=EXCLUDED_STATUS) + .order_by("visit_date") + .values("visit_date")[:1] + ) + + managed_opportunities = ( + ManagedOpportunity.objects.filter(program=program) + .order_by("start_date") + .annotate( + workers_invited=Count("opportunityaccess", distinct=True), + workers_passing_assessment=Count( + "opportunityaccess__assessment", + filter=Q( + opportunityaccess__assessment__passed=True, + ), + distinct=True, + ), + workers_starting_delivery=Count( + "opportunityaccess__uservisit__user", + filter=FILTER_FOR_VALID_VISIT_DATE, + distinct=True, + ), + percentage_conversion=calculate_safe_percentage("workers_starting_delivery", "workers_invited"), + average_time_to_convert=Avg( + ExpressionWrapper( + Subquery(earliest_visits) - F("opportunityaccess__invited_date"), output_field=DurationField() + ), + filter=FILTER_FOR_VALID_VISIT_DATE, + distinct=True, + ), + ) + ) + return managed_opportunities + + +def get_delivery_performance_report(program: Program, start_date, end_date): + date_filter = FILTER_FOR_VALID_VISIT_DATE + + if start_date: + date_filter &= Q(opportunityaccess__uservisit__visit_date__gte=start_date) + + if end_date: + date_filter &= Q(opportunityaccess__uservisit__visit_date__lte=end_date) + + flagged_visits_filter = ( + Q(opportunityaccess__uservisit__flagged=True) + & date_filter + & Q(opportunityaccess__uservisit__completed_work__isnull=False) + ) + + managed_opportunities = ( + ManagedOpportunity.objects.filter(program=program) + .order_by("start_date") + .annotate( + total_workers_starting_delivery=Count( + "opportunityaccess__uservisit__user", + filter=FILTER_FOR_VALID_VISIT_DATE, + distinct=True, + ), + active_workers=Count( + "opportunityaccess__uservisit__user", + filter=date_filter, + distinct=True, + ), + total_payment_units_with_flags=Count( + "opportunityaccess__uservisit", distinct=True, filter=flagged_visits_filter + ), + total_payment_since_start_date=Count( + "opportunityaccess__uservisit", + distinct=True, + filter=date_filter & Q(opportunityaccess__uservisit__completed_work__isnull=False), + ), + deliveries_per_day_per_worker=Case( + When(active_workers=0, then=Value(0)), + default=Round(F("total_payment_since_start_date") / F("active_workers"), 2), + output_field=FloatField(), + ), + records_flagged_percentage=calculate_safe_percentage( + "total_payment_units_with_flags", "total_payment_since_start_date" + ), + ) + ) + + return managed_opportunities diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index 1f3c1ea4..b899c19b 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -1,10 +1,11 @@ import django_tables2 as tables from django.template.loader import render_to_string from django.urls import reverse +from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from .models import Program, ProgramApplication, ProgramApplicationStatus +from .models import ManagedOpportunity, Program, ProgramApplication, ProgramApplicationStatus TABLE_TEMPLATE = "django_tables2/bootstrap5.html" RESPONSIVE_TABLE_AND_LIGHT_HEADER = { @@ -163,6 +164,14 @@ def render_manage(self, record): "pk": record.id, }, ) + + dashboard_url = reverse( + "program:dashboard", + kwargs={ + "org_slug": self.context["request"].org.slug, + "pk": record.id, + }, + ) application_url = reverse( "program:applications", kwargs={ @@ -195,6 +204,7 @@ def render_manage(self, record): "color": "success", "icon": "bi bi-people-fill", }, + {"post": False, "url": dashboard_url, "text": "Dashboard", "color": "info", "icon": "bi bi-graph-up"}, ] return get_manage_buttons_html(buttons, self.context["request"]) @@ -224,3 +234,80 @@ def get_manage_buttons_html(buttons, request): request=request, ) return mark_safe(html) + + +class FunnelPerformanceTable(tables.Table): + organization = tables.Column() + opportunity = tables.Column(accessor="name", verbose_name="Opportunity") + start_date = tables.DateColumn() + workers_invited = tables.Column(verbose_name=_("Workers Invited")) + workers_passing_assessment = tables.Column(verbose_name=_("Workers Passing Assessment")) + workers_starting_delivery = tables.Column(verbose_name=_("Workers Starting Delivery")) + percentage_conversion = tables.Column(verbose_name=_("% Conversion")) + average_time_to_convert = tables.Column(verbose_name=_("Average Time To convert")) + + class Meta: + model = ManagedOpportunity + empty_text = "No data available yet." + fields = ( + "organization", + "opportunity", + "start_date", + "workers_invited", + "workers_passing_assessment", + "workers_starting_delivery", + "percentage_conversion", + "average_time_to_convert", + ) + orderable = False + + def render_opportunity(self, value, record): + url = reverse( + "opportunity:detail", + kwargs={ + "org_slug": record.organization.slug, + "pk": record.id, + }, + ) + return format_html('{}', url, value) + + def render_average_time_to_convert(self, record): + if not record.average_time_to_convert: + return "---" + total_seconds = record.average_time_to_convert.total_seconds() + hours = total_seconds / 3600 + return f"{round(hours, 2)}hr" + + +class DeliveryPerformanceTable(tables.Table): + organization = tables.Column() + opportunity = tables.Column(accessor="name", verbose_name="Opportunity") + start_date = tables.DateColumn() + total_workers_starting_delivery = tables.Column(verbose_name=_("Workers Starting Delivery")) + active_workers = tables.Column(verbose_name=_("Active Workers")) + deliveries_per_day_per_worker = tables.Column(verbose_name=_("Deliveries per Day per Worker")) + records_flagged_percentage = tables.Column(verbose_name=_("% Records flagged")) + + class Meta: + model = ManagedOpportunity + empty_text = "No data available yet." + fields = ( + "organization", + "opportunity", + "start_date", + "total_workers_starting_delivery", + "active_workers", + "deliveries_per_day_per_worker", + "records_flagged_percentage", + ) + orderable = False + + def render_opportunity(self, value, record): + url = reverse( + "opportunity:detail", + kwargs={ + "org_slug": record.organization.slug, + "pk": record.id, + }, + ) + return format_html('{}', url, value) diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py new file mode 100644 index 00000000..cddfb6a2 --- /dev/null +++ b/commcare_connect/program/tests/test_helpers.py @@ -0,0 +1,249 @@ +from datetime import timedelta + +import pytest +from django_celery_beat.utils import now + +from commcare_connect.opportunity.models import VisitValidationStatus +from commcare_connect.opportunity.tests.factories import ( + AssessmentFactory, + CompletedWorkFactory, + OpportunityAccessFactory, + UserVisitFactory, +) +from commcare_connect.program.helpers import get_annotated_managed_opportunity, get_delivery_performance_report +from commcare_connect.program.tests.factories import ManagedOpportunityFactory, ProgramFactory +from commcare_connect.users.tests.factories import OrganizationFactory, UserFactory + + +@pytest.mark.django_db +class BaseManagedOpportunityTest: + @pytest.fixture(autouse=True) + def setup(self, db): + self.program = ProgramFactory.create() + self.nm_org = OrganizationFactory.create() + self.opp = ManagedOpportunityFactory.create(program=self.program, organization=self.nm_org) + + def create_user_with_access(self, visit_status=VisitValidationStatus.pending, passed_assessment=True): + user = UserFactory.create() + access = OpportunityAccessFactory.create(opportunity=self.opp, user=user, invited_date=now()) + AssessmentFactory.create(opportunity=self.opp, user=user, opportunity_access=access, passed=passed_assessment) + UserVisitFactory.create( + user=user, + opportunity=self.opp, + status=visit_status, + opportunity_access=access, + visit_date=now() + timedelta(days=1), + ) + return user + + def create_user_with_visit(self, visit_status, visit_date, flagged=False, create_completed_work=True): + user = UserFactory.create() + access = OpportunityAccessFactory.create(opportunity=self.opp, user=user, invited_date=now()) + visit = UserVisitFactory.create( + user=user, + opportunity=self.opp, + status=visit_status, + opportunity_access=access, + visit_date=visit_date, + flagged=flagged, + ) + if create_completed_work: + work = CompletedWorkFactory.create(opportunity_access=access) + visit.completed_work = work + visit.save() + return user + + +class TestGetAnnotatedManagedOpportunity(BaseManagedOpportunityTest): + @pytest.mark.parametrize( + "scenario, visit_statuses, passing_assessments, expected_invited," + " expected_passing, expected_delivery, expected_conversion, expected_avg_time_to_convert", + [ + ( + "basic_scenario", + [VisitValidationStatus.pending, VisitValidationStatus.pending, VisitValidationStatus.trial], + [True, True, True], + 3, + 3, + 2, + 66.67, + timedelta(days=1), + ), + ("empty_scenario", [], [], 0, 0, 0, 0.0, None), + ("multiple_visits_scenario", [VisitValidationStatus.pending], [True], 1, 1, 1, 100.0, timedelta(days=1)), + ( + "excluded_statuses", + [VisitValidationStatus.over_limit, VisitValidationStatus.trial], + [True, True], + 2, + 2, + 0, + 0.0, + None, + ), + ( + "failed_assessments", + [VisitValidationStatus.pending, VisitValidationStatus.pending], + [False, True], + 2, + 1, + 2, + 100.0, + timedelta(days=1), + ), + ], + ) + def test_scenarios( + self, + scenario, + visit_statuses, + passing_assessments, + expected_invited, + expected_passing, + expected_delivery, + expected_conversion, + expected_avg_time_to_convert, + ): + for i, visit_status in enumerate(visit_statuses): + user = self.create_user_with_access(visit_status=visit_status, passed_assessment=passing_assessments[i]) + + # For the "multiple_visits_scenario", create additional visits for the same user + if scenario == "multiple_visits_scenario": + access = user.opportunityaccess_set.first() + UserVisitFactory.create_batch( + 2, + user=user, + opportunity=self.opp, + status=VisitValidationStatus.pending, + opportunity_access=access, + visit_date=now() + timedelta(days=2), + ) + opps = get_annotated_managed_opportunity(self.program) + assert len(opps) == 1 + annotated_opp = opps[0] + assert annotated_opp.workers_invited == expected_invited + assert annotated_opp.workers_passing_assessment == expected_passing + assert annotated_opp.workers_starting_delivery == expected_delivery + assert pytest.approx(annotated_opp.percentage_conversion, 0.01) == expected_conversion + + if expected_avg_time_to_convert: + diff = abs(annotated_opp.average_time_to_convert - expected_avg_time_to_convert) + assert diff < timedelta(minutes=1) + else: + assert annotated_opp.average_time_to_convert is None + + +@pytest.mark.django_db +class TestDeliveryPerformanceReport(BaseManagedOpportunityTest): + start_date = now() - timedelta(10) + end_date = now() + timedelta(10) + + @pytest.mark.parametrize( + "scenario, visit_statuses, visit_date, flagged_statuses, expected_active_workers, " + "expected_total_workers, expected_records_flagged_percentage," + "total_payment_units_with_flags,total_payment_since_start_date, delivery_per_day_per_worker", + [ + ( + "basic_scenario", + [VisitValidationStatus.pending] * 2 + [VisitValidationStatus.approved] * 3, + [now()] * 5, + [True] * 3 + [False] * 2, + 5, + 5, + 60.0, + 3, + 5, + 1.0, + ), + ( + "date_range_scenario", + [VisitValidationStatus.pending] * 4, + [ + now() - timedelta(8), + now() + timedelta(11), + now() - timedelta(9), + now() + timedelta(11), + ], + [False] * 4, + 2, + 4, + 0.0, + 0, + 2, + 1.0, + ), + ( + "flagged_visits_scenario", + [VisitValidationStatus.pending, VisitValidationStatus.pending], + [now()] * 2, + [False, True], + 2, + 2, + 50.0, + 1, + 2, + 1.0, + ), + ( + "no_active_workers_scenario", + [VisitValidationStatus.over_limit, VisitValidationStatus.trial], + [now(), now()], + [False, False], + 0, + 0, + 0.0, + 0, + 0, + 0.0, + ), + ( + "mixed_statuses_scenario", + [ + VisitValidationStatus.pending, + VisitValidationStatus.approved, + VisitValidationStatus.rejected, + VisitValidationStatus.over_limit, + ], + [now()] * 4, + [True] * 4, + 3, + 3, + 100, + 3, + 3, + 1.0, + ), + ], + ) + def test_delivery_performance_report_scenarios( + self, + scenario, + visit_statuses, + visit_date, + flagged_statuses, + expected_active_workers, + expected_total_workers, + expected_records_flagged_percentage, + total_payment_units_with_flags, + total_payment_since_start_date, + delivery_per_day_per_worker, + ): + for i, visit_status in enumerate(visit_statuses): + self.create_user_with_visit( + visit_status=visit_status, visit_date=visit_date[i], flagged=flagged_statuses[i] + ) + + start_date = end_date = None + if scenario == "date_range_scenario": + start_date = now() - timedelta(10) + end_date = now() + timedelta(10) + + opps = get_delivery_performance_report(self.program, start_date, end_date) + + assert len(opps) == 1 + assert opps[0].active_workers == expected_active_workers + assert opps[0].total_workers_starting_delivery == expected_total_workers + assert opps[0].records_flagged_percentage == expected_records_flagged_percentage + assert opps[0].total_payment_units_with_flags == total_payment_units_with_flags + assert opps[0].total_payment_since_start_date == total_payment_since_start_date + assert opps[0].deliveries_per_day_per_worker == delivery_per_day_per_worker diff --git a/commcare_connect/program/urls.py b/commcare_connect/program/urls.py index 53209232..7d9cd6c1 100644 --- a/commcare_connect/program/urls.py +++ b/commcare_connect/program/urls.py @@ -1,12 +1,15 @@ from django.urls import path from commcare_connect.program.views import ( + DeliveryPerformanceTableView, + FunnelPerformanceTableView, ManagedOpportunityInit, ManagedOpportunityList, ProgramApplicationList, ProgramCreateOrUpdate, ProgramList, apply_or_decline_application, + dashboard, invite_organization, manage_application, ) @@ -26,4 +29,11 @@ view=apply_or_decline_application, name="apply_or_decline_application", ), + path("/dashboard", dashboard, name="dashboard"), + path("/funnel_performance_table", FunnelPerformanceTableView.as_view(), name="funnel_performance_table"), + path( + "/delivery_performance_table", + DeliveryPerformanceTableView.as_view(), + name="delivery_performance_table", + ), ] diff --git a/commcare_connect/program/views.py b/commcare_connect/program/views.py index 79a80fc0..ff60a8b5 100644 --- a/commcare_connect/program/views.py +++ b/commcare_connect/program/views.py @@ -1,6 +1,6 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.decorators.http import require_POST from django.views.generic import ListView, UpdateView @@ -10,8 +10,14 @@ from commcare_connect.organization.decorators import org_admin_required, org_program_manager_required from commcare_connect.organization.models import Organization from commcare_connect.program.forms import ManagedOpportunityInitForm, ProgramForm +from commcare_connect.program.helpers import get_annotated_managed_opportunity, get_delivery_performance_report from commcare_connect.program.models import ManagedOpportunity, Program, ProgramApplication, ProgramApplicationStatus -from commcare_connect.program.tables import ProgramApplicationTable, ProgramTable +from commcare_connect.program.tables import ( + DeliveryPerformanceTable, + FunnelPerformanceTable, + ProgramApplicationTable, + ProgramTable, +) class ProgramManagerMixin(LoginRequiredMixin, UserPassesTestMixin): @@ -236,3 +242,38 @@ def apply_or_decline_application(request, application_id, action, org_slug=None, messages.success(request, action_map[action]["message"]) return redirect(redirect_url) + + +@org_program_manager_required +def dashboard(request, **kwargs): + program = get_object_or_404(Program, id=kwargs.get("pk"), organization=request.org) + context = { + "program": program, + } + return render(request, "program/dashboard.html", context) + + +class FunnelPerformanceTableView(ProgramManagerMixin, SingleTableView): + model = ManagedOpportunity + paginate_by = 10 + table_class = FunnelPerformanceTable + template_name = "tables/single_table.html" + + def get_queryset(self): + program_id = self.kwargs["pk"] + program = get_object_or_404(Program, id=program_id) + return get_annotated_managed_opportunity(program) + + +class DeliveryPerformanceTableView(ProgramManagerMixin, SingleTableView): + model = ManagedOpportunity + paginate_by = 10 + table_class = DeliveryPerformanceTable + template_name = "tables/single_table.html" + + def get_queryset(self): + program_id = self.kwargs["pk"] + program = get_object_or_404(Program, id=program_id) + start_date = self.request.GET.get("start_date") or None + end_date = self.request.GET.get("end_date") or None + return get_delivery_performance_report(program, start_date, end_date) diff --git a/commcare_connect/templates/program/dashboard.html b/commcare_connect/templates/program/dashboard.html new file mode 100644 index 00000000..57f3b461 --- /dev/null +++ b/commcare_connect/templates/program/dashboard.html @@ -0,0 +1,78 @@ +{% extends "program/base.html" %} +{% load static %} +{% load i18n %} +{% load django_tables2 %} +{% block title %}{{ request.org }} - Programs{% endblock %} + +{% block breadcrumbs_inner %} +{{ block.super }} + + +{% endblock %} +{% block content %} +
+
+

{% trans "Dashboard" %}

+
+
+ +
+
+
+ {% include "tables/table_placeholder.html" with num_cols=6 %} +
+
+ +
+
+
+ + +
+
+ + +
+ +
+
+
+ {% include "tables/table_placeholder.html" with num_cols=7 %} +
+
+
+
+
+{% endblock content %}