Skip to content

Commit

Permalink
Merge branch 'main' into sr/events
Browse files Browse the repository at this point in the history
  • Loading branch information
sravfeyn committed Aug 15, 2024
2 parents 51b7719 + 8d448cb commit 3fb92a8
Show file tree
Hide file tree
Showing 19 changed files with 233 additions and 83 deletions.
26 changes: 14 additions & 12 deletions commcare_connect/form_receiver/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,16 +302,16 @@ def process_deliver_unit(user, xform: XForm, app: CommCareApp, opportunity: Oppo
or counts["total"] >= claim_limit.max_visits
or datetime.date.today() > claim.end_date
):
user_visit.update_status(VisitValidationStatus.over_limit)
user_visit.status = VisitValidationStatus.over_limit
if not completed_work.status == CompletedWorkStatus.over_limit:
completed_work.update_status(CompletedWorkStatus.over_limit)
completed_work.status = CompletedWorkStatus.over_limit
completed_work_needs_save = True
elif counts["entity"] > 0:
user_visit.update_status(VisitValidationStatus.duplicate)
user_visit.status = VisitValidationStatus.duplicate
flags = clean_form_submission(access, user_visit, xform)
if access.suspended:
flags.append(["user_suspended", "This user is suspended from the opportunity."])
user_visit.update_status(VisitValidationStatus.rejected)
user_visit.status = VisitValidationStatus.rejected
if flags:
user_visit.flagged = True
user_visit.flag_reason = {"flags": flags}
Expand All @@ -321,15 +321,17 @@ def process_deliver_unit(user, xform: XForm, app: CommCareApp, opportunity: Oppo
and user_visit.status == VisitValidationStatus.pending
and not user_visit.flagged
):
user_visit.update_status(VisitValidationStatus.approved)
user_visit.status = VisitValidationStatus.approved
user_visit.save()

if completed_work is not None:
if completed_work.completed_count > 0 and completed_work.status == CompletedWorkStatus.incomplete:
completed_work.update_status(CompletedWorkStatus.pending)
completed_work_needs_save = True
if completed_work_needs_save:
completed_work.save()
if (
completed_work is not None
and completed_work.completed_count > 0
and completed_work.status == CompletedWorkStatus.incomplete
):
completed_work.status = CompletedWorkStatus.pending
completed_work_needs_save = True
if completed_work_needs_save:
completed_work.save()
download_user_visit_attachments.delay(user_visit.id)


Expand Down
7 changes: 7 additions & 0 deletions commcare_connect/opportunity/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Opportunity,
OpportunityAccess,
OpportunityClaim,
OpportunityClaimLimit,
Payment,
PaymentUnit,
UserInvite,
Expand Down Expand Up @@ -74,9 +75,15 @@ class LearnModuleAndDeliverUnitAdmin(admin.ModelAdmin):
search_fields = ["name"]


class OpportunityClaimLimitInline(admin.TabularInline):
list_display = ["payment_unit", "max_visit"]
model = OpportunityClaimLimit


@admin.register(OpportunityClaim)
class OpportunityClaimAdmin(admin.ModelAdmin):
list_display = ["get_username", "get_opp_name", "opportunity_access"]
inlines = [OpportunityClaimLimitInline]

@admin.display(description="Opportunity Name")
def get_opp_name(self, obj):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.core.management import BaseCommand

from commcare_connect.opportunity.models import CompletedWorkStatus, Opportunity, OpportunityAccess
from commcare_connect.opportunity.utils.completed_work import update_status


class Command(BaseCommand):
help = "Run auto-approval logic over opportunity"

def add_arguments(self, parser):
parser.add_argument(
"--opp", type=int, required=True, help="ID of the opportunity to run auto-approval logic on"
)

def handle(self, *args, opp: int, **options):
try:
opportunity = Opportunity.objects.get(id=opp)
access_objects = OpportunityAccess.objects.filter(
opportunity=opportunity, suspended=False, opportunity__auto_approve_payments=True
)
for access in access_objects:
completed_works = access.completedwork_set.exclude(
status__in=[CompletedWorkStatus.rejected, CompletedWorkStatus.over_limit]
)
update_status(completed_works, access, False)

self.stdout.write(self.style.SUCCESS(f"Successfully processed opportunity with id {opp}"))

except Opportunity.DoesNotExist:
self.stdout.write(self.style.ERROR(f"Opportunity with id {opp} does not exist."))
22 changes: 18 additions & 4 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,9 +420,16 @@ class CompletedWork(models.Model):
reason = models.CharField(max_length=300, null=True, blank=True)
status_modified_date = models.DateTimeField(null=True)

def update_status(self, status):
self.status = status
def __init__(self, *args, **kwargs):
self.status = CompletedWorkStatus.incomplete
self.status_modified_date = now()
super().__init__(*args, **kwargs)

def __setattr__(self, name, value):
if name == "status":
if getattr(self, "status", None) != value: # Check if status has changed
self.status_modified_date = now()
super().__setattr__(name, value)

# TODO: add caching on this property
@property
Expand Down Expand Up @@ -521,9 +528,16 @@ class UserVisit(XFormBaseModel):
completed_work = models.ForeignKey(CompletedWork, on_delete=models.DO_NOTHING, null=True, blank=True)
status_modified_date = models.DateTimeField(null=True)

def update_status(self, status):
self.status = status
def __init__(self, *args, **kwargs):
self.status = VisitValidationStatus.pending
self.status_modified_date = now()
super().__init__(*args, **kwargs)

def __setattr__(self, name, value):
if name == "status":
if getattr(self, "status", None) != value:
self.status_modified_date = now()
super().__setattr__(name, value)

@property
def images(self):
Expand Down
16 changes: 2 additions & 14 deletions commcare_connect/opportunity/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
UserVisit,
VisitValidationStatus,
)
from commcare_connect.opportunity.utils.completed_work import update_status
from commcare_connect.users.models import User
from commcare_connect.utils.datetime import is_date_before
from commcare_connect.utils.sms import send_sms
Expand Down Expand Up @@ -331,20 +332,7 @@ def bulk_approve_completed_work():
completed_works = access.completedwork_set.exclude(
status__in=[CompletedWorkStatus.rejected, CompletedWorkStatus.over_limit]
)
access.payment_accrued = 0
for completed_work in completed_works:
if completed_work.completed_count > 0:
approved_count = completed_work.approved_count
visits = completed_work.uservisit_set.values_list("status", "reason")
if any(status == "rejected" for status, _ in visits):
completed_work.update_status(CompletedWorkStatus.rejected)
completed_work.reason = "\n".join(reason for _, reason in visits if reason)
elif all(status == "approved" for status, _ in visits):
completed_work.update_status(CompletedWorkStatus.approved)
if approved_count > 0 and completed_work.status == CompletedWorkStatus.approved:
access.payment_accrued += approved_count * completed_work.payment_unit.amount
completed_work.save()
access.save()
update_status(completed_works, access, True)


@celery_app.task()
Expand Down
36 changes: 34 additions & 2 deletions commcare_connect/opportunity/tests/test_visit_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
from itertools import chain

import pytest
from django.utils.timezone import now
from tablib import Dataset

from commcare_connect.conftest import MobileUserFactory
from commcare_connect.opportunity.models import (
CatchmentArea,
CompletedWork,
CompletedWorkStatus,
Opportunity,
OpportunityAccess,
Payment,
PaymentUnit,
UserVisit,
VisitValidationStatus,
)
Expand All @@ -27,6 +30,7 @@
from commcare_connect.opportunity.visit_import import (
ImportException,
_bulk_update_catchments,
_bulk_update_completed_work_status,
_bulk_update_payments,
_bulk_update_visit_status,
get_status_by_visit_id,
Expand All @@ -48,10 +52,38 @@ def test_bulk_update_visit_status(opportunity: Opportunity, mobile_user: User):
dataset = Dataset(headers=["visit id", "status", "rejected reason"])
dataset.extend([[visit.xform_id, VisitValidationStatus.approved.value, ""] for visit in visits])

before_update = now()
import_status = _bulk_update_visit_status(opportunity, dataset)
after_update = now()

assert not import_status.missing_visits
after_status = set(UserVisit.objects.filter(opportunity=opportunity).values_list("status", flat=True))
assert after_status == {VisitValidationStatus.approved.value}

updated_visits = UserVisit.objects.filter(opportunity=opportunity)
for visit in updated_visits:
assert visit.status == VisitValidationStatus.approved.value
assert visit.status_modified_date is not None
assert before_update <= visit.status_modified_date <= after_update


@pytest.mark.django_db
def test_bulk_update_completed_work_status(opportunity: Opportunity, mobile_user: User):
access = OpportunityAccess.objects.get(user=mobile_user, opportunity=opportunity)
payment_unit = PaymentUnit.objects.get(opportunity=opportunity)
DeliverUnitFactory(payment_unit=payment_unit, app=opportunity.deliver_app, optional=False)

completed_works = CompletedWorkFactory.create_batch(5, opportunity_access=access, payment_unit=payment_unit)
dataset = Dataset(headers=["instance id", "payment approval", "rejected reason"])
dataset.extend([[work.id, CompletedWorkStatus.approved.value, ""] for work in completed_works])

before_update = now()
_bulk_update_completed_work_status(opportunity=opportunity, dataset=dataset)
after_update = now()

updated_work = CompletedWork.objects.filter(opportunity_access=access)
for work in updated_work:
assert work.status == CompletedWorkStatus.approved.value
assert work.status_modified_date is not None
assert before_update <= work.status_modified_date <= after_update


@pytest.mark.django_db
Expand Down
Empty file.
34 changes: 34 additions & 0 deletions commcare_connect/opportunity/utils/completed_work.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from commcare_connect.opportunity.models import CompletedWorkStatus


def update_status(completed_works, opportunity_access, compute_payment=True):
"""
Updates the status of completed works and optionally calculates & update total payment_accrued.
"""
payment_accrued = 0
for completed_work in completed_works:
if completed_work.completed_count < 1:
continue

if opportunity_access.opportunity.auto_approve_payments:
update_completed_work_status(completed_work)

if compute_payment:
approved_count = completed_work.approved_count
if approved_count > 0 and completed_work.status == CompletedWorkStatus.approved:
payment_accrued += approved_count * completed_work.payment_unit.amount

if compute_payment:
opportunity_access.payment_accrued = payment_accrued
opportunity_access.save()


def update_completed_work_status(completed_work):
visits = completed_work.uservisit_set.values_list("status", "reason")
if any(status == "rejected" for status, _ in visits):
completed_work.status = CompletedWorkStatus.rejected
completed_work.reason = "\n".join(reason for _, reason in visits if reason)
elif all(status == "approved" for status, _ in visits):
completed_work.status = CompletedWorkStatus.approved

completed_work.save()
3 changes: 3 additions & 0 deletions commcare_connect/opportunity/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,9 @@ def add_payment_unit(request, org_slug=None, pk=None):
parent_payment_unit=form.instance.id
)
messages.success(request, f"Payment unit {form.instance.name} created.")
claims = OpportunityClaim.objects.filter(opportunity_access__opportunity=opportunity)
for claim in claims:
OpportunityClaimLimit.create_claim_limits(opportunity, claim)
return redirect("opportunity:add_payment_units", org_slug=request.org.slug, pk=opportunity.id)
elif request.POST:
messages.error(request, "Invalid Data")
Expand Down
26 changes: 6 additions & 20 deletions commcare_connect/opportunity/visit_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
VisitValidationStatus,
)
from commcare_connect.opportunity.tasks import send_payment_notification
from commcare_connect.opportunity.utils.completed_work import update_status
from commcare_connect.utils.file import get_file_extension
from commcare_connect.utils.itertools import batched

Expand Down Expand Up @@ -130,7 +131,7 @@ def _bulk_update_visit_status(opportunity: Opportunity, dataset: Dataset):
changed = False

if visit.status != status:
visit.update_status(status)
visit.status = status
changed = True

if status == VisitValidationStatus.rejected and reason and reason != visit.reason:
Expand All @@ -141,7 +142,7 @@ def _bulk_update_visit_status(opportunity: Opportunity, dataset: Dataset):
to_update.append(visit)
user_ids.add(visit.user_id)

UserVisit.objects.bulk_update(to_update, fields=["status", "reason"])
UserVisit.objects.bulk_update(to_update, fields=["status", "reason", "status_modified_date"])
missing_visits |= set(visit_batch) - seen_visits
update_payment_accrued(opportunity, users=user_ids)

Expand All @@ -155,23 +156,8 @@ def update_payment_accrued(opportunity: Opportunity, users):
completed_works = access.completedwork_set.exclude(
status__in=[CompletedWorkStatus.rejected, CompletedWorkStatus.over_limit]
).select_related("payment_unit")
access.payment_accrued = 0
for completed_work in completed_works:
# Auto Approve Payment conditions
if completed_work.completed_count > 0:
if opportunity.auto_approve_payments:
visits = completed_work.uservisit_set.values_list("status", "reason")
if any(status == "rejected" for status, _ in visits):
completed_work.update_status(CompletedWorkStatus.rejected)
completed_work.reason = "\n".join(reason for _, reason in visits if reason)
elif all(status == "approved" for status, _ in visits):
completed_work.update_status(CompletedWorkStatus.approved)
approved_count = completed_work.approved_count
if approved_count > 0 and completed_work.status == CompletedWorkStatus.approved:
access.payment_accrued += approved_count * completed_work.payment_unit.amount
completed_work.save()
access.save()
Event(event_type=Event.Type.PAYMENT_ACCRUED, user=access.user, opportunity=access.opportunity).save()
update_status(completed_works, access, True)


def get_status_by_visit_id(dataset) -> dict[int, VisitValidationStatus]:
Expand Down Expand Up @@ -294,7 +280,7 @@ def _bulk_update_completed_work_status(opportunity: Opportunity, dataset: Datase
changed = False

if completed_work.status != status:
completed_work.update_status(status)
completed_work.status = status
changed = True
if status == CompletedWorkStatus.rejected and reason and reason != completed_work.reason:
completed_work.reason = reason
Expand All @@ -303,7 +289,7 @@ def _bulk_update_completed_work_status(opportunity: Opportunity, dataset: Datase
if changed:
to_update.append(completed_work)
user_ids.add(completed_work.opportunity_access.user_id)
CompletedWork.objects.bulk_update(to_update, fields=["status", "reason"])
CompletedWork.objects.bulk_update(to_update, fields=["status", "reason", "status_modified_date"])
missing_completed_works |= set(work_batch) - seen_completed_works
update_payment_accrued(opportunity, users=user_ids)
return CompletedWorkImportStatus(seen_completed_works, missing_completed_works)
Expand Down
13 changes: 13 additions & 0 deletions commcare_connect/organization/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,16 @@ def clean_users(self):
user_data = self.cleaned_data["users"]
split_users = [line.strip() for line in user_data.splitlines() if line.strip()]
return split_users


OrganizationCreationForm = forms.modelform_factory(
Organization,
fields=("name",),
labels={"name": "Organization Name"},
help_texts={
"name": (
"This would be used to create the Organization URL,"
" and you will not be able to change the URL in future."
)
},
)
20 changes: 19 additions & 1 deletion commcare_connect/organization/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,29 @@
from commcare_connect import connect_id_client
from commcare_connect.connect_id_client.models import Credential
from commcare_connect.organization.decorators import org_admin_required
from commcare_connect.organization.forms import AddCredentialForm, MembershipForm, OrganizationChangeForm
from commcare_connect.organization.forms import (
AddCredentialForm,
MembershipForm,
OrganizationChangeForm,
OrganizationCreationForm,
)
from commcare_connect.organization.models import Organization, UserOrganizationMembership
from commcare_connect.organization.tasks import add_credential_task, send_org_invite


@login_required
def organization_create(request):
form = OrganizationCreationForm(data=request.POST or None)

if form.is_valid():
org = form.save(commit=False)
org.save()
org.members.add(request.user, through_defaults={"role": UserOrganizationMembership.Role.ADMIN})
return redirect("opportunity:list", org.slug)

return render(request, "organization/organization_create.html", context={"form": form})


@org_admin_required
def organization_home(request, org_slug):
org = get_object_or_404(Organization, slug=org_slug)
Expand Down
Loading

0 comments on commit 3fb92a8

Please sign in to comment.