Skip to content

Commit

Permalink
Merge pull request #391 from dimagi/hy/Network-manager-funnel-perform…
Browse files Browse the repository at this point in the history
…ance

Network manager funnel performance
  • Loading branch information
hemant10yadav authored Nov 28, 2024
2 parents 362dd42 + 7ce05e7 commit bdc1bbf
Show file tree
Hide file tree
Showing 8 changed files with 611 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])]
Expand Down
125 changes: 125 additions & 0 deletions commcare_connect/program/helpers.py
Original file line number Diff line number Diff line change
@@ -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
89 changes: 88 additions & 1 deletion commcare_connect/program/tables.py
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', url, value)
Loading

0 comments on commit bdc1bbf

Please sign in to comment.