diff --git a/commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py b/commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py new file mode 100644 index 00000000..b5cebef6 --- /dev/null +++ b/commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2024-10-15 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("opportunity", "0060_completedwork_payment_date"), + ] + + 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 17f68978..ea0c25c2 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -235,6 +235,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..78802a34 --- /dev/null +++ b/commcare_connect/program/helpers.py @@ -0,0 +1,73 @@ +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 + + +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): + excluded_status = [ + VisitValidationStatus.over_limit, + VisitValidationStatus.trial, + ] + + filter_for_valid__visit_date = ~Q(opportunityaccess__uservisit__status__in=excluded_status) + + earliest_visits = ( + UserVisit.objects.filter( + opportunity_access=OuterRef("opportunityaccess"), + ) + .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, + ), + ) + ) + + return managed_opportunities diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index 1f3c1ea4..e9cf0296 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -4,7 +4,7 @@ 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 +163,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 +203,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 +233,34 @@ def get_manage_buttons_html(buttons, request): request=request, ) return mark_safe(html) + + +class FunnelPerformanceTable(tables.Table): + organization = tables.Column() + 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=_("Percentage 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", + "start_date", + "workers_invited", + "workers_passing_assessment", + "workers_starting_delivery", + "percentage_conversion", + "average_time_to_convert", + ) + orderable = False + + 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" diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py new file mode 100644 index 00000000..de67f02c --- /dev/null +++ b/commcare_connect/program/tests/test_helpers.py @@ -0,0 +1,99 @@ +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, OpportunityAccessFactory, UserVisitFactory +from commcare_connect.program.helpers import get_annotated_managed_opportunity +from commcare_connect.program.tests.factories import ManagedOpportunityFactory, ProgramFactory +from commcare_connect.users.tests.factories import OrganizationFactory, UserFactory + + +class TestGetAnnotatedManagedOpportunity: + @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 + + @pytest.mark.parametrize( + "scenario, visit_statuses, passing_assessments, expected_invited," + " expected_passing, expected_delivery, expected_conversion", + [ + ( + "basic_scenario", + [VisitValidationStatus.pending, VisitValidationStatus.pending, VisitValidationStatus.trial], + [True, True, True], + 3, + 3, + 2, + 66.67, + ), + ("empty_scenario", [], [], 0, 0, 0, 0.0), + ("multiple_visits_scenario", [VisitValidationStatus.pending], [True], 1, 1, 1, 100.0), + ( + "excluded_statuses", + [VisitValidationStatus.over_limit, VisitValidationStatus.trial], + [True, True], + 2, + 2, + 0, + 0.0, + ), + ( + "failed_assessments", + [VisitValidationStatus.pending, VisitValidationStatus.pending], + [False, True], + 2, + 1, + 2, + 100.0, + ), + ], + ) + def test_scenarios( + self, + scenario, + visit_statuses, + passing_assessments, + expected_invited, + expected_passing, + expected_delivery, + expected_conversion, + ): + 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, f"Failed in {scenario}" + assert annotated_opp.workers_passing_assessment == expected_passing, f"Failed in {scenario}" + assert annotated_opp.workers_starting_delivery == expected_delivery, f"Failed in {scenario}" + assert pytest.approx(annotated_opp.percentage_conversion, 0.01) == expected_conversion, f"Failed in {scenario}" diff --git a/commcare_connect/program/urls.py b/commcare_connect/program/urls.py index 53209232..339771bb 100644 --- a/commcare_connect/program/urls.py +++ b/commcare_connect/program/urls.py @@ -1,12 +1,14 @@ from django.urls import path from commcare_connect.program.views import ( + FunnelPerformanceTableView, ManagedOpportunityInit, ManagedOpportunityList, ProgramApplicationList, ProgramCreateOrUpdate, ProgramList, apply_or_decline_application, + dashboard, invite_organization, manage_application, ) @@ -26,4 +28,6 @@ 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"), ] diff --git a/commcare_connect/program/views.py b/commcare_connect/program/views.py index 79a80fc0..c7b21f94 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,9 @@ 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 from commcare_connect.program.models import ManagedOpportunity, Program, ProgramApplication, ProgramApplicationStatus -from commcare_connect.program.tables import ProgramApplicationTable, ProgramTable +from commcare_connect.program.tables import FunnelPerformanceTable, ProgramApplicationTable, ProgramTable class ProgramManagerMixin(LoginRequiredMixin, UserPassesTestMixin): @@ -236,3 +237,24 @@ 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) diff --git a/commcare_connect/templates/program/dashboard.html b/commcare_connect/templates/program/dashboard.html new file mode 100644 index 00000000..c5c7d895 --- /dev/null +++ b/commcare_connect/templates/program/dashboard.html @@ -0,0 +1,41 @@ +{% 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 %} +
+
+
+
+
+{% endblock content %}