Skip to content

Commit

Permalink
Merge main; resolve conflict
Browse files Browse the repository at this point in the history
  • Loading branch information
sravfeyn committed Dec 5, 2024
2 parents 9fb1dc4 + 41b7557 commit c403e34
Show file tree
Hide file tree
Showing 18 changed files with 870 additions and 97 deletions.
6 changes: 4 additions & 2 deletions commcare_connect/form_receiver/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions commcare_connect/form_receiver/tests/test_receiver_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions commcare_connect/opportunity/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 12 additions & 1 deletion commcare_connect/opportunity/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [])
Expand All @@ -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")),
Expand Down Expand Up @@ -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


Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.5 on 2024-11-28 07:22

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("opportunity", "0061_opportunityclaimlimit_end_date_paymentunit_end_date_and_more"),
]

operations = [
migrations.AddField(
model_name="opportunityaccess",
name="invited_date",
field=models.DateTimeField(auto_now_add=True, null=True),
),
]
5 changes: 5 additions & 0 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ class OpportunityAccess(models.Model):
suspended = models.BooleanField(default=False)
suspension_date = models.DateTimeField(null=True, blank=True)
suspension_reason = models.CharField(max_length=300, null=True, blank=True)
invited_date = models.DateTimeField(auto_now_add=True, editable=False, null=True)

class Meta:
indexes = [models.Index(fields=["invite_id"])]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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,
)


Expand Down
16 changes: 9 additions & 7 deletions commcare_connect/opportunity/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ class Meta:
)


class UserVisitReviewTable(tables.Table):
class UserVisitReviewTable(OrgContextTable):
pk = columns.CheckBoxColumn(
accessor="pk",
verbose_name="",
Expand All @@ -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
Expand All @@ -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'<a href="{url}">View</a>')


class PaymentReportTable(tables.Table):
payment_unit = columns.Column(verbose_name="Payment Unit")
Expand Down
2 changes: 1 addition & 1 deletion commcare_connect/opportunity/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
125 changes: 125 additions & 0 deletions commcare_connect/program/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from django.db.models import (
Avg,
Case,
Count,
DurationField,
ExpressionWrapper,
F,
FloatField,
OuterRef,
Q,
Subquery,
Value,
When,
)
from django.db.models.functions import Cast, Round

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

EXCLUDED_STATUS = [
VisitValidationStatus.over_limit,
VisitValidationStatus.trial,
]

FILTER_FOR_VALID_VISIT_DATE = ~Q(opportunityaccess__uservisit__status__in=EXCLUDED_STATUS)


def calculate_safe_percentage(numerator, denominator):
return Case(
When(**{denominator: 0}, then=Value(0)), # Handle division by zero
default=Round(Cast(F(numerator), FloatField()) / Cast(F(denominator), FloatField()) * 100, 2),
output_field=FloatField(),
)


def get_annotated_managed_opportunity(program: Program):
earliest_visits = (
UserVisit.objects.filter(
opportunity_access=OuterRef("opportunityaccess"),
user=OuterRef("opportunityaccess__uservisit__user"),
)
.exclude(status__in=EXCLUDED_STATUS)
.order_by("visit_date")
.values("visit_date")[:1]
)

managed_opportunities = (
ManagedOpportunity.objects.filter(program=program)
.order_by("start_date")
.annotate(
workers_invited=Count("opportunityaccess", distinct=True),
workers_passing_assessment=Count(
"opportunityaccess__assessment",
filter=Q(
opportunityaccess__assessment__passed=True,
),
distinct=True,
),
workers_starting_delivery=Count(
"opportunityaccess__uservisit__user",
filter=FILTER_FOR_VALID_VISIT_DATE,
distinct=True,
),
percentage_conversion=calculate_safe_percentage("workers_starting_delivery", "workers_invited"),
average_time_to_convert=Avg(
ExpressionWrapper(
Subquery(earliest_visits) - F("opportunityaccess__invited_date"), output_field=DurationField()
),
filter=FILTER_FOR_VALID_VISIT_DATE,
distinct=True,
),
)
)
return managed_opportunities


def get_delivery_performance_report(program: Program, start_date, end_date):
date_filter = FILTER_FOR_VALID_VISIT_DATE

if start_date:
date_filter &= Q(opportunityaccess__uservisit__visit_date__gte=start_date)

if end_date:
date_filter &= Q(opportunityaccess__uservisit__visit_date__lte=end_date)

flagged_visits_filter = (
Q(opportunityaccess__uservisit__flagged=True)
& date_filter
& Q(opportunityaccess__uservisit__completed_work__isnull=False)
)

managed_opportunities = (
ManagedOpportunity.objects.filter(program=program)
.order_by("start_date")
.annotate(
total_workers_starting_delivery=Count(
"opportunityaccess__uservisit__user",
filter=FILTER_FOR_VALID_VISIT_DATE,
distinct=True,
),
active_workers=Count(
"opportunityaccess__uservisit__user",
filter=date_filter,
distinct=True,
),
total_payment_units_with_flags=Count(
"opportunityaccess__uservisit", distinct=True, filter=flagged_visits_filter
),
total_payment_since_start_date=Count(
"opportunityaccess__uservisit",
distinct=True,
filter=date_filter & Q(opportunityaccess__uservisit__completed_work__isnull=False),
),
deliveries_per_day_per_worker=Case(
When(active_workers=0, then=Value(0)),
default=Round(F("total_payment_since_start_date") / F("active_workers"), 2),
output_field=FloatField(),
),
records_flagged_percentage=calculate_safe_percentage(
"total_payment_units_with_flags", "total_payment_since_start_date"
),
)
)

return managed_opportunities
Loading

0 comments on commit c403e34

Please sign in to comment.