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

Network manager funnel performance #391

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.5 on 2024-09-18 10:16

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("opportunity", "0058_paymentinvoice_payment_invoice"),
]

operations = [
migrations.AddField(
model_name="opportunityaccess",
name="invited_date",
field=models.DateTimeField(auto_now_add=True, null=True),
),
]
1 change: 1 addition & 0 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])]
Expand Down
56 changes: 56 additions & 0 deletions commcare_connect/program/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from django.db.models import Avg, Count, DurationField, ExpressionWrapper, F, OuterRef, Q, Subquery

from commcare_connect.opportunity.models import UserVisit, VisitValidationStatus
from commcare_connect.program.models import ManagedOpportunity, Program


def get_annotated_managed_opportunity(program: Program):
filter_for_valid__visit_date = ~Q(
opportunityaccess__uservisit__status__in=[
VisitValidationStatus.over_limit,
VisitValidationStatus.trial,
]
)

earliest_visits = (
UserVisit.objects.filter(
opportunity_access=OuterRef("opportunityaccess"),
)
.exclude(status__in=[VisitValidationStatus.over_limit, VisitValidationStatus.trial])
.order_by("visit_date")
.values("visit_date")[:1]
)

managed_opportunities = (
ManagedOpportunity.objects.filter(program=program)
.order_by("start_date")
.annotate(
workers_invited=Count("opportunityaccess"),
workers_passing_assessment=Count(
"opportunityaccess__assessment",
filter=Q(
opportunityaccess__assessment__passed=True,
opportunityaccess__assessment__opportunity=F("opportunityaccess__opportunity"),
),
),
workers_starting_delivery=Count(
"opportunityaccess__uservisit__user",
filter=filter_for_valid__visit_date,
distinct=True,
),
percentage_conversion=F("workers_starting_delivery") / F("workers_invited") * 100,
average_time_to_convert=Avg(
ExpressionWrapper(
Subquery(earliest_visits) - F("opportunityaccess__invited_date"), output_field=DurationField()
),
filter=filter_for_valid__visit_date,
),
)
.prefetch_related(
"opportunityaccess_set",
"opportunityaccess_set__uservisit_set",
"opportunityaccess_set__assessment_set",
)
)

return managed_opportunities
40 changes: 39 additions & 1 deletion commcare_connect/program/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -224,3 +233,32 @@ 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):
total_seconds = record.average_time_to_convert.total_seconds()
hours = total_seconds / 3600
return f"{round(hours, 2)}hr"
34 changes: 34 additions & 0 deletions commcare_connect/program/tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from datetime import timedelta

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.organization.models import Organization
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


def test_get_annotated_managed_opportunity(program_manager_org: Organization):
program = ProgramFactory.create(organization=program_manager_org)
nm_org = OrganizationFactory.create()
opp = ManagedOpportunityFactory.create(program=program, organization=nm_org)
users = UserFactory.create_batch(5)
for index, user in enumerate(users):
access = OpportunityAccessFactory.create(opportunity=opp, user=user, invited_date=now())
AssessmentFactory.create(opportunity=opp, user=user, opportunity_access=access)
visit_status = VisitValidationStatus.pending if index < 3 else VisitValidationStatus.trial
UserVisitFactory.create(
user=user,
opportunity=opp,
status=visit_status,
opportunity_access=access,
visit_date=now() + timedelta(1),
)

opps = get_annotated_managed_opportunity(program)
for opp in opps:
assert nm_org.slug == opp.organization.slug
assert opp.workers_passing_assessment == 5
assert opp.workers_starting_delivery == 3
4 changes: 4 additions & 0 deletions commcare_connect/program/urls.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -26,4 +28,6 @@
view=apply_or_decline_application,
name="apply_or_decline_application",
),
path("<int:pk>/dashboard", dashboard, name="dashboard"),
path("<int:pk>/funnel_performance_table", FunnelPerformanceTableView.as_view(), name="funnel_performance_table"),
]
26 changes: 24 additions & 2 deletions commcare_connect/program/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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)
41 changes: 41 additions & 0 deletions commcare_connect/templates/program/dashboard.html
Original file line number Diff line number Diff line change
@@ -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 }}
<li class="breadcrumb-item" aria-current="page">{{ program.name }}</li>
<li class="breadcrumb-item active">{% translate "Dashboard" %}</li>
{% endblock %}
{% block content %}
<div class="container bg-white shadow pb-2">
<div class="mt-5 py-3">
<h1> {% trans "Dashboard" %}</h1>
</div>
<section class="mt-4 shadow mb-5">
<ul class="nav nav-tabs fw-bold bg-primary-subtle" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="funnel-performance-tab" data-bs-toggle="tab"
data-bs-target="#funnel-performance-tab-pane"
type="button" role="tab" aria-controls="funnel-performance" aria-selected="true">
<i class="bi bi-filter-square"></i> {% trans "Funnel Performance" %}
</button>
</li>

</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="funnel-performance-tab-pane" role="tabpanel"
aria-labelledby="funnel-performance-tab"
tabindex="0" hx-on::after-request="refreshTooltips()">
<div class="pb-4" id="funnel-performance-table-containers"
hx-get="{% url 'program:funnel_performance_table' request.org.slug program.id %}" hx-trigger="load"
hx-swap="outerHTML">
{% include "tables/table_placeholder.html" with num_cols=6 %}
</div>
</div>
</div>
</section>
</div>
{% endblock content %}
Loading