From 233e8965a7f55ab0bef7dc851f4410ff45aa43e3 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Fri, 13 Sep 2024 16:57:52 +0530 Subject: [PATCH 01/44] added helper method --- .../0055_opportunityaccess_invited_date.py | 17 ++++ commcare_connect/opportunity/models.py | 1 + commcare_connect/program/helpers.py | 71 ++++++++++++++++ commcare_connect/program/tables.py | 85 ++++++++++++++++++- .../program/tests/test_helpers.py | 37 ++++++++ commcare_connect/program/urls.py | 6 ++ commcare_connect/program/views.py | 33 ++++++- .../templates/program/dashboard.html | 63 ++++++++++++++ 8 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 commcare_connect/opportunity/migrations/0055_opportunityaccess_invited_date.py create mode 100644 commcare_connect/program/helpers.py create mode 100644 commcare_connect/program/tests/test_helpers.py create mode 100644 commcare_connect/templates/program/dashboard.html diff --git a/commcare_connect/opportunity/migrations/0055_opportunityaccess_invited_date.py b/commcare_connect/opportunity/migrations/0055_opportunityaccess_invited_date.py new file mode 100644 index 00000000..b55f94d5 --- /dev/null +++ b/commcare_connect/opportunity/migrations/0055_opportunityaccess_invited_date.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2024-09-12 18:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("opportunity", "0054_opportunity_managed_alter_opportunity_organization"), + ] + + 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 f02264f0..d2f7a266 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -236,6 +236,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..7f961b74 --- /dev/null +++ b/commcare_connect/program/helpers.py @@ -0,0 +1,71 @@ +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 + + +def get_annotated_managed_opportunity_nm(program: Program, start_date=None, end_date=None): + managed_opportunities = ( + ManagedOpportunity.objects.filter(program=program, start_date__gte=start_date) + .order_by("start_date") + .annotate() + .prefetch_related( + "opportunityaccess_set", + "opportunityaccess_set__uservisit_set", + "opportunityaccess_set__assessment_set", + ) + ) + + return managed_opportunities diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index 2a360d3c..b3214703 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 = { @@ -160,6 +160,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={ @@ -192,6 +200,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"]) @@ -221,3 +230,77 @@ 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=_("% 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" + + +class DeliveryPerformanceTable(tables.Table): + organization = tables.Column() + start_date = tables.DateColumn() + workers_invited = tables.Column( + empty_values=(), + verbose_name=_("Workers Invited"), + ) + workers_starting_delivery = tables.Column(empty_values=(), verbose_name=_("Total Workers Starting Delivery")) + active_workers = tables.Column(empty_values=(), verbose_name=_("Active Workers")) + deliveries_per_day_per_worker = tables.Column(empty_values=(), verbose_name=_("Deliveries Per Day Per Worker")) + percentage_records_flagged = tables.Column(empty_values=(), verbose_name="% Records Flagged") + + class Meta: + model = ManagedOpportunity + empty_text = "No data available yet." + fields = ( + "organization", + "start_date", + "Total Workers Starting Delivery", + "Active Workers", + "Deliveries Per Day Per Worker", + "% Records Flagged", + ) + orderable = False + + @staticmethod + def get_queryset(): + queryset = ManagedOpportunity.objects.prefetch_related("opportunityaccess_set") + return queryset + + def precompute_data(self, record): + """Precompute the values for the record and store them in the record.""" + if not hasattr(record, "_precomputed_data"): + data = { + "workers_starting_delivery": 0, + } + for access in record.opportunityaccess_set.all(): + if access.last_visit_date: + data["workers_starting_delivery"] += 1 + + record._precomputed_data = data + + return record._precomputed_data diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py new file mode 100644 index 00000000..04d4a168 --- /dev/null +++ b/commcare_connect/program/tests/test_helpers.py @@ -0,0 +1,37 @@ +from datetime import timedelta + +import pytest +from django.urls import reverse +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.tests.factories import ManagedOpportunityFactory, ProgramFactory +from commcare_connect.program.tests.test_views import BaseProgramTest +from commcare_connect.users.tests.factories import OrganizationFactory, UserFactory + + +class TestFunnelPerformanceTable(BaseProgramTest): + @pytest.mark.django_db + class TestProgramListView(BaseProgramTest): + @pytest.fixture(autouse=True) + def test_setup(self): + self.program = ProgramFactory.create(organization=self.organization) + self.list_url = reverse( + "program:funnel_performance_table", kwargs={"org_slug": self.organization.slug, "pk": self.program.id} + ) + + nm_org = OrganizationFactory.create() + opp = ManagedOpportunityFactory.create(program=self.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 < 9 else VisitValidationStatus.trial + UserVisitFactory.create( + user=user, + opportunity=opp, + status=visit_status, + opportunity_access=access, + visit_date=now() + timedelta(3), + ) diff --git a/commcare_connect/program/urls.py b/commcare_connect/program/urls.py index 53209232..4e93c995 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 ( + FunnelPerformanceTableView, ManagedOpportunityInit, ManagedOpportunityList, ProgramApplicationList, ProgramCreateOrUpdate, ProgramList, apply_or_decline_application, + dashboard, + delivery_table, invite_organization, manage_application, ) @@ -26,4 +29,7 @@ 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", delivery_table, name="delivery_performance_table"), ] diff --git a/commcare_connect/program/views.py b/commcare_connect/program/views.py index 4c23d224..70850d9b 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): @@ -232,3 +233,31 @@ 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) + + +@org_program_manager_required +def delivery_table(request, **kwargs): + manage_opps = ManagedOpportunity.objects.filter(program__id=kwargs.get("pk")) + delivery_performance_table = FunnelPerformanceTable(manage_opps) + return render(request, "tables/single_table.html", {"table": delivery_performance_table}) diff --git a/commcare_connect/templates/program/dashboard.html b/commcare_connect/templates/program/dashboard.html new file mode 100644 index 00000000..7234964d --- /dev/null +++ b/commcare_connect/templates/program/dashboard.html @@ -0,0 +1,63 @@ +{% 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 %} +
+
+

Dashboard

+
+
+ +
+
+
+ {% include "tables/table_placeholder.html" with num_cols=6 %} +
+
+
+
+ {% include "tables/table_placeholder.html" with num_cols=7 %} +
+
+
+
+
+{% endblock content %} From b73e31fbfc8a577c4ec9af500b886d484f46da33 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Mon, 16 Sep 2024 16:05:28 +0530 Subject: [PATCH 02/44] refactor code --- commcare_connect/program/tables.py | 47 +----------------- .../program/tests/test_helpers.py | 49 +++++++++---------- commcare_connect/program/urls.py | 4 +- .../templates/program/dashboard.html | 26 +--------- 4 files changed, 27 insertions(+), 99 deletions(-) diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index b3214703..437d4aa2 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -238,7 +238,7 @@ class FunnelPerformanceTable(tables.Table): 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")) + percentage_conversion = tables.Column(verbose_name=_("Percentage Conversion")) average_time_to_convert = tables.Column(verbose_name=_("Average Time To convert")) class Meta: @@ -259,48 +259,3 @@ 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" - - -class DeliveryPerformanceTable(tables.Table): - organization = tables.Column() - start_date = tables.DateColumn() - workers_invited = tables.Column( - empty_values=(), - verbose_name=_("Workers Invited"), - ) - workers_starting_delivery = tables.Column(empty_values=(), verbose_name=_("Total Workers Starting Delivery")) - active_workers = tables.Column(empty_values=(), verbose_name=_("Active Workers")) - deliveries_per_day_per_worker = tables.Column(empty_values=(), verbose_name=_("Deliveries Per Day Per Worker")) - percentage_records_flagged = tables.Column(empty_values=(), verbose_name="% Records Flagged") - - class Meta: - model = ManagedOpportunity - empty_text = "No data available yet." - fields = ( - "organization", - "start_date", - "Total Workers Starting Delivery", - "Active Workers", - "Deliveries Per Day Per Worker", - "% Records Flagged", - ) - orderable = False - - @staticmethod - def get_queryset(): - queryset = ManagedOpportunity.objects.prefetch_related("opportunityaccess_set") - return queryset - - def precompute_data(self, record): - """Precompute the values for the record and store them in the record.""" - if not hasattr(record, "_precomputed_data"): - data = { - "workers_starting_delivery": 0, - } - for access in record.opportunityaccess_set.all(): - if access.last_visit_date: - data["workers_starting_delivery"] += 1 - - record._precomputed_data = data - - return record._precomputed_data diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 04d4a168..8339976f 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -1,37 +1,34 @@ from datetime import timedelta -import pytest -from django.urls import reverse 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.program.tests.test_views import BaseProgramTest from commcare_connect.users.tests.factories import OrganizationFactory, UserFactory -class TestFunnelPerformanceTable(BaseProgramTest): - @pytest.mark.django_db - class TestProgramListView(BaseProgramTest): - @pytest.fixture(autouse=True) - def test_setup(self): - self.program = ProgramFactory.create(organization=self.organization) - self.list_url = reverse( - "program:funnel_performance_table", kwargs={"org_slug": self.organization.slug, "pk": self.program.id} - ) +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), + ) - nm_org = OrganizationFactory.create() - opp = ManagedOpportunityFactory.create(program=self.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 < 9 else VisitValidationStatus.trial - UserVisitFactory.create( - user=user, - opportunity=opp, - status=visit_status, - opportunity_access=access, - visit_date=now() + timedelta(3), - ) + 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 diff --git a/commcare_connect/program/urls.py b/commcare_connect/program/urls.py index 4e93c995..339771bb 100644 --- a/commcare_connect/program/urls.py +++ b/commcare_connect/program/urls.py @@ -9,7 +9,6 @@ ProgramList, apply_or_decline_application, dashboard, - delivery_table, invite_organization, manage_application, ) @@ -30,6 +29,5 @@ 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", delivery_table, name="delivery_performance_table"), + path("/funnel_performance_table", FunnelPerformanceTableView.as_view(), name="funnel_performance_table"), ] diff --git a/commcare_connect/templates/program/dashboard.html b/commcare_connect/templates/program/dashboard.html index 7234964d..c5c7d895 100644 --- a/commcare_connect/templates/program/dashboard.html +++ b/commcare_connect/templates/program/dashboard.html @@ -12,7 +12,7 @@ {% block content %}
-

Dashboard

+

{% trans "Dashboard" %}

Dashboard {% include "tables/table_placeholder.html" with num_cols=6 %}
-
-
- {% include "tables/table_placeholder.html" with num_cols=7 %} -
-
From 0c02dc370db9617d5a6177af62d351a33947f9cf Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Mon, 16 Sep 2024 16:18:14 +0530 Subject: [PATCH 03/44] Removed unused code --- commcare_connect/program/helpers.py | 15 --------------- commcare_connect/program/views.py | 7 ------- 2 files changed, 22 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 7f961b74..a9bcd9f7 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -54,18 +54,3 @@ def get_annotated_managed_opportunity(program: Program): ) return managed_opportunities - - -def get_annotated_managed_opportunity_nm(program: Program, start_date=None, end_date=None): - managed_opportunities = ( - ManagedOpportunity.objects.filter(program=program, start_date__gte=start_date) - .order_by("start_date") - .annotate() - .prefetch_related( - "opportunityaccess_set", - "opportunityaccess_set__uservisit_set", - "opportunityaccess_set__assessment_set", - ) - ) - - return managed_opportunities diff --git a/commcare_connect/program/views.py b/commcare_connect/program/views.py index 70850d9b..15db42a5 100644 --- a/commcare_connect/program/views.py +++ b/commcare_connect/program/views.py @@ -254,10 +254,3 @@ def get_queryset(self): program_id = self.kwargs["pk"] program = get_object_or_404(Program, id=program_id) return get_annotated_managed_opportunity(program) - - -@org_program_manager_required -def delivery_table(request, **kwargs): - manage_opps = ManagedOpportunity.objects.filter(program__id=kwargs.get("pk")) - delivery_performance_table = FunnelPerformanceTable(manage_opps) - return render(request, "tables/single_table.html", {"table": delivery_performance_table}) From be2ba7d2245a151456c255362355f9e273ca670a Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 18 Sep 2024 15:53:44 +0530 Subject: [PATCH 04/44] fixed migration sequence --- ...invited_date.py => 0059_opportunityaccess_invited_date.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename commcare_connect/opportunity/migrations/{0055_opportunityaccess_invited_date.py => 0059_opportunityaccess_invited_date.py} (70%) diff --git a/commcare_connect/opportunity/migrations/0055_opportunityaccess_invited_date.py b/commcare_connect/opportunity/migrations/0059_opportunityaccess_invited_date.py similarity index 70% rename from commcare_connect/opportunity/migrations/0055_opportunityaccess_invited_date.py rename to commcare_connect/opportunity/migrations/0059_opportunityaccess_invited_date.py index b55f94d5..1382f847 100644 --- a/commcare_connect/opportunity/migrations/0055_opportunityaccess_invited_date.py +++ b/commcare_connect/opportunity/migrations/0059_opportunityaccess_invited_date.py @@ -1,11 +1,11 @@ -# Generated by Django 4.2.5 on 2024-09-12 18:13 +# Generated by Django 4.2.5 on 2024-09-18 10:16 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("opportunity", "0054_opportunity_managed_alter_opportunity_organization"), + ("opportunity", "0058_paymentinvoice_payment_invoice"), ] operations = [ From a8f01206c6e5ee998645182ec92e02c3fbad9fb5 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 25 Sep 2024 11:28:05 +0530 Subject: [PATCH 05/44] added performace table query and table --- commcare_connect/program/helpers.py | 58 ++++++++++++++++--- commcare_connect/program/tables.py | 22 +++++++ .../program/tests/test_helpers.py | 24 ++++++++ commcare_connect/program/urls.py | 9 ++- commcare_connect/program/views.py | 24 +++++++- .../templates/program/dashboard.html | 37 ++++++++++++ 6 files changed, 161 insertions(+), 13 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index a9bcd9f7..6ee46f45 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -3,15 +3,15 @@ from commcare_connect.opportunity.models import UserVisit, VisitValidationStatus from commcare_connect.program.models import ManagedOpportunity, Program +FILTER_FOR_VALID_VISIT_DATE = ~Q( + opportunityaccess__uservisit__status__in=[ + VisitValidationStatus.over_limit, + VisitValidationStatus.trial, + ] +) -def get_annotated_managed_opportunity(program: Program): - filter_for_valid__visit_date = ~Q( - opportunityaccess__uservisit__status__in=[ - VisitValidationStatus.over_limit, - VisitValidationStatus.trial, - ] - ) +def get_annotated_managed_opportunity(program: Program): earliest_visits = ( UserVisit.objects.filter( opportunity_access=OuterRef("opportunityaccess"), @@ -35,7 +35,7 @@ def get_annotated_managed_opportunity(program: Program): ), workers_starting_delivery=Count( "opportunityaccess__uservisit__user", - filter=filter_for_valid__visit_date, + filter=FILTER_FOR_VALID_VISIT_DATE, distinct=True, ), percentage_conversion=F("workers_starting_delivery") / F("workers_invited") * 100, @@ -43,7 +43,7 @@ def get_annotated_managed_opportunity(program: Program): ExpressionWrapper( Subquery(earliest_visits) - F("opportunityaccess__invited_date"), output_field=DurationField() ), - filter=filter_for_valid__visit_date, + filter=FILTER_FOR_VALID_VISIT_DATE, ), ) .prefetch_related( @@ -54,3 +54,43 @@ def get_annotated_managed_opportunity(program: Program): ) return managed_opportunities + + +def get_delivery_performance_report(program: Program, start_date, end_date): + date_filter = Q() + + 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) + + active_workers_filter = Q(FILTER_FOR_VALID_VISIT_DATE, date_filter) + + managed_opportunities = ( + ManagedOpportunity.objects.filter(program=program) + .order_by("start_date") + .prefetch_related( + "opportunityaccess_set", + "opportunityaccess_set__uservisit_set", + "opportunityaccess_set__completedwork_set", + ) + .annotate( + total_workers_starting_delivery=Count( + "opportunityaccess__uservisit__user", + filter=FILTER_FOR_VALID_VISIT_DATE, + distinct=True, + ), + active_workers=Count( + "opportunityaccess__uservisit__user", + filter=active_workers_filter, + distinct=True, + ), + total_payment_units=Count("opportunityaccess__completedwork", distinct=True), + total_payement_since_start_date=Count("opportunityaccess__completedwork", distinct=True), + deliveries_per_day=F("total_payement_since_start_date") / F("active_workers"), + records_flagged_percentage=F("total_payment_units") / F("total_payement_since_start_date"), + ) + ) + + return managed_opportunities diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index 4e964d4c..bc5b68ea 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -262,3 +262,25 @@ 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" + + +class DeliveryPerformanceTable(tables.Table): + organization = tables.Column() + start_date = tables.DateColumn() + workers_invited = tables.Column(verbose_name=_("Workers Starting Delivery")) + active_workers = tables.Column(verbose_name=_("Active Workers")) + delivery_per_day_per_worker = tables.Column(verbose_name=_("Delivery Per Day Per Worker")) + records_flagged = tables.Column(verbose_name=_("Records flagged")) + + class Meta: + model = ManagedOpportunity + empty_text = "No data available yet." + fields = ( + "organization", + "start_date", + "workers_starting_delivery", + "active_workers", + "delivery_per_day_per_worker", + "records_flagged", + ) + orderable = False diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 8339976f..e4f2ba6f 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -32,3 +32,27 @@ def test_get_annotated_managed_opportunity(program_manager_org: Organization): assert nm_org.slug == opp.organization.slug assert opp.workers_passing_assessment == 5 assert opp.workers_starting_delivery == 3 + + +def test_delivery_performance(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 diff --git a/commcare_connect/program/urls.py b/commcare_connect/program/urls.py index 339771bb..7f96d4c9 100644 --- a/commcare_connect/program/urls.py +++ b/commcare_connect/program/urls.py @@ -1,7 +1,7 @@ from django.urls import path from commcare_connect.program.views import ( - FunnelPerformanceTableView, + DeliveryPerformanceTableView, ManagedOpportunityInit, ManagedOpportunityList, ProgramApplicationList, @@ -29,5 +29,10 @@ name="apply_or_decline_application", ), path("/dashboard", dashboard, name="dashboard"), - path("/funnel_performance_table", FunnelPerformanceTableView.as_view(), name="funnel_performance_table"), + path("/funnel_performance_table", DeliveryPerformanceTableView.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 c7b21f94..ab23240a 100644 --- a/commcare_connect/program/views.py +++ b/commcare_connect/program/views.py @@ -10,9 +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 +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 FunnelPerformanceTable, ProgramApplicationTable, ProgramTable +from commcare_connect.program.tables import ( + DeliveryPerformanceTable, + FunnelPerformanceTable, + ProgramApplicationTable, + ProgramTable, +) class ProgramManagerMixin(LoginRequiredMixin, UserPassesTestMixin): @@ -258,3 +263,18 @@ 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 index c5c7d895..57f3b461 100644 --- a/commcare_connect/templates/program/dashboard.html +++ b/commcare_connect/templates/program/dashboard.html @@ -24,6 +24,14 @@

{% trans "Dashboard" %}

+ +
{% trans "Dashboard" %} {% include "tables/table_placeholder.html" with num_cols=6 %}
+ +
+
+
+ + +
+
+ + +
+ +
+
+
+ {% include "tables/table_placeholder.html" with num_cols=7 %} +
+
From d5ea308a314df5f84245c7862d0d40afbde65140 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 25 Sep 2024 13:56:14 +0530 Subject: [PATCH 06/44] Refactor logic and changed tests --- commcare_connect/program/helpers.py | 32 ++++++++++++++++--- commcare_connect/program/tables.py | 8 ++--- .../program/tests/test_helpers.py | 23 ++++++++----- commcare_connect/program/views.py | 2 ++ 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 6ee46f45..933b4a3a 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -1,4 +1,18 @@ -from django.db.models import Avg, Count, DurationField, ExpressionWrapper, F, OuterRef, Q, Subquery +from django.db.models import ( + Avg, + Case, + Count, + DurationField, + ExpressionWrapper, + F, + FloatField, + OuterRef, + Q, + Subquery, + Value, + When, +) +from django.db.models.functions import Round from commcare_connect.opportunity.models import UserVisit, VisitValidationStatus from commcare_connect.program.models import ManagedOpportunity, Program @@ -86,10 +100,18 @@ def get_delivery_performance_report(program: Program, start_date, end_date): filter=active_workers_filter, distinct=True, ), - total_payment_units=Count("opportunityaccess__completedwork", distinct=True), - total_payement_since_start_date=Count("opportunityaccess__completedwork", distinct=True), - deliveries_per_day=F("total_payement_since_start_date") / F("active_workers"), - records_flagged_percentage=F("total_payment_units") / F("total_payement_since_start_date"), + total_payment_units=Count("opportunityaccess__completedwork"), + total_payement_since_start_date=Count("opportunityaccess__completedwork", filter=date_filter), + delivery_per_day_per_worker=Case( + When(active_workers=0, then=Value(0)), + default=Round(F("total_payement_since_start_date") / F("active_workers"), 2), + output_field=FloatField(), + ), + records_flagged_percentage=Case( + When(active_workers=0, then=Value(0)), + default=Round(F("total_payment_units") / F("total_payement_since_start_date")), + output_field=FloatField(), + ), ) ) diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index bc5b68ea..b36a3001 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -267,10 +267,10 @@ def render_average_time_to_convert(self, record): class DeliveryPerformanceTable(tables.Table): organization = tables.Column() start_date = tables.DateColumn() - workers_invited = tables.Column(verbose_name=_("Workers Starting Delivery")) + total_workers_starting_delivery = tables.Column(verbose_name=_("Workers Starting Delivery")) active_workers = tables.Column(verbose_name=_("Active Workers")) delivery_per_day_per_worker = tables.Column(verbose_name=_("Delivery Per Day Per Worker")) - records_flagged = tables.Column(verbose_name=_("Records flagged")) + records_flagged_percentage = tables.Column(verbose_name=_("Records flagged")) class Meta: model = ManagedOpportunity @@ -278,9 +278,9 @@ class Meta: fields = ( "organization", "start_date", - "workers_starting_delivery", + "total_workers_starting_delivery", "active_workers", "delivery_per_day_per_worker", - "records_flagged", + "records_flagged_percentage", ) orderable = False diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index e4f2ba6f..50b8bf56 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -3,9 +3,14 @@ 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.opportunity.tests.factories import ( + AssessmentFactory, + CompletedWorkFactory, + OpportunityAccessFactory, + UserVisitFactory, +) from commcare_connect.organization.models import Organization -from commcare_connect.program.helpers import get_annotated_managed_opportunity +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 @@ -48,11 +53,13 @@ def test_delivery_performance(program_manager_org: Organization): opportunity=opp, status=visit_status, opportunity_access=access, - visit_date=now() + timedelta(1), + visit_date=now() + timedelta(3), ) + CompletedWorkFactory.create(opportunity_access=access) - 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 + opps = get_delivery_performance_report(program, None, None) + + assert opps[0].total_workers_starting_delivery == 3, "Total workers starting delivery doesn't match" + assert opps[0].active_workers == 3, "Active workers count doesn't match" + assert opps[0].delivery_per_day_per_worker == 1.0, "Deliveries per day doesn't match" + assert opps[0].records_flagged_percentage == 1.0, "Records flagged percentage doesn't match" diff --git a/commcare_connect/program/views.py b/commcare_connect/program/views.py index ab23240a..5bb75d10 100644 --- a/commcare_connect/program/views.py +++ b/commcare_connect/program/views.py @@ -277,4 +277,6 @@ def get_queryset(self): start_date = self.request.GET.get("start_date") or None end_date = self.request.GET.get("end_date") or None + print("start_date", start_date) + print("end_date", end_date) return get_delivery_performance_report(program, start_date, end_date) From 8d54051e69620c04fded13e5bcefc2f93401552f Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Thu, 26 Sep 2024 17:29:40 +0530 Subject: [PATCH 07/44] fixed url for delivery table --- commcare_connect/program/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commcare_connect/program/urls.py b/commcare_connect/program/urls.py index 7f96d4c9..7d9cd6c1 100644 --- a/commcare_connect/program/urls.py +++ b/commcare_connect/program/urls.py @@ -2,6 +2,7 @@ from commcare_connect.program.views import ( DeliveryPerformanceTableView, + FunnelPerformanceTableView, ManagedOpportunityInit, ManagedOpportunityList, ProgramApplicationList, @@ -29,7 +30,7 @@ name="apply_or_decline_application", ), path("/dashboard", dashboard, name="dashboard"), - path("/funnel_performance_table", DeliveryPerformanceTableView.as_view(), name="funnel_performance_table"), + path("/funnel_performance_table", FunnelPerformanceTableView.as_view(), name="funnel_performance_table"), path( "/delivery_performance_table", DeliveryPerformanceTableView.as_view(), From ecaecc4a40ae631eda66c4cac23b448e61ed218a Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Fri, 27 Sep 2024 12:55:04 +0530 Subject: [PATCH 08/44] fixed percent issue --- commcare_connect/program/helpers.py | 2 +- commcare_connect/program/tests/test_helpers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 933b4a3a..6357e703 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -109,7 +109,7 @@ def get_delivery_performance_report(program: Program, start_date, end_date): ), records_flagged_percentage=Case( When(active_workers=0, then=Value(0)), - default=Round(F("total_payment_units") / F("total_payement_since_start_date")), + default=Round(F("total_payment_units") / F("total_payement_since_start_date")) * 100, output_field=FloatField(), ), ) diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 50b8bf56..440a4cf2 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -62,4 +62,4 @@ def test_delivery_performance(program_manager_org: Organization): assert opps[0].total_workers_starting_delivery == 3, "Total workers starting delivery doesn't match" assert opps[0].active_workers == 3, "Active workers count doesn't match" assert opps[0].delivery_per_day_per_worker == 1.0, "Deliveries per day doesn't match" - assert opps[0].records_flagged_percentage == 1.0, "Records flagged percentage doesn't match" + assert opps[0].records_flagged_percentage == 100.0, "Records flagged percentage doesn't match" From 5fafc6b439bd0edd2332c733b7af19a98dbba8b6 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 2 Oct 2024 12:55:31 +0530 Subject: [PATCH 09/44] Fixed visit logic and added more test --- commcare_connect/program/helpers.py | 26 ++-- .../program/tests/test_helpers.py | 126 +++++++++++++++--- 2 files changed, 128 insertions(+), 24 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 6357e703..1d44c728 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -12,7 +12,7 @@ Value, When, ) -from django.db.models.functions import Round +from django.db.models.functions import Cast, Round from commcare_connect.opportunity.models import UserVisit, VisitValidationStatus from commcare_connect.program.models import ManagedOpportunity, Program @@ -79,7 +79,12 @@ def get_delivery_performance_report(program: Program, start_date, end_date): if end_date: date_filter &= Q(opportunityaccess__uservisit__visit_date__lte=end_date) - active_workers_filter = Q(FILTER_FOR_VALID_VISIT_DATE, date_filter) + flagged_visits_filter = Q(opportunityaccess__uservisit__flagged=True) & ~Q( + opportunityaccess__uservisit__status__in=[ + VisitValidationStatus.rejected, + VisitValidationStatus.approved, + ] + ) managed_opportunities = ( ManagedOpportunity.objects.filter(program=program) @@ -92,24 +97,29 @@ def get_delivery_performance_report(program: Program, start_date, end_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=active_workers_filter, + filter=date_filter, distinct=True, ), total_payment_units=Count("opportunityaccess__completedwork"), - total_payement_since_start_date=Count("opportunityaccess__completedwork", filter=date_filter), + total_payment_units_with_flags=Count("opportunityaccess__completedwork", filter=flagged_visits_filter), + total_payment_since_start_date=Count("opportunityaccess__completedwork", filter=date_filter), delivery_per_day_per_worker=Case( When(active_workers=0, then=Value(0)), - default=Round(F("total_payement_since_start_date") / F("active_workers"), 2), + default=Round(F("total_payment_since_start_date") / F("active_workers"), 2), output_field=FloatField(), ), records_flagged_percentage=Case( - When(active_workers=0, then=Value(0)), - default=Round(F("total_payment_units") / F("total_payement_since_start_date")) * 100, + When(total_payment_since_start_date=0, then=Value(0)), + default=Round( + Cast(F("total_payment_units_with_flags"), FloatField()) + / Cast(F("total_payment_since_start_date"), FloatField()) + * 100, + 2, + ), output_field=FloatField(), ), ) diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 440a4cf2..bb4c60c0 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -1,5 +1,6 @@ from datetime import timedelta +import pytest from django_celery_beat.utils import now from commcare_connect.opportunity.models import VisitValidationStatus @@ -39,27 +40,120 @@ def test_get_annotated_managed_opportunity(program_manager_org: Organization): assert opp.workers_starting_delivery == 3 -def test_delivery_performance(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 +@pytest.mark.django_db +class TestDeliveryPerformanceReport: + @pytest.fixture(autouse=True) + def setup(self): + self.program_manager_org = OrganizationFactory.create() + self.program = ProgramFactory.create(organization=self.program_manager_org) + self.nm_org = OrganizationFactory.create() + self.opp = ManagedOpportunityFactory.create(program=self.program, organization=self.nm_org) + + 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()) UserVisitFactory.create( user=user, - opportunity=opp, + opportunity=self.opp, status=visit_status, opportunity_access=access, - visit_date=now() + timedelta(3), + visit_date=visit_date, + flagged=flagged, + ) + if create_completed_work: + CompletedWorkFactory.create(opportunity_access=access) + return user + + def test_basic_delivery_performance(self): + for _ in range(2): + self.create_user_with_visit(VisitValidationStatus.pending, now(), True) + for _ in range(3): + self.create_user_with_visit(VisitValidationStatus.approved, now(), False) + + opps = get_delivery_performance_report(self.program, None, None) + assert len(opps) == 1 + assert opps[0].total_workers_starting_delivery == 5 + assert opps[0].active_workers == 5 + assert opps[0].total_payment_units == 5 + assert opps[0].total_payment_units_with_flags == 2 + assert opps[0].total_payment_since_start_date == 5 + assert opps[0].delivery_per_day_per_worker == 1.0 + assert opps[0].records_flagged_percentage == 40.0 + + def test_delivery_performance_with_date_range(self): + start_date = now() - timedelta(10) + end_date = now() + timedelta(10) + + self.create_user_with_visit(VisitValidationStatus.pending, start_date - timedelta(1)) + self.create_user_with_visit(VisitValidationStatus.pending, start_date + timedelta(1)) + self.create_user_with_visit(VisitValidationStatus.pending, end_date - timedelta(1)) + self.create_user_with_visit(VisitValidationStatus.pending, end_date + timedelta(1)) + + opps = get_delivery_performance_report(self.program, start_date, end_date) + assert opps[0].active_workers == 2 + assert opps[0].total_payment_since_start_date == 2 + + def test_delivery_performance_with_flagged_visits(self): + self.create_user_with_visit(VisitValidationStatus.pending, now()) + self.create_user_with_visit(VisitValidationStatus.pending, now(), flagged=True) + + opps = get_delivery_performance_report(self.program, None, None) + assert opps[0].total_payment_units_with_flags == 1 + assert opps[0].records_flagged_percentage == 50.0 + + def test_delivery_performance_with_no_active_workers(self): + self.create_user_with_visit(VisitValidationStatus.over_limit, now()) + self.create_user_with_visit(VisitValidationStatus.trial, now()) + + opps = get_delivery_performance_report(self.program, None, None) + assert opps[0].total_workers_starting_delivery == 2 + assert opps[0].active_workers == 2 + assert opps[0].delivery_per_day_per_worker == 1.0 + + def test_delivery_performance_with_multiple_opportunities(self): + opp2 = ManagedOpportunityFactory.create(program=self.program) + + self.create_user_with_visit(VisitValidationStatus.pending, now()) + + user = UserFactory.create() + access = OpportunityAccessFactory.create(opportunity=opp2, user=user, invited_date=now()) + UserVisitFactory.create( + user=user, + opportunity=opp2, + status=VisitValidationStatus.pending, + opportunity_access=access, + visit_date=now(), ) CompletedWorkFactory.create(opportunity_access=access) - opps = get_delivery_performance_report(program, None, None) + opps = get_delivery_performance_report(self.program, None, None) + assert len(opps) == 2 + assert all(o.active_workers == 1 for o in opps) + + def test_delivery_performance_with_no_completed_work(self): + self.create_user_with_visit(VisitValidationStatus.pending, now(), create_completed_work=False) + + opps = get_delivery_performance_report(self.program, None, None) + assert opps[0].total_payment_units == 0 + assert opps[0].delivery_per_day_per_worker == 0 + + @pytest.mark.parametrize("visit_status", [VisitValidationStatus.rejected, VisitValidationStatus.approved]) + def test_delivery_performance_excluded_statuses(self, visit_status): + self.create_user_with_visit(visit_status, now(), flagged=True) + + opps = get_delivery_performance_report(self.program, None, None) + assert opps[0].total_workers_starting_delivery == 1 + assert opps[0].active_workers == 1 + assert opps[0].total_payment_units_with_flags == 0 + + def test_delivery_performance_with_mixed_statuses(self): + self.create_user_with_visit(VisitValidationStatus.pending, now(), flagged=True) + self.create_user_with_visit(VisitValidationStatus.approved, now(), flagged=True) + self.create_user_with_visit(VisitValidationStatus.rejected, now(), flagged=True) + self.create_user_with_visit(VisitValidationStatus.over_limit, now(), flagged=True) - assert opps[0].total_workers_starting_delivery == 3, "Total workers starting delivery doesn't match" - assert opps[0].active_workers == 3, "Active workers count doesn't match" - assert opps[0].delivery_per_day_per_worker == 1.0, "Deliveries per day doesn't match" - assert opps[0].records_flagged_percentage == 100.0, "Records flagged percentage doesn't match" + opps = get_delivery_performance_report(self.program, None, None) + assert opps[0].total_workers_starting_delivery == 4 + assert opps[0].active_workers == 4 + assert opps[0].total_payment_units_with_flags == 2 + assert opps[0].records_flagged_percentage == 50.0 From 680b361a5c0f1df32c462d8cbd25935cd1acbc35 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 2 Oct 2024 13:23:52 +0530 Subject: [PATCH 10/44] Code refactor --- commcare_connect/program/helpers.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 1d44c728..96aa1eb6 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -25,6 +25,14 @@ ) +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( @@ -52,7 +60,7 @@ def get_annotated_managed_opportunity(program: Program): filter=FILTER_FOR_VALID_VISIT_DATE, distinct=True, ), - percentage_conversion=F("workers_starting_delivery") / F("workers_invited") * 100, + 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() @@ -112,15 +120,8 @@ def get_delivery_performance_report(program: Program, start_date, end_date): default=Round(F("total_payment_since_start_date") / F("active_workers"), 2), output_field=FloatField(), ), - records_flagged_percentage=Case( - When(total_payment_since_start_date=0, then=Value(0)), - default=Round( - Cast(F("total_payment_units_with_flags"), FloatField()) - / Cast(F("total_payment_since_start_date"), FloatField()) - * 100, - 2, - ), - output_field=FloatField(), + records_flagged_percentage=calculate_safe_percentage( + "total_payment_units_with_flags", "total_payment_since_start_date" ), ) ) From 4f88e2aa8a84e76736c6954c1e540b7857143581 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Mon, 7 Oct 2024 10:27:00 +0530 Subject: [PATCH 11/44] fixed code review issues and added more tests --- commcare_connect/program/helpers.py | 44 ++++-- .../program/tests/test_helpers.py | 139 +++++++++++++++--- 2 files changed, 155 insertions(+), 28 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index a9bcd9f7..086b7e10 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -1,22 +1,44 @@ -from django.db.models import Avg, Count, DurationField, ExpressionWrapper, F, OuterRef, Q, Subquery +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 get_annotated_managed_opportunity(program: Program): - filter_for_valid__visit_date = ~Q( - opportunityaccess__uservisit__status__in=[ - VisitValidationStatus.over_limit, - VisitValidationStatus.trial, - ] +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=[VisitValidationStatus.over_limit, VisitValidationStatus.trial]) + .exclude(status__in=excluded_status) .order_by("visit_date") .values("visit_date")[:1] ) @@ -25,20 +47,20 @@ def get_annotated_managed_opportunity(program: Program): ManagedOpportunity.objects.filter(program=program) .order_by("start_date") .annotate( - workers_invited=Count("opportunityaccess"), + workers_invited=Count("opportunityaccess", distinct=True), workers_passing_assessment=Count( "opportunityaccess__assessment", filter=Q( opportunityaccess__assessment__passed=True, - opportunityaccess__assessment__opportunity=F("opportunityaccess__opportunity"), ), + distinct=True, ), 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, + 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() diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 8339976f..150aa305 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -1,34 +1,139 @@ 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.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 +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=opp, + opportunity=self.opp, status=visit_status, opportunity_access=access, - visit_date=now() + timedelta(1), + visit_date=now() + timedelta(days=1), + ) + return user + + def test_basic_scenario(self): + for i in range(5): + self.create_user_with_access( + visit_status=VisitValidationStatus.pending if i < 3 else VisitValidationStatus.trial + ) + + opps = get_annotated_managed_opportunity(self.program) + assert len(opps) == 1 + annotated_opp = opps[0] + assert annotated_opp.organization.slug == self.nm_org.slug + assert annotated_opp.workers_invited == 5 + assert annotated_opp.workers_passing_assessment == 5 + assert annotated_opp.workers_starting_delivery == 3 + assert annotated_opp.percentage_conversion == 60.0 + + def test_empty_scenario(self): + opps = get_annotated_managed_opportunity(self.program) + assert len(opps) == 1 + annotated_opp = opps[0] + assert annotated_opp.workers_invited == 0 + assert annotated_opp.workers_passing_assessment == 0 + assert annotated_opp.workers_starting_delivery == 0 + assert annotated_opp.percentage_conversion == 0.0 + assert annotated_opp.average_time_to_convert is None + + def test_multiple_visits(self): + user = self.create_user_with_access() + UserVisitFactory.create_batch( + 2, + user=user, + opportunity=self.opp, + status=VisitValidationStatus.pending, + opportunity_access=user.opportunityaccess_set.first(), + visit_date=now() + timedelta(days=2), ) - 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 + opps = get_annotated_managed_opportunity(self.program) + assert len(opps) == 1 + annotated_opp = opps[0] + assert annotated_opp.workers_invited == 1 + assert annotated_opp.workers_passing_assessment == 1 + assert annotated_opp.workers_starting_delivery == 1 + assert annotated_opp.percentage_conversion == 100.0 + + def test_excluded_statuses(self): + self.create_user_with_access(visit_status=VisitValidationStatus.over_limit) + self.create_user_with_access(visit_status=VisitValidationStatus.trial) + + opps = get_annotated_managed_opportunity(self.program) + assert len(opps) == 1 + annotated_opp = opps[0] + assert annotated_opp.workers_invited == 2 + assert annotated_opp.workers_passing_assessment == 2 + assert annotated_opp.workers_starting_delivery == 0 + assert annotated_opp.percentage_conversion == 0.0 + + def test_average_time_to_convert(self): + for i in range(3): + user = self.create_user_with_access() + user.opportunityaccess_set.update(invited_date=now() - timedelta(days=i)) + + opps = get_annotated_managed_opportunity(self.program) + assert len(opps) == 1 + annotated_opp = opps[0] + expected_time = timedelta(days=2) + actual_time = annotated_opp.average_time_to_convert + assert abs(actual_time - expected_time) < timedelta(seconds=5) + + def test_multiple_opportunities(self): + nm_org2 = OrganizationFactory.create() + opp2 = ManagedOpportunityFactory.create( + program=self.program, organization=nm_org2, start_date=now() + timedelta(days=1) + ) + + self.create_user_with_access() + user2 = UserFactory.create() + access2 = OpportunityAccessFactory.create(opportunity=opp2, user=user2, invited_date=now()) + AssessmentFactory.create(opportunity=opp2, user=user2, opportunity_access=access2, passed=True) + UserVisitFactory.create( + user=user2, + opportunity=opp2, + status=VisitValidationStatus.pending, + opportunity_access=access2, + visit_date=now() + timedelta(days=1), + ) + + opps = get_annotated_managed_opportunity(self.program) + assert len(opps) == 2 + assert opps[0].organization.slug == self.nm_org.slug + assert opps[1].organization.slug == nm_org2.slug + for annotated_opp in opps: + assert annotated_opp.workers_invited == 1 + assert annotated_opp.workers_passing_assessment == 1 + assert annotated_opp.workers_starting_delivery == 1 + assert annotated_opp.percentage_conversion == 100.0 + + def test_failed_assessments(self): + self.create_user_with_access(passed_assessment=False) + self.create_user_with_access(passed_assessment=True) + + opps = get_annotated_managed_opportunity(self.program) + assert len(opps) == 1 + annotated_opp = opps[0] + assert annotated_opp.workers_invited == 2 + assert annotated_opp.workers_passing_assessment == 1 + assert annotated_opp.workers_starting_delivery == 2 + assert annotated_opp.percentage_conversion == 100.0 From 7075ad4a8b23c1b10cc76d6151660659393d1283 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Mon, 7 Oct 2024 10:29:38 +0530 Subject: [PATCH 12/44] added check for none average time --- commcare_connect/program/tables.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index 4e964d4c..e9cf0296 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -259,6 +259,8 @@ class Meta: 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" From 56651e5afcf82d984a4807833be35d6f7e9a2a91 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Mon, 7 Oct 2024 10:35:12 +0530 Subject: [PATCH 13/44] fixed migration sequence --- ...invited_date.py => 0060_opportunityaccess_invited_date.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename commcare_connect/opportunity/migrations/{0059_opportunityaccess_invited_date.py => 0060_opportunityaccess_invited_date.py} (74%) diff --git a/commcare_connect/opportunity/migrations/0059_opportunityaccess_invited_date.py b/commcare_connect/opportunity/migrations/0060_opportunityaccess_invited_date.py similarity index 74% rename from commcare_connect/opportunity/migrations/0059_opportunityaccess_invited_date.py rename to commcare_connect/opportunity/migrations/0060_opportunityaccess_invited_date.py index 1382f847..d96766df 100644 --- a/commcare_connect/opportunity/migrations/0059_opportunityaccess_invited_date.py +++ b/commcare_connect/opportunity/migrations/0060_opportunityaccess_invited_date.py @@ -1,11 +1,11 @@ -# Generated by Django 4.2.5 on 2024-09-18 10:16 +# Generated by Django 4.2.5 on 2024-10-07 05:04 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("opportunity", "0058_paymentinvoice_payment_invoice"), + ("opportunity", "0059_payment_amount_usd"), ] operations = [ From 78fe8cd983e6cb2381c1edd63f73eea1d9b4c2a3 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Tue, 15 Oct 2024 15:26:51 +0530 Subject: [PATCH 14/44] Refactor tests removed print statement. --- commcare_connect/program/helpers.py | 5 -- .../program/tests/test_helpers.py | 46 +++++++++---------- commcare_connect/program/views.py | 3 -- 3 files changed, 21 insertions(+), 33 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index d74e05f3..bdbe8665 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -97,11 +97,6 @@ def get_delivery_performance_report(program: Program, start_date, end_date): managed_opportunities = ( ManagedOpportunity.objects.filter(program=program) .order_by("start_date") - .prefetch_related( - "opportunityaccess_set", - "opportunityaccess_set__uservisit_set", - "opportunityaccess_set__completedwork_set", - ) .annotate( total_workers_starting_delivery=Count( "opportunityaccess__uservisit__user", diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 7be60a1c..986d3cf8 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -15,7 +15,8 @@ from commcare_connect.users.tests.factories import OrganizationFactory, UserFactory -class TestGetAnnotatedManagedOpportunity: +@pytest.mark.django_db +class BaseManagedOpportunityTest: @pytest.fixture(autouse=True) def setup(self, db): self.program = ProgramFactory.create() @@ -35,6 +36,23 @@ def create_user_with_access(self, visit_status=VisitValidationStatus.pending, pa ) 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()) + UserVisitFactory.create( + user=user, + opportunity=self.opp, + status=visit_status, + opportunity_access=access, + visit_date=visit_date, + flagged=flagged, + ) + if create_completed_work: + CompletedWorkFactory.create(opportunity_access=access) + return user + + +class TestGetAnnotatedManagedOpportunity(BaseManagedOpportunityTest): def test_basic_scenario(self): for i in range(5): self.create_user_with_access( @@ -103,7 +121,7 @@ def test_average_time_to_convert(self): assert abs(actual_time - expected_time) < timedelta(seconds=5) def test_multiple_opportunities(self): - nm_org2 = OrganizationFactory.create() + nm_org2 = OrganizationFactory.create(program_manager=True) opp2 = ManagedOpportunityFactory.create( program=self.program, organization=nm_org2, start_date=now() + timedelta(days=1) ) @@ -144,29 +162,7 @@ def test_failed_assessments(self): @pytest.mark.django_db -class TestDeliveryPerformanceReport: - @pytest.fixture(autouse=True) - def setup(self): - self.program_manager_org = OrganizationFactory.create() - self.program = ProgramFactory.create(organization=self.program_manager_org) - self.nm_org = OrganizationFactory.create() - self.opp = ManagedOpportunityFactory.create(program=self.program, organization=self.nm_org) - - 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()) - UserVisitFactory.create( - user=user, - opportunity=self.opp, - status=visit_status, - opportunity_access=access, - visit_date=visit_date, - flagged=flagged, - ) - if create_completed_work: - CompletedWorkFactory.create(opportunity_access=access) - return user - +class TestDeliveryPerformanceReport(BaseManagedOpportunityTest): def test_basic_delivery_performance(self): for _ in range(2): self.create_user_with_visit(VisitValidationStatus.pending, now(), True) diff --git a/commcare_connect/program/views.py b/commcare_connect/program/views.py index 5bb75d10..ff60a8b5 100644 --- a/commcare_connect/program/views.py +++ b/commcare_connect/program/views.py @@ -276,7 +276,4 @@ def get_queryset(self): 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 - - print("start_date", start_date) - print("end_date", end_date) return get_delivery_performance_report(program, start_date, end_date) From f87aa0223bd7fcd6b7a3857c1051e023e876068e Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Tue, 15 Oct 2024 15:51:35 +0530 Subject: [PATCH 15/44] refactor tests --- commcare_connect/program/helpers.py | 5 - .../program/tests/test_helpers.py | 166 +++++++----------- 2 files changed, 63 insertions(+), 108 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 086b7e10..78802a34 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -68,11 +68,6 @@ def get_annotated_managed_opportunity(program: Program): filter=filter_for_valid__visit_date, ), ) - .prefetch_related( - "opportunityaccess_set", - "opportunityaccess_set__uservisit_set", - "opportunityaccess_set__assessment_set", - ) ) return managed_opportunities diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 150aa305..de67f02c 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -30,110 +30,70 @@ def create_user_with_access(self, visit_status=VisitValidationStatus.pending, pa ) return user - def test_basic_scenario(self): - for i in range(5): - self.create_user_with_access( - visit_status=VisitValidationStatus.pending if i < 3 else VisitValidationStatus.trial - ) + @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.organization.slug == self.nm_org.slug - assert annotated_opp.workers_invited == 5 - assert annotated_opp.workers_passing_assessment == 5 - assert annotated_opp.workers_starting_delivery == 3 - assert annotated_opp.percentage_conversion == 60.0 - - def test_empty_scenario(self): - opps = get_annotated_managed_opportunity(self.program) - assert len(opps) == 1 - annotated_opp = opps[0] - assert annotated_opp.workers_invited == 0 - assert annotated_opp.workers_passing_assessment == 0 - assert annotated_opp.workers_starting_delivery == 0 - assert annotated_opp.percentage_conversion == 0.0 - assert annotated_opp.average_time_to_convert is None - - def test_multiple_visits(self): - user = self.create_user_with_access() - UserVisitFactory.create_batch( - 2, - user=user, - opportunity=self.opp, - status=VisitValidationStatus.pending, - opportunity_access=user.opportunityaccess_set.first(), - 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 == 1 - assert annotated_opp.workers_passing_assessment == 1 - assert annotated_opp.workers_starting_delivery == 1 - assert annotated_opp.percentage_conversion == 100.0 - - def test_excluded_statuses(self): - self.create_user_with_access(visit_status=VisitValidationStatus.over_limit) - self.create_user_with_access(visit_status=VisitValidationStatus.trial) - - opps = get_annotated_managed_opportunity(self.program) - assert len(opps) == 1 - annotated_opp = opps[0] - assert annotated_opp.workers_invited == 2 - assert annotated_opp.workers_passing_assessment == 2 - assert annotated_opp.workers_starting_delivery == 0 - assert annotated_opp.percentage_conversion == 0.0 - - def test_average_time_to_convert(self): - for i in range(3): - user = self.create_user_with_access() - user.opportunityaccess_set.update(invited_date=now() - timedelta(days=i)) - - opps = get_annotated_managed_opportunity(self.program) - assert len(opps) == 1 - annotated_opp = opps[0] - expected_time = timedelta(days=2) - actual_time = annotated_opp.average_time_to_convert - assert abs(actual_time - expected_time) < timedelta(seconds=5) - - def test_multiple_opportunities(self): - nm_org2 = OrganizationFactory.create() - opp2 = ManagedOpportunityFactory.create( - program=self.program, organization=nm_org2, start_date=now() + timedelta(days=1) - ) - - self.create_user_with_access() - user2 = UserFactory.create() - access2 = OpportunityAccessFactory.create(opportunity=opp2, user=user2, invited_date=now()) - AssessmentFactory.create(opportunity=opp2, user=user2, opportunity_access=access2, passed=True) - UserVisitFactory.create( - user=user2, - opportunity=opp2, - status=VisitValidationStatus.pending, - opportunity_access=access2, - visit_date=now() + timedelta(days=1), - ) - - opps = get_annotated_managed_opportunity(self.program) - assert len(opps) == 2 - assert opps[0].organization.slug == self.nm_org.slug - assert opps[1].organization.slug == nm_org2.slug - for annotated_opp in opps: - assert annotated_opp.workers_invited == 1 - assert annotated_opp.workers_passing_assessment == 1 - assert annotated_opp.workers_starting_delivery == 1 - assert annotated_opp.percentage_conversion == 100.0 - - def test_failed_assessments(self): - self.create_user_with_access(passed_assessment=False) - self.create_user_with_access(passed_assessment=True) - - opps = get_annotated_managed_opportunity(self.program) - assert len(opps) == 1 - annotated_opp = opps[0] - assert annotated_opp.workers_invited == 2 - assert annotated_opp.workers_passing_assessment == 1 - assert annotated_opp.workers_starting_delivery == 2 - assert annotated_opp.percentage_conversion == 100.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}" From 98f0a2ef5919eda10ed4f02ca7ee5abbbd4175f4 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Tue, 15 Oct 2024 15:56:19 +0530 Subject: [PATCH 16/44] Resolved migration conflict --- ...invited_date.py => 0061_opportunityaccess_invited_date.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename commcare_connect/opportunity/migrations/{0060_opportunityaccess_invited_date.py => 0061_opportunityaccess_invited_date.py} (74%) diff --git a/commcare_connect/opportunity/migrations/0060_opportunityaccess_invited_date.py b/commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py similarity index 74% rename from commcare_connect/opportunity/migrations/0060_opportunityaccess_invited_date.py rename to commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py index d96766df..b5cebef6 100644 --- a/commcare_connect/opportunity/migrations/0060_opportunityaccess_invited_date.py +++ b/commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py @@ -1,11 +1,11 @@ -# Generated by Django 4.2.5 on 2024-10-07 05:04 +# Generated by Django 4.2.5 on 2024-10-15 10:25 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("opportunity", "0059_payment_amount_usd"), + ("opportunity", "0060_completedwork_payment_date"), ] operations = [ From 678dfae69217a104b099cedea076b25ece7e0f35 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Fri, 18 Oct 2024 17:16:40 +0530 Subject: [PATCH 17/44] removed redundant code --- commcare_connect/program/tests/test_helpers.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 96f7d4e7..c03e0f1e 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -53,12 +53,6 @@ def create_user_with_visit(self, visit_status, visit_date, flagged=False, create class TestGetAnnotatedManagedOpportunity(BaseManagedOpportunityTest): - def test_basic_scenario(self): - for i in range(5): - self.create_user_with_access( - visit_status=VisitValidationStatus.pending if i < 3 else VisitValidationStatus.trial - ) - @pytest.mark.parametrize( "scenario, visit_statuses, passing_assessments, expected_invited," " expected_passing, expected_delivery, expected_conversion", From 68f5b45c12dc634f87d1e5013a4599cafb6ecf1f Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Tue, 29 Oct 2024 16:58:11 +0530 Subject: [PATCH 18/44] Add start, end date fields on model, migration --- ...entunit_end_date_paymentunit_start_date.py | 22 +++++++++++++++++++ commcare_connect/opportunity/models.py | 2 ++ 2 files changed, 24 insertions(+) create mode 100644 commcare_connect/opportunity/migrations/0061_paymentunit_end_date_paymentunit_start_date.py diff --git a/commcare_connect/opportunity/migrations/0061_paymentunit_end_date_paymentunit_start_date.py b/commcare_connect/opportunity/migrations/0061_paymentunit_end_date_paymentunit_start_date.py new file mode 100644 index 00000000..6e44bd1b --- /dev/null +++ b/commcare_connect/opportunity/migrations/0061_paymentunit_end_date_paymentunit_start_date.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.5 on 2024-10-29 11:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("opportunity", "0060_completedwork_payment_date"), + ] + + operations = [ + migrations.AddField( + model_name="paymentunit", + name="end_date", + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name="paymentunit", + name="start_date", + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index 17f68978..b18cabe5 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -354,6 +354,8 @@ class PaymentUnit(models.Model): blank=True, null=True, ) + start_date = models.DateField(null=True, blank=True) + end_date = models.DateField(null=True, blank=True) class DeliverUnit(models.Model): From 02f634625a2c31e9b0452fd40f4d7df1dc8bd7d9 Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Wed, 30 Oct 2024 19:41:56 +0530 Subject: [PATCH 19/44] Add end_date to opportunity claim limits, migration --- ...tyclaimlimit_end_date_paymentunit_end_date_and_more.py} | 7 ++++++- commcare_connect/opportunity/models.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) rename commcare_connect/opportunity/migrations/{0061_paymentunit_end_date_paymentunit_start_date.py => 0061_opportunityclaimlimit_end_date_paymentunit_end_date_and_more.py} (70%) diff --git a/commcare_connect/opportunity/migrations/0061_paymentunit_end_date_paymentunit_start_date.py b/commcare_connect/opportunity/migrations/0061_opportunityclaimlimit_end_date_paymentunit_end_date_and_more.py similarity index 70% rename from commcare_connect/opportunity/migrations/0061_paymentunit_end_date_paymentunit_start_date.py rename to commcare_connect/opportunity/migrations/0061_opportunityclaimlimit_end_date_paymentunit_end_date_and_more.py index 6e44bd1b..11e615a2 100644 --- a/commcare_connect/opportunity/migrations/0061_paymentunit_end_date_paymentunit_start_date.py +++ b/commcare_connect/opportunity/migrations/0061_opportunityclaimlimit_end_date_paymentunit_end_date_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2024-10-29 11:28 +# Generated by Django 4.2.5 on 2024-10-30 14:11 from django.db import migrations, models @@ -9,6 +9,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name="opportunityclaimlimit", + name="end_date", + field=models.DateField(blank=True, null=True), + ), migrations.AddField( model_name="paymentunit", name="end_date", diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index b18cabe5..c734eff9 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -586,6 +586,7 @@ class OpportunityClaimLimit(models.Model): opportunity_claim = models.ForeignKey(OpportunityClaim, on_delete=models.CASCADE) payment_unit = models.ForeignKey(PaymentUnit, on_delete=models.CASCADE) max_visits = models.IntegerField() + end_date = models.DateField(null=True, blank=True) class Meta: unique_together = [ @@ -615,6 +616,7 @@ def create_claim_limits(cls, opportunity: Opportunity, claim: OpportunityClaim): opportunity_claim=claim, payment_unit=payment_unit, defaults={"max_visits": min(remaining, payment_unit.max_total)}, + end_date=payment_unit.end_date, ) From 66f89762909faf4f1ff9b00e36b1599811dcff02 Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Mon, 4 Nov 2024 14:42:27 +0530 Subject: [PATCH 20/44] Add start and end date checks to form processor --- commcare_connect/form_receiver/processor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/commcare_connect/form_receiver/processor.py b/commcare_connect/form_receiver/processor.py index 641213c5..2872e81e 100644 --- a/commcare_connect/form_receiver/processor.py +++ b/commcare_connect/form_receiver/processor.py @@ -245,6 +245,7 @@ def process_deliver_unit(user, xform: XForm, app: CommCareApp, opportunity: Oppo claim = OpportunityClaim.objects.get(opportunity_access=access) entity_id = deliver_unit_block.get("entity_id") entity_name = deliver_unit_block.get("entity_name") + payment_unit = deliver_unit.payment_unit user_visit = UserVisit( opportunity=opportunity, user=user, @@ -260,26 +261,25 @@ def process_deliver_unit(user, xform: XForm, app: CommCareApp, opportunity: Oppo location=xform.metadata.location, ) completed_work_needs_save = False - if opportunity.start_date > datetime.date.today(): + today = datetime.date.today() + if opportunity.start_date > today or (payment_unit.start_date and payment_unit.start_date > today): completed_work = None user_visit.status = VisitValidationStatus.trial else: completed_work, _ = CompletedWork.objects.get_or_create( opportunity_access=access, entity_id=entity_id, - payment_unit=deliver_unit.payment_unit, + payment_unit=payment_unit, defaults={ "entity_name": entity_name, }, ) user_visit.completed_work = completed_work - claim_limit = OpportunityClaimLimit.objects.get( - opportunity_claim=claim, payment_unit=completed_work.payment_unit - ) + claim_limit = OpportunityClaimLimit.objects.get(opportunity_claim=claim, payment_unit=payment_unit) if ( - counts["daily"] >= deliver_unit.payment_unit.max_daily + counts["daily"] >= payment_unit.max_daily or counts["total"] >= claim_limit.max_visits - or datetime.date.today() > claim.end_date + or (today > claim.end_date or (claim_limit.end_date and today > claim_limit.end_date)) ): user_visit.status = VisitValidationStatus.over_limit if not completed_work.status == CompletedWorkStatus.over_limit: From 515521bd9122af419f2f1b1ecd64fc6066ab184c Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Mon, 4 Nov 2024 14:42:45 +0530 Subject: [PATCH 21/44] Add tests for start and end dates on payment units --- .../tests/test_receiver_integration.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/commcare_connect/form_receiver/tests/test_receiver_integration.py b/commcare_connect/form_receiver/tests/test_receiver_integration.py index e24857da..2757a57c 100644 --- a/commcare_connect/form_receiver/tests/test_receiver_integration.py +++ b/commcare_connect/form_receiver/tests/test_receiver_integration.py @@ -3,6 +3,7 @@ from uuid import uuid4 import pytest +from django.utils.timezone import now from rest_framework.test import APIClient from commcare_connect.form_receiver.tests.test_receiver_endpoint import add_credentials @@ -602,6 +603,25 @@ def test_receiver_visit_review_status( assert visit.review_status == review_status +@pytest.mark.parametrize( + "paymentunit_options, visit_status", + [ + ({"start_date": now().date()}, VisitValidationStatus.approved), + ({"start_date": now() + datetime.timedelta(days=2)}, VisitValidationStatus.trial), + ({"end_date": now().date()}, VisitValidationStatus.approved), + ({"end_date": now() - datetime.timedelta(days=2)}, VisitValidationStatus.over_limit), + ], +) +def test_receiver_visit_payment_unit_dates( + mobile_user_with_connect_link: User, api_client: APIClient, opportunity: Opportunity, visit_status +): + form_json = get_form_json_for_payment_unit(opportunity.paymentunit_set.first()) + form_json["metadata"]["timeStart"] = now() - datetime.timedelta(minutes=2) + make_request(api_client, form_json, mobile_user_with_connect_link) + visit = UserVisit.objects.get(user=mobile_user_with_connect_link) + assert visit.status == visit_status + + def get_form_json_for_payment_unit(payment_unit): deliver_unit = DeliverUnitFactory(app=payment_unit.opportunity.deliver_app, payment_unit=payment_unit) stub = DeliverUnitStubFactory(id=deliver_unit.slug) From 28fe5917a7fa91a2dbeb66536acc3ebf6da916be Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Mon, 4 Nov 2024 15:56:54 +0530 Subject: [PATCH 22/44] Add start and end date to payment unit form --- commcare_connect/opportunity/forms.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/commcare_connect/opportunity/forms.py b/commcare_connect/opportunity/forms.py index 3e472450..dc1f4fae 100644 --- a/commcare_connect/opportunity/forms.py +++ b/commcare_connect/opportunity/forms.py @@ -611,7 +611,15 @@ def __init__(self, *args, **kwargs): class PaymentUnitForm(forms.ModelForm): class Meta: model = PaymentUnit - fields = ["name", "description", "amount", "max_total", "max_daily"] + fields = ["name", "description", "amount", "max_total", "max_daily", "start_date", "end_date"] + help_texts = { + "start_date": "Optional. If not specified opportunity start date applies to form submissions.", + "end_date": "Optional. If not specified opportunity end date applies to form submissions.", + } + widgets = { + "start_date": forms.DateInput(attrs={"type": "date", "class": "form-input"}), + "end_date": forms.DateInput(attrs={"type": "date", "class": "form-input"}), + } def __init__(self, *args, **kwargs): deliver_units = kwargs.pop("deliver_units", []) @@ -623,6 +631,7 @@ def __init__(self, *args, **kwargs): Row(Field("name")), Row(Field("description")), Row(Field("amount")), + Row(Column("start_date"), Column("end_date")), Row(Field("required_deliver_units")), Row(Field("optional_deliver_units")), Row(Field("payment_units")), @@ -683,6 +692,8 @@ def clean(self): "optional_deliver_units", error=f"{deliver_unit_obj.name} cannot be marked both Required and Optional", ) + if cleaned_data["end_date"] < now().date(): + self.add_error("end_date", "Please provide a valid end date.") return cleaned_data From 1f283a9e5d5fab09ee81c7f0ab746c2b6742b91b Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Tue, 12 Nov 2024 12:44:36 +0530 Subject: [PATCH 23/44] added distinct true in queries --- commcare_connect/program/helpers.py | 17 ++++++++--------- commcare_connect/program/tests/test_helpers.py | 7 ++++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 94382f33..e26c0cfc 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -82,12 +82,7 @@ def get_delivery_performance_report(program: Program, start_date, end_date): if end_date: date_filter &= Q(opportunityaccess__uservisit__visit_date__lte=end_date) - flagged_visits_filter = Q(opportunityaccess__uservisit__flagged=True) & ~Q( - opportunityaccess__uservisit__status__in=[ - VisitValidationStatus.rejected, - VisitValidationStatus.approved, - ] - ) + flagged_visits_filter = Q(opportunityaccess__uservisit__flagged=True) & FILTER_FOR_VALID_VISIT_DATE managed_opportunities = ( ManagedOpportunity.objects.filter(program=program) @@ -103,9 +98,13 @@ def get_delivery_performance_report(program: Program, start_date, end_date): filter=date_filter, distinct=True, ), - total_payment_units=Count("opportunityaccess__completedwork"), - total_payment_units_with_flags=Count("opportunityaccess__completedwork", filter=flagged_visits_filter), - total_payment_since_start_date=Count("opportunityaccess__completedwork", filter=date_filter), + total_payment_units=Count("opportunityaccess__completedwork", distinct=True), + total_payment_units_with_flags=Count( + "opportunityaccess__completedwork", distinct=True, filter=flagged_visits_filter + ), + total_payment_since_start_date=Count( + "opportunityaccess__completedwork", distinct=True, filter=date_filter + ), delivery_per_day_per_worker=Case( When(active_workers=0, then=Value(0)), default=Round(F("total_payment_since_start_date") / F("active_workers"), 2), diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index c03e0f1e..015c5fb2 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -27,13 +27,17 @@ def create_user_with_access(self, visit_status=VisitValidationStatus.pending, pa 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( + visit = UserVisitFactory.create( user=user, opportunity=self.opp, status=visit_status, opportunity_access=access, visit_date=now() + timedelta(days=1), ) + print("invited date:", access.invited_date) + print("invited date:", visit.visit_date) + print(visit.visit_date - access.invited_date) + print("@@@@@") return user def create_user_with_visit(self, visit_status, visit_date, flagged=False, create_completed_work=True): @@ -115,6 +119,7 @@ def test_scenarios( opps = get_annotated_managed_opportunity(self.program) assert len(opps) == 1 annotated_opp = opps[0] + print("avergae ==>:", annotated_opp.average_time_to_convert) 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}" From c78a3ba710bb94701fe0518e4b1817dd44ff72bd Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Tue, 12 Nov 2024 12:47:45 +0530 Subject: [PATCH 24/44] removed logs --- commcare_connect/program/tests/test_helpers.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 015c5fb2..c03e0f1e 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -27,17 +27,13 @@ def create_user_with_access(self, visit_status=VisitValidationStatus.pending, pa 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) - visit = UserVisitFactory.create( + UserVisitFactory.create( user=user, opportunity=self.opp, status=visit_status, opportunity_access=access, visit_date=now() + timedelta(days=1), ) - print("invited date:", access.invited_date) - print("invited date:", visit.visit_date) - print(visit.visit_date - access.invited_date) - print("@@@@@") return user def create_user_with_visit(self, visit_status, visit_date, flagged=False, create_completed_work=True): @@ -119,7 +115,6 @@ def test_scenarios( opps = get_annotated_managed_opportunity(self.program) assert len(opps) == 1 annotated_opp = opps[0] - print("avergae ==>:", annotated_opp.average_time_to_convert) 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}" From 09639ce8b8cb3ba19069b7c5c0c7548e4c9171cf Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 13 Nov 2024 07:56:12 +0530 Subject: [PATCH 25/44] added test coverage --- .../program/tests/test_helpers.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index c03e0f1e..736ecd89 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -55,7 +55,7 @@ def create_user_with_visit(self, visit_status, visit_date, flagged=False, create class TestGetAnnotatedManagedOpportunity(BaseManagedOpportunityTest): @pytest.mark.parametrize( "scenario, visit_statuses, passing_assessments, expected_invited," - " expected_passing, expected_delivery, expected_conversion", + " expected_passing, expected_delivery, expected_conversion, expected_avg_time_to_convert", [ ( "basic_scenario", @@ -65,9 +65,10 @@ class TestGetAnnotatedManagedOpportunity(BaseManagedOpportunityTest): 3, 2, 66.67, + timedelta(days=1), ), - ("empty_scenario", [], [], 0, 0, 0, 0.0), - ("multiple_visits_scenario", [VisitValidationStatus.pending], [True], 1, 1, 1, 100.0), + ("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], @@ -76,6 +77,7 @@ class TestGetAnnotatedManagedOpportunity(BaseManagedOpportunityTest): 2, 0, 0.0, + None, ), ( "failed_assessments", @@ -85,6 +87,7 @@ class TestGetAnnotatedManagedOpportunity(BaseManagedOpportunityTest): 1, 2, 100.0, + timedelta(days=1), ), ], ) @@ -97,6 +100,7 @@ def test_scenarios( 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]) @@ -115,10 +119,16 @@ def test_scenarios( 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}" + 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 From 89443e512c037d74fd530171011c7b944cf7498c Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 13 Nov 2024 08:29:14 +0530 Subject: [PATCH 26/44] changed test as per new changes --- commcare_connect/program/tests/test_helpers.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 736ecd89..61bdaa8c 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -138,7 +138,7 @@ class TestDeliveryPerformanceReport(BaseManagedOpportunityTest): @pytest.mark.parametrize( "scenario, visit_statuses, visit_date, flagged_statuses, expected_active_workers, " - "expected_total_workers, expected_flags, expected_records_flagged_percentage," + "expected_total_workers, expected_records_flagged_percentage," "total_payment_units_with_flags,total_payment_since_start_date, delivery_per_day_per_worker", [ ( @@ -148,9 +148,8 @@ class TestDeliveryPerformanceReport(BaseManagedOpportunityTest): [True] * 3 + [False] * 2, 5, 5, - 2, - 40.0, - 2, + 60.0, + 3, 5, 1.0, ), @@ -166,7 +165,6 @@ class TestDeliveryPerformanceReport(BaseManagedOpportunityTest): [False] * 4, 2, 4, - 0, 0.0, 0, 2, @@ -179,7 +177,6 @@ class TestDeliveryPerformanceReport(BaseManagedOpportunityTest): [False, True], 2, 2, - 1, 50.0, 1, 2, @@ -192,7 +189,6 @@ class TestDeliveryPerformanceReport(BaseManagedOpportunityTest): [False, False], 0, 0, - 0, 0.0, 0, 0, @@ -210,9 +206,8 @@ class TestDeliveryPerformanceReport(BaseManagedOpportunityTest): [True] * 4, 3, 3, - 2, - 66.67, - 2, + 100, + 3, 3, 1.0, ), @@ -226,7 +221,6 @@ def test_delivery_performance_report_scenarios( flagged_statuses, expected_active_workers, expected_total_workers, - expected_flags, expected_records_flagged_percentage, total_payment_units_with_flags, total_payment_since_start_date, @@ -247,7 +241,6 @@ def test_delivery_performance_report_scenarios( 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].total_payment_units_with_flags == expected_flags 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 From c424966ede84bdde1566471fb3db24b81939c9cb Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Thu, 14 Nov 2024 08:47:54 +0530 Subject: [PATCH 27/44] fixed average time to covert calc --- commcare_connect/program/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index e26c0cfc..7e0ca99a 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -37,6 +37,7 @@ 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") @@ -66,10 +67,10 @@ def get_annotated_managed_opportunity(program: Program): Subquery(earliest_visits) - F("opportunityaccess__invited_date"), output_field=DurationField() ), filter=FILTER_FOR_VALID_VISIT_DATE, + distinct=True, ), ) ) - return managed_opportunities From 26cd175658e03bae3d69dde9fcb8ff0b86a2256c Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Fri, 15 Nov 2024 16:25:19 +0530 Subject: [PATCH 28/44] fixed record % issue --- commcare_connect/program/helpers.py | 12 +++++++++--- commcare_connect/program/tests/test_helpers.py | 6 ++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 7e0ca99a..390eaebf 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -83,7 +83,11 @@ def get_delivery_performance_report(program: Program, start_date, end_date): if end_date: date_filter &= Q(opportunityaccess__uservisit__visit_date__lte=end_date) - flagged_visits_filter = Q(opportunityaccess__uservisit__flagged=True) & FILTER_FOR_VALID_VISIT_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) @@ -101,10 +105,12 @@ def get_delivery_performance_report(program: Program, start_date, end_date): ), total_payment_units=Count("opportunityaccess__completedwork", distinct=True), total_payment_units_with_flags=Count( - "opportunityaccess__completedwork", distinct=True, filter=flagged_visits_filter + "opportunityaccess__uservisit", distinct=True, filter=flagged_visits_filter ), total_payment_since_start_date=Count( - "opportunityaccess__completedwork", distinct=True, filter=date_filter + "opportunityaccess__uservisit", + distinct=True, + filter=date_filter & Q(opportunityaccess__uservisit__completed_work__isnull=False), ), delivery_per_day_per_worker=Case( When(active_workers=0, then=Value(0)), diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index 61bdaa8c..fb7f07ea 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -39,7 +39,7 @@ def create_user_with_access(self, visit_status=VisitValidationStatus.pending, pa 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()) - UserVisitFactory.create( + visit = UserVisitFactory.create( user=user, opportunity=self.opp, status=visit_status, @@ -48,7 +48,9 @@ def create_user_with_visit(self, visit_status, visit_date, flagged=False, create flagged=flagged, ) if create_completed_work: - CompletedWorkFactory.create(opportunity_access=access) + work = CompletedWorkFactory.create(opportunity_access=access) + visit.completed_work = work + visit.save() return user From af496b1addb17a83a1ff0fb286bae602979564c9 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 20 Nov 2024 11:31:43 +0530 Subject: [PATCH 29/44] added the opportunity column --- commcare_connect/program/helpers.py | 3 +- commcare_connect/program/tables.py | 32 ++++++++++++++++--- .../program/tests/test_helpers.py | 2 +- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/commcare_connect/program/helpers.py b/commcare_connect/program/helpers.py index 390eaebf..0e83471e 100644 --- a/commcare_connect/program/helpers.py +++ b/commcare_connect/program/helpers.py @@ -103,7 +103,6 @@ def get_delivery_performance_report(program: Program, start_date, end_date): filter=date_filter, distinct=True, ), - total_payment_units=Count("opportunityaccess__completedwork", distinct=True), total_payment_units_with_flags=Count( "opportunityaccess__uservisit", distinct=True, filter=flagged_visits_filter ), @@ -112,7 +111,7 @@ def get_delivery_performance_report(program: Program, start_date, end_date): distinct=True, filter=date_filter & Q(opportunityaccess__uservisit__completed_work__isnull=False), ), - delivery_per_day_per_worker=Case( + 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(), diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index 1207488a..a045700e 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -237,11 +237,12 @@ def get_manage_buttons_html(buttons, request): class FunnelPerformanceTable(tables.Table): organization = tables.Column() + opportunity = 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")) + percentage_conversion = tables.Column(verbose_name=_("% Conversion")) average_time_to_convert = tables.Column(verbose_name=_("Average Time To convert")) class Meta: @@ -249,6 +250,7 @@ class Meta: empty_text = "No data available yet." fields = ( "organization", + "opportunity", "start_date", "workers_invited", "workers_passing_assessment", @@ -265,24 +267,46 @@ def render_average_time_to_convert(self, record): hours = total_seconds / 3600 return f"{round(hours, 2)}hr" + def render_opportunity(self, record): + url = reverse( + "opportunity:detail", + kwargs={ + "org_slug": record.organization.slug, + "pk": record.id, + }, + ) + return mark_safe(f'{record.name}') + class DeliveryPerformanceTable(tables.Table): organization = tables.Column() + opportunity = tables.Column() start_date = tables.DateColumn() total_workers_starting_delivery = tables.Column(verbose_name=_("Workers Starting Delivery")) active_workers = tables.Column(verbose_name=_("Active Workers")) - delivery_per_day_per_worker = tables.Column(verbose_name=_("Delivery Per Day Per Worker")) - records_flagged_percentage = tables.Column(verbose_name=_("Records flagged")) + 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", - "delivery_per_day_per_worker", + "deliveries_per_day_per_worker", "records_flagged_percentage", ) orderable = False + + def render_opportunity(self, record): + url = reverse( + "opportunity:detail", + kwargs={ + "org_slug": record.organization.slug, + "pk": record.id, + }, + ) + return mark_safe(f'{record.name}') diff --git a/commcare_connect/program/tests/test_helpers.py b/commcare_connect/program/tests/test_helpers.py index fb7f07ea..cddfb6a2 100644 --- a/commcare_connect/program/tests/test_helpers.py +++ b/commcare_connect/program/tests/test_helpers.py @@ -246,4 +246,4 @@ def test_delivery_performance_report_scenarios( 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].delivery_per_day_per_worker == delivery_per_day_per_worker + assert opps[0].deliveries_per_day_per_worker == delivery_per_day_per_worker From 0c233ba83f45b3ec67e01fafd8c6c1a9c61094ba Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 20 Nov 2024 12:18:49 +0530 Subject: [PATCH 30/44] fix the opporunity name not displayed issue --- commcare_connect/program/tables.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index a045700e..8ba34e79 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -1,6 +1,7 @@ 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 _ @@ -237,7 +238,7 @@ def get_manage_buttons_html(buttons, request): class FunnelPerformanceTable(tables.Table): organization = tables.Column() - opportunity = tables.Column() + opportunity = tables.Column(accessor="name") start_date = tables.DateColumn() workers_invited = tables.Column(verbose_name=_("Workers Invited")) workers_passing_assessment = tables.Column(verbose_name=_("Workers Passing Assessment")) @@ -260,14 +261,7 @@ class Meta: ) 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" - - def render_opportunity(self, record): + def render_opportunity(self, value, record): url = reverse( "opportunity:detail", kwargs={ @@ -275,12 +269,19 @@ def render_opportunity(self, record): "pk": record.id, }, ) - return mark_safe(f'{record.name}') + 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() + opportunity = tables.Column(accessor="name") start_date = tables.DateColumn() total_workers_starting_delivery = tables.Column(verbose_name=_("Workers Starting Delivery")) active_workers = tables.Column(verbose_name=_("Active Workers")) @@ -301,7 +302,7 @@ class Meta: ) orderable = False - def render_opportunity(self, record): + def render_opportunity(self, value, record): url = reverse( "opportunity:detail", kwargs={ @@ -309,4 +310,4 @@ def render_opportunity(self, record): "pk": record.id, }, ) - return mark_safe(f'{record.name}') + return format_html('{}', url, value) From f8622c9c9bf33466d8e1149a8a53a502b6b53ddb Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Wed, 20 Nov 2024 18:32:39 +0530 Subject: [PATCH 31/44] chnaged column name to opportunity --- commcare_connect/program/tables.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commcare_connect/program/tables.py b/commcare_connect/program/tables.py index 8ba34e79..b899c19b 100644 --- a/commcare_connect/program/tables.py +++ b/commcare_connect/program/tables.py @@ -238,7 +238,7 @@ def get_manage_buttons_html(buttons, request): class FunnelPerformanceTable(tables.Table): organization = tables.Column() - opportunity = tables.Column(accessor="name") + 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")) @@ -281,7 +281,7 @@ def render_average_time_to_convert(self, record): class DeliveryPerformanceTable(tables.Table): organization = tables.Column() - opportunity = tables.Column(accessor="name") + 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")) From 1eff1c20095a2191c79c1a2d904af01a6440f4cd Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Thu, 21 Nov 2024 07:47:10 +0530 Subject: [PATCH 32/44] added street view and fixed opacity --- commcare_connect/static/js/project.js | 2 +- .../templates/opportunity/user_profile.html | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/commcare_connect/static/js/project.js b/commcare_connect/static/js/project.js index 83ba289b..b2665b39 100644 --- a/commcare_connect/static/js/project.js +++ b/commcare_connect/static/js/project.js @@ -67,7 +67,7 @@ window.addAccuracyCircles = addAccuracyCircles; function addCatchmentAreas(map, catchments) { const ACTIVE_COLOR = '#3366ff'; const INACTIVE_COLOR = '#ff4d4d'; - const CIRCLE_OPACITY = 0.3; + const CIRCLE_OPACITY = 0.15; map.on('load', () => { const catchmentCircles = catchments.map((catchment) => diff --git a/commcare_connect/templates/opportunity/user_profile.html b/commcare_connect/templates/opportunity/user_profile.html index fe4fd4e0..084532d0 100644 --- a/commcare_connect/templates/opportunity/user_profile.html +++ b/commcare_connect/templates/opportunity/user_profile.html @@ -45,6 +45,14 @@

{{access.display_name}}

{{access.last_visit_date}}
+
@@ -81,7 +89,7 @@
Catchment Areas
{{ user_catchments|json_script:"userCatchments" }} {% endblock %} From 76880ffb8cfaa985fbaa04b85f992d2f794ab727 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Thu, 21 Nov 2024 10:07:22 +0530 Subject: [PATCH 33/44] fixed catchement areas were disappering after change in map style --- commcare_connect/static/js/project.js | 39 ++-- .../templates/opportunity/user_profile.html | 186 ++++++++++++------ 2 files changed, 145 insertions(+), 80 deletions(-) diff --git a/commcare_connect/static/js/project.js b/commcare_connect/static/js/project.js index b2665b39..d960110d 100644 --- a/commcare_connect/static/js/project.js +++ b/commcare_connect/static/js/project.js @@ -69,14 +69,19 @@ function addCatchmentAreas(map, catchments) { const INACTIVE_COLOR = '#ff4d4d'; const CIRCLE_OPACITY = 0.15; - map.on('load', () => { - const catchmentCircles = catchments.map((catchment) => - circle([catchment.lng, catchment.lat], catchment.radius, { - units: 'meters', - properties: { active: catchment.active }, - }), - ); + const catchmentCircles = catchments.map((catchment) => + circle([catchment.lng, catchment.lat], catchment.radius, { + units: 'meters', + properties: { active: catchment.active }, + }), + ); + if (map.getSource('catchment_circles')) { + map.getSource('catchment_circles').setData({ + type: 'FeatureCollection', + features: catchmentCircles, + }); + } else { map.addSource('catchment_circles', { type: 'geojson', data: { @@ -105,17 +110,17 @@ function addCatchmentAreas(map, catchments) { 'line-opacity': 0.5, }, }); + } - if (catchments?.length) { - window.Alpine.nextTick(() => { - const legendElement = document.getElementById('legend'); - if (legendElement) { - const legendData = window.Alpine.$data(legendElement); - legendData.show = true; - } - }); - } - }); + if (catchments?.length) { + window.Alpine.nextTick(() => { + const legendElement = document.getElementById('legend'); + if (legendElement) { + const legendData = window.Alpine.$data(legendElement); + legendData.show = true; + } + }); + } } window.addCatchmentAreas = addCatchmentAreas; diff --git a/commcare_connect/templates/opportunity/user_profile.html b/commcare_connect/templates/opportunity/user_profile.html index 084532d0..174c8cf4 100644 --- a/commcare_connect/templates/opportunity/user_profile.html +++ b/commcare_connect/templates/opportunity/user_profile.html @@ -22,63 +22,112 @@ -
-
-

{{access.display_name}}

-
{{access.user.username}}
-
-
-
-
{% translate "Phone" %}
-
{{access.user.phone_number}}
-
-
-
{% translate "Learn Progress" %}
-
{{access.learn_progress}}%
-
-
-
{% translate "Total Visits" %}
-
{{access.visit_count}}
+
+
+
+ +
+
+ {{access.display_name|slice:":1"}} +
+

{{access.display_name}}

+
{{access.user.username}}
-
-
{% translate "Last Visit Date" %}
-
{{access.last_visit_date}}
+ + +
+
+
+
+ +
{% translate "Phone" %}
+
{{access.user.phone_number}}
+
+
+
+
+
+
+ +
{% translate "Learn Progress" %}
+
+
+
+
+ {{access.learn_progress}}% +
+
+
+
+
+
+
+ +
{% translate "Total Visits" %}
+
{{access.visit_count}}
+
+
+
+
+
+
+ +
{% translate "Last Visit" %}
+
{{access.last_visit_date}}
+
+
+
-
- -
-
-
-
-
Catchment Areas
-
- - Active + + +
+
+
+
Visit Locations
+
+ + +
-
- - Inactive +
+
+
+
+
Catchment Areas
+
+ + Active +
+
+ + Inactive +
+
+
+ + +
+ {% if access.suspended %} + + {% translate "Revoke Suspension" %} + + {% else %} + + {% endif %} +
- {% if access.suspended %} - - {% translate "Revoke Suspension" %} - - {% else %} - - {% endif %}
{% endblock content %} @@ -89,33 +138,44 @@
Catchment Areas
{{ user_catchments|json_script:"userCatchments" }} {% endblock %} From eecad814876217662cbe26f5366a2fff6749cdc2 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Thu, 21 Nov 2024 10:47:18 +0530 Subject: [PATCH 34/44] refactor code --- .../templates/opportunity/user_profile.html | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/commcare_connect/templates/opportunity/user_profile.html b/commcare_connect/templates/opportunity/user_profile.html index 174c8cf4..a031f3f5 100644 --- a/commcare_connect/templates/opportunity/user_profile.html +++ b/commcare_connect/templates/opportunity/user_profile.html @@ -102,11 +102,13 @@
Visit Locations
Catchment Areas
- + Active
- + Inactive
@@ -156,20 +158,21 @@
Catchment Areas
.addTo(map) }) + const setMapStyle = (styleId, targetElementId, otherElementId) => { + map.setStyle(styleId); + const targetElement = document.getElementById(targetElementId); + targetElement.classList.add('active', 'pe-none'); + + const otherElement = document.getElementById(otherElementId); + otherElement.classList.remove('active', 'pe-none'); + }; + document.getElementById('streets-v12').addEventListener('click', (e) => { - map.setStyle('mapbox://styles/mapbox/streets-v12'); - e.target.classList.add('active'); - e.target.classList.add('pe-none'); - document.getElementById('satellite-streets-v12').classList.remove('active'); - document.getElementById('satellite-streets-v12').classList.remove('pe-none'); + setMapStyle('mapbox://styles/mapbox/streets-v12', 'streets-v12', 'satellite-streets-v12'); }); document.getElementById('satellite-streets-v12').addEventListener('click', (e) => { - map.setStyle('mapbox://styles/mapbox/satellite-streets-v12'); - e.target.classList.add('active'); - e.target.classList.add('pe-none'); - document.getElementById('streets-v12').classList.remove('active'); - document.getElementById('streets-v12').classList.remove('pe-none'); + setMapStyle('mapbox://styles/mapbox/satellite-streets-v12', 'satellite-streets-v12', 'streets-v12'); }); map.on('style.load', () => { From 5ce6cde06d0cba2dde5f7bede20cbae460579926 Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Thu, 21 Nov 2024 13:40:04 +0530 Subject: [PATCH 35/44] Fix error in payment unit admin --- commcare_connect/opportunity/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commcare_connect/opportunity/admin.py b/commcare_connect/opportunity/admin.py index e833dd19..2c026ec3 100644 --- a/commcare_connect/opportunity/admin.py +++ b/commcare_connect/opportunity/admin.py @@ -127,8 +127,8 @@ def get_username(self, obj): @admin.register(PaymentUnit) class PaymentUnitAdmin(admin.ModelAdmin): list_display = ["name", "get_opp_name"] - search_fields = ["name"] + search_fields = ["name", "opportunity__name"] @admin.display(description="Opportunity Name") def get_opp_name(self, obj): - return obj.opportunity_access.opportunity.name + return obj.opportunity.name From 4ff0ae9065d0ff0b383baba0039c480be0cfc6c6 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Thu, 21 Nov 2024 14:30:31 +0530 Subject: [PATCH 36/44] used alpine @click event --- .../templates/opportunity/user_profile.html | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/commcare_connect/templates/opportunity/user_profile.html b/commcare_connect/templates/opportunity/user_profile.html index a031f3f5..714a1c68 100644 --- a/commcare_connect/templates/opportunity/user_profile.html +++ b/commcare_connect/templates/opportunity/user_profile.html @@ -83,15 +83,14 @@
{% translate "Last Visit" %}
-
-
+
Visit Locations
- -
@@ -115,7 +114,6 @@
Catchment Areas
-
@@ -143,7 +141,7 @@
Catchment Areas
mapboxgl.accessToken = "{{ MAPBOX_TOKEN }}"; const map = new mapboxgl.Map({ container: 'user-visit-map', - style: 'mapbox://styles/mapbox/streets-v12', // Default to streets style + style: 'mapbox://styles/mapbox/streets-v12', center: [{{ lng_avg }}, {{ lat_avg }}], zoom: 14, }); @@ -151,33 +149,34 @@
Catchment Areas
const userVisits = JSON.parse(document.getElementById('userVisits').textContent); const userCatchments = JSON.parse(document.getElementById('userCatchments').textContent); - userVisits.forEach(loc => { - new mapboxgl.Marker() - .setLngLat([loc.lng, loc.lat]) - .setPopup(new mapboxgl.Popup().setHTML(`${loc.entity_name}
${loc.visit_date}`)) - .addTo(map) - }) - - const setMapStyle = (styleId, targetElementId, otherElementId) => { - map.setStyle(styleId); - const targetElement = document.getElementById(targetElementId); - targetElement.classList.add('active', 'pe-none'); + map.on('load', () => { + userVisits.forEach(loc => { + new mapboxgl.Marker() + .setLngLat([loc.lng, loc.lat]) + .setPopup(new mapboxgl.Popup().setHTML(`${loc.entity_name}
${loc.visit_date}`)) + .addTo(map) + }); - const otherElement = document.getElementById(otherElementId); - otherElement.classList.remove('active', 'pe-none'); - }; - - document.getElementById('streets-v12').addEventListener('click', (e) => { - setMapStyle('mapbox://styles/mapbox/streets-v12', 'streets-v12', 'satellite-streets-v12'); + addAccuracyCircles(map, userVisits); + addCatchmentAreas(map, userCatchments); }); - document.getElementById('satellite-streets-v12').addEventListener('click', (e) => { - setMapStyle('mapbox://styles/mapbox/satellite-streets-v12', 'satellite-streets-v12', 'streets-v12'); - }); + // Watch for Alpine.js style changes + Alpine.effect(() => { + const alpineData = Alpine.$data(document.querySelector('[x-data]')); + const currentStyle = alpineData.currentStyle; + const styles = { + 'streets-v12': 'mapbox://styles/mapbox/streets-v12', + 'satellite-streets-v12': 'mapbox://styles/mapbox/satellite-streets-v12' + }; + map.setStyle(styles[currentStyle]); - map.on('style.load', () => { - addAccuracyCircles(map, userVisits); - addCatchmentAreas(map, userCatchments); + // Re-add circles and catchments after style changes + map.once('style.load', () => { + alpineData.currentStyle = currentStyle; + addAccuracyCircles(map, userVisits); + addCatchmentAreas(map, userCatchments); + }); }); }); From 1b57aec98ba6f1861f9babe62f9a8a060ef75a69 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Thu, 21 Nov 2024 21:43:03 +0530 Subject: [PATCH 37/44] fix for accuracy circles --- commcare_connect/static/js/project.js | 32 ++++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/commcare_connect/static/js/project.js b/commcare_connect/static/js/project.js index d960110d..ef6e4946 100644 --- a/commcare_connect/static/js/project.js +++ b/commcare_connect/static/js/project.js @@ -24,13 +24,22 @@ window.circle = circle; * @param {Array.<{lng: float, lat: float, precision: float}> visit_data - Visit location data for User */ function addAccuracyCircles(map, visit_data) { - map.on('load', () => { - const visit_accuracy_circles = []; - visit_data.forEach((loc) => { - visit_accuracy_circles.push( - circle([loc.lng, loc.lat], loc.precision, { units: 'meters' }), - ); + const FILL_OPACITY = 0.1; + const OUTLINE_COLOR = '#fcbf49'; + const OUTLINE_WIDTH = 3; + const OUTLINE_OPACITY = 0.5; + + const visit_accuracy_circles = visit_data.map((loc) => + circle([loc.lng, loc.lat], loc.precision, { units: 'meters' }), + ); + + // Check if the source exists, then update or add the source + if (map.getSource('visit_accuracy_circles')) { + map.getSource('visit_accuracy_circles').setData({ + type: 'FeatureCollection', + features: visit_accuracy_circles, }); + } else { map.addSource('visit_accuracy_circles', { type: 'geojson', data: { @@ -45,21 +54,22 @@ function addAccuracyCircles(map, visit_data) { type: 'fill', paint: { 'fill-antialias': true, - 'fill-opacity': 0.3, + 'fill-opacity': FILL_OPACITY, }, }); + // Add the outline layer map.addLayer({ id: 'visit-accuracy-circle-outlines-layer', source: 'visit_accuracy_circles', type: 'line', paint: { - 'line-color': '#fcbf49', - 'line-width': 3, - 'line-opacity': 0.5, + 'line-color': OUTLINE_COLOR, + 'line-width': OUTLINE_WIDTH, + 'line-opacity': OUTLINE_OPACITY, }, }); - }); + } } window.addAccuracyCircles = addAccuracyCircles; From e171f22ecaa04805ea9b7af016cb02cc1debe497 Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Mon, 25 Nov 2024 14:00:19 +0530 Subject: [PATCH 38/44] Add opportunity date tests --- .../tests/test_receiver_integration.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/commcare_connect/form_receiver/tests/test_receiver_integration.py b/commcare_connect/form_receiver/tests/test_receiver_integration.py index 2757a57c..1c1f79ed 100644 --- a/commcare_connect/form_receiver/tests/test_receiver_integration.py +++ b/commcare_connect/form_receiver/tests/test_receiver_integration.py @@ -604,13 +604,20 @@ def test_receiver_visit_review_status( @pytest.mark.parametrize( - "paymentunit_options, visit_status", + "opportunity, paymentunit_options, visit_status", [ - ({"start_date": now().date()}, VisitValidationStatus.approved), - ({"start_date": now() + datetime.timedelta(days=2)}, VisitValidationStatus.trial), - ({"end_date": now().date()}, VisitValidationStatus.approved), - ({"end_date": now() - datetime.timedelta(days=2)}, VisitValidationStatus.over_limit), + ({}, {"start_date": now().date()}, VisitValidationStatus.approved), + ({}, {"start_date": now() + datetime.timedelta(days=2)}, VisitValidationStatus.trial), + ({}, {"end_date": now().date()}, VisitValidationStatus.approved), + ({}, {"end_date": now() - datetime.timedelta(days=2)}, VisitValidationStatus.over_limit), + ({"opp_options": {"start_date": now().date()}}, {}, VisitValidationStatus.approved), + ({"opp_options": {"start_date": now() + datetime.timedelta(days=2)}}, {}, VisitValidationStatus.trial), + ({"opp_options": {"end_date": now().date()}}, {}, VisitValidationStatus.approved), + # NOTE: this test case fails as opportunities with past end_date are marked + # as inactive, and are not processed in the form processor + # ({"opp_options": {"end_date": now() - datetime.timedelta(days=2)}}, {}, VisitValidationStatus.over_limit), ], + indirect=["opportunity"], ) def test_receiver_visit_payment_unit_dates( mobile_user_with_connect_link: User, api_client: APIClient, opportunity: Opportunity, visit_status From 9d7991a553d0aa550c3fb28ad7875f7070ee0b0f Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Tue, 26 Nov 2024 13:27:19 +0530 Subject: [PATCH 39/44] Fix wrong text check causing issues in hq user creation --- commcare_connect/users/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commcare_connect/users/helpers.py b/commcare_connect/users/helpers.py index 5cfbe50b..04264954 100644 --- a/commcare_connect/users/helpers.py +++ b/commcare_connect/users/helpers.py @@ -34,7 +34,7 @@ def create_hq_user(user, domain, api_key): try: hq_request.raise_for_status() except httpx.HTTPStatusError as e: - if e.response.status_code == 400 and "already exists" in e.response.text: + if e.response.status_code == 400 and "already taken" in e.response.text: return True raise CommCareHQAPIException( f"{e.response.status_code} Error response {e.response.text} while creating user {user.username}" From fe18fc6fd656d3f303f7bb4919a4461d4d68f9e2 Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Wed, 27 Nov 2024 17:45:08 +0530 Subject: [PATCH 40/44] Fix add check for no end_date --- commcare_connect/opportunity/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commcare_connect/opportunity/forms.py b/commcare_connect/opportunity/forms.py index dc1f4fae..056c8453 100644 --- a/commcare_connect/opportunity/forms.py +++ b/commcare_connect/opportunity/forms.py @@ -692,7 +692,7 @@ def clean(self): "optional_deliver_units", error=f"{deliver_unit_obj.name} cannot be marked both Required and Optional", ) - if cleaned_data["end_date"] < now().date(): + if cleaned_data["end_date"] and cleaned_data["end_date"] < now().date(): self.add_error("end_date", "Please provide a valid end date.") return cleaned_data From 0d7b3589e04a747f356b8ad059cfcea1b652b6f0 Mon Sep 17 00:00:00 2001 From: Pawan Verma Date: Wed, 27 Nov 2024 19:23:46 +0530 Subject: [PATCH 41/44] Remove commented code --- .../form_receiver/tests/test_receiver_integration.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/commcare_connect/form_receiver/tests/test_receiver_integration.py b/commcare_connect/form_receiver/tests/test_receiver_integration.py index 1c1f79ed..7af90d3a 100644 --- a/commcare_connect/form_receiver/tests/test_receiver_integration.py +++ b/commcare_connect/form_receiver/tests/test_receiver_integration.py @@ -613,9 +613,6 @@ def test_receiver_visit_review_status( ({"opp_options": {"start_date": now().date()}}, {}, VisitValidationStatus.approved), ({"opp_options": {"start_date": now() + datetime.timedelta(days=2)}}, {}, VisitValidationStatus.trial), ({"opp_options": {"end_date": now().date()}}, {}, VisitValidationStatus.approved), - # NOTE: this test case fails as opportunities with past end_date are marked - # as inactive, and are not processed in the form processor - # ({"opp_options": {"end_date": now() - datetime.timedelta(days=2)}}, {}, VisitValidationStatus.over_limit), ], indirect=["opportunity"], ) From 7ce05e763dd84e0402f376103543bc7db6a64760 Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Thu, 28 Nov 2024 12:53:08 +0530 Subject: [PATCH 42/44] fix migration order --- ...invited_date.py => 0062_opportunityaccess_invited_date.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename commcare_connect/opportunity/migrations/{0061_opportunityaccess_invited_date.py => 0062_opportunityaccess_invited_date.py} (69%) diff --git a/commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py b/commcare_connect/opportunity/migrations/0062_opportunityaccess_invited_date.py similarity index 69% rename from commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py rename to commcare_connect/opportunity/migrations/0062_opportunityaccess_invited_date.py index b5cebef6..12ef7484 100644 --- a/commcare_connect/opportunity/migrations/0061_opportunityaccess_invited_date.py +++ b/commcare_connect/opportunity/migrations/0062_opportunityaccess_invited_date.py @@ -1,11 +1,11 @@ -# Generated by Django 4.2.5 on 2024-10-15 10:25 +# Generated by Django 4.2.5 on 2024-11-28 07:22 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("opportunity", "0060_completedwork_payment_date"), + ("opportunity", "0061_opportunityclaimlimit_end_date_paymentunit_end_date_and_more"), ] operations = [ From 8c161e6e31a6c4baaf95053061429369f66d7f2b Mon Sep 17 00:00:00 2001 From: hemant10yadav Date: Fri, 29 Nov 2024 13:49:17 +0530 Subject: [PATCH 43/44] added the org slug in table --- commcare_connect/opportunity/tables.py | 16 +++++++++------- commcare_connect/opportunity/views.py | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/commcare_connect/opportunity/tables.py b/commcare_connect/opportunity/tables.py index a856650f..ca9586ad 100644 --- a/commcare_connect/opportunity/tables.py +++ b/commcare_connect/opportunity/tables.py @@ -374,7 +374,7 @@ class Meta: ) -class UserVisitReviewTable(tables.Table): +class UserVisitReviewTable(OrgContextTable): pk = columns.CheckBoxColumn( accessor="pk", verbose_name="", @@ -389,12 +389,7 @@ class UserVisitReviewTable(tables.Table): visit_date = columns.Column() created_on = columns.Column(accessor="review_created_on", verbose_name="Review Requested On") review_status = columns.Column(verbose_name="Program Manager Review") - user_visit = columns.LinkColumn( - "opportunity:visit_verification", - verbose_name="User Visit", - text="View", - args=[utils.A("opportunity__organization__slug"), utils.A("pk")], - ) + user_visit = columns.Column(verbose_name="User Visit", empty_values=()) class Meta: model = UserVisit @@ -412,6 +407,13 @@ class Meta: ) empty_text = "No visits submitted for review." + def render_user_visit(self, record): + url = reverse( + "opportunity:visit_verification", + kwargs={"org_slug": self.org_slug, "pk": record.pk}, + ) + return mark_safe(f'View') + class PaymentReportTable(tables.Table): payment_unit = columns.Column(verbose_name="Payment Unit") diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index a53c0050..645ba4ef 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -1112,7 +1112,7 @@ def user_visit_review(request, org_slug, opp_id): user_visit_reviews = UserVisit.objects.filter(opportunity=opportunity, review_created_on__isnull=False).order_by( "visit_date" ) - table = UserVisitReviewTable(user_visit_reviews) + table = UserVisitReviewTable(user_visit_reviews, org_slug=request.org.slug) if not is_program_manager: table.exclude = ("pk",) if request.POST and is_program_manager: From 41f5c25e1e24c1fd9c2e3931332d757d7211f597 Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Fri, 29 Nov 2024 16:15:38 +0530 Subject: [PATCH 44/44] Check for None value --- commcare_connect/form_receiver/processor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commcare_connect/form_receiver/processor.py b/commcare_connect/form_receiver/processor.py index 2872e81e..aa8c844a 100644 --- a/commcare_connect/form_receiver/processor.py +++ b/commcare_connect/form_receiver/processor.py @@ -262,7 +262,8 @@ def process_deliver_unit(user, xform: XForm, app: CommCareApp, opportunity: Oppo ) completed_work_needs_save = False today = datetime.date.today() - if opportunity.start_date > today or (payment_unit.start_date and payment_unit.start_date > today): + paymentunit_startdate = payment_unit.start_date if payment_unit else None + if opportunity.start_date > today or (paymentunit_startdate and paymentunit_startdate > today): completed_work = None user_visit.status = VisitValidationStatus.trial else: