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

{% trans "Dashboard" %}

+
+
+ +
+
+
+ {% include "tables/table_placeholder.html" with num_cols=6 %} +
+
+ +
+
+
+ + +
+
+ + +
+ +
+
+
+ {% include "tables/table_placeholder.html" with num_cols=7 %} +
+
+
+
+
+{% endblock content %} 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}"