diff --git a/README.md b/README.md index a2450d14..6e32ed21 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,19 @@ For details on how this actions is configured see: - https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/ - https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services +### Deploying to the staging environment + +The project has a staging environment at [https://connect-staging.dimagi.com/](https://connect-staging.dimagi.com/), +which is connected to the staging environment of CommCare HQ at +[https://staging.commcarehq.org/](https://staging.commcarehq.org/). + +By convention, the `pkv/staging` branch is used for changes that are on the staging environment. +To put your own changes on the staging environment, you can create merge your own branch into +`pkv/staging` and then push it to GitHub. + +After that, you can deploy to the staging environment by manually running the `deploy` +[workflow from here](https://github.com/dimagi/commcare-connect/actions/workflows/deploy.yml). + ### Custom Bootstrap Compilation The generated CSS is set up with automatic Bootstrap recompilation with variables of your choice. diff --git a/commcare_connect/conftest.py b/commcare_connect/conftest.py index 8c91a1df..c984262b 100644 --- a/commcare_connect/conftest.py +++ b/commcare_connect/conftest.py @@ -10,6 +10,7 @@ PaymentUnitFactory, ) from commcare_connect.organization.models import Organization +from commcare_connect.program.tests.factories import ManagedOpportunityFactory from commcare_connect.users.models import User from commcare_connect.users.tests.factories import ( ConnectIdUserLinkFactory, @@ -48,9 +49,15 @@ def user(db) -> User: @pytest.fixture() -def opportunity(): - factory = OpportunityFactory(is_test=False) - OpportunityVerificationFlagsFactory(opportunity=factory) +def opportunity(request): + verification_flags = getattr(request, "param", {}).get("verification_flags", {}) + opp_options = {"is_test": False} + opp_options.update(getattr(request, "param", {}).get("opp_options", {})) + if opp_options.get("managed", False): + factory = ManagedOpportunityFactory(**opp_options) + else: + factory = OpportunityFactory(**opp_options) + OpportunityVerificationFlagsFactory(opportunity=factory, **verification_flags) return factory diff --git a/commcare_connect/connect_id_client/main.py b/commcare_connect/connect_id_client/main.py index 82b2d401..29f08c0a 100644 --- a/commcare_connect/connect_id_client/main.py +++ b/commcare_connect/connect_id_client/main.py @@ -54,8 +54,11 @@ def add_credential(organization: Organization, credential: str, users: list[str] return -def fetch_credentials(): - response = _make_request(GET, "/users/fetch_credentials") +def fetch_credentials(org_slug=None): + params = {} + if org_slug: + params["org_slug"] = org_slug + response = _make_request(GET, "/users/fetch_credentials", params=params) data = response.json() return [Credential(**c) for c in data["credentials"]] diff --git a/commcare_connect/form_receiver/processor.py b/commcare_connect/form_receiver/processor.py index 641213c5..a45f6f3c 100644 --- a/commcare_connect/form_receiver/processor.py +++ b/commcare_connect/form_receiver/processor.py @@ -29,6 +29,7 @@ VisitValidationStatus, ) from commcare_connect.opportunity.tasks import download_user_visit_attachments +from commcare_connect.opportunity.visit_import import update_payment_accrued from commcare_connect.users.models import User LEARN_MODULE_JSONPATH = parse("$..module") @@ -242,7 +243,9 @@ def process_deliver_unit(user, xform: XForm, app: CommCareApp, opportunity: Oppo entity=Count("pk", filter=Q(entity_id=deliver_unit_block.get("entity_id"), deliver_unit=deliver_unit)), ) ) + payment_unit = deliver_unit.payment_unit claim = OpportunityClaim.objects.get(opportunity_access=access) + claim_limit = OpportunityClaimLimit.objects.get(opportunity_claim=claim, payment_unit=payment_unit) entity_id = deliver_unit_block.get("entity_id") entity_name = deliver_unit_block.get("entity_name") user_visit = UserVisit( @@ -260,26 +263,23 @@ 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: completed_work, _ = CompletedWork.objects.get_or_create( opportunity_access=access, entity_id=entity_id, - payment_unit=deliver_unit.payment_unit, - defaults={ - "entity_name": entity_name, - }, + 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 - ) 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: @@ -303,15 +303,13 @@ def process_deliver_unit(user, xform: XForm, app: CommCareApp, opportunity: Oppo user_visit.status = VisitValidationStatus.approved user_visit.review_status = VisitReviewStatus.agree user_visit.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() + if completed_work is not None: + if 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() + update_payment_accrued(opportunity, [user.id]) download_user_visit_attachments.delay(user_visit.id) diff --git a/commcare_connect/form_receiver/tests/test_receiver_integration.py b/commcare_connect/form_receiver/tests/test_receiver_integration.py index c52af2a5..2daac9b5 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 @@ -23,6 +24,7 @@ OpportunityClaimLimit, OpportunityVerificationFlags, UserVisit, + VisitReviewStatus, VisitValidationStatus, ) from commcare_connect.opportunity.tasks import bulk_approve_completed_work @@ -172,24 +174,14 @@ def test_receiver_deliver_form_daily_visits_reached( def test_receiver_deliver_form_max_visits_reached( mobile_user_with_connect_link: User, api_client: APIClient, opportunity: Opportunity ): - def form_json(payment_unit): - deliver_unit = DeliverUnitFactory(app=opportunity.deliver_app, payment_unit=payment_unit) - stub = DeliverUnitStubFactory(id=deliver_unit.slug) - form_json = get_form_json( - form_block=stub.json, - domain=deliver_unit.app.cc_domain, - app_id=deliver_unit.app.cc_app_id, - ) - return form_json - def submit_form_for_random_entity(form_json): duplicate_json = deepcopy(form_json) duplicate_json["form"]["deliver"]["entity_id"] = str(uuid4()) make_request(api_client, duplicate_json, mobile_user_with_connect_link) payment_units = opportunity.paymentunit_set.all() - form_json1 = form_json(payment_units[0]) - form_json2 = form_json(payment_units[1]) + form_json1 = get_form_json_for_payment_unit(payment_units[0]) + form_json2 = get_form_json_for_payment_unit(payment_units[1]) for _ in range(2): submit_form_for_random_entity(form_json1) submit_form_for_random_entity(form_json2) @@ -351,8 +343,11 @@ def test_auto_approve_payments_approved_visit( assert access.payment_accrued == completed_work.payment_accrued -def test_auto_approve_payments_rejected_visit( - user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity +@pytest.mark.parametrize( + "update_func, args_required", [(update_payment_accrued, True), (bulk_approve_completed_work, False)] +) +def test_auto_approve_payments_rejected_visit_functions( + user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity, update_func, args_required ): form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) form_json["metadata"]["timeEnd"] = "2023-06-07T12:36:10.178000Z" @@ -366,17 +361,10 @@ def test_auto_approve_payments_rejected_visit( rejected_reason.append(visit.reason) visit.save() - duplicate_json = deepcopy(form_json) - duplicate_json["id"] = str(uuid4()) - make_request(api_client, duplicate_json, user_with_connectid_link) - visit = UserVisit.objects.get(xform_id=duplicate_json["id"]) - visit.status = VisitValidationStatus.rejected - visit.reason = "duplicate" - rejected_reason.append(visit.reason) - visit.save() - # Payment Approval - update_payment_accrued(opportunity, users=[user_with_connectid_link]) + args = (opportunity, [user_with_connectid_link]) if args_required else () + update_func(*args) + access = OpportunityAccess.objects.get(user=user_with_connectid_link, opportunity=opportunity) completed_work = CompletedWork.objects.get(opportunity_access=access) assert completed_work.status == CompletedWorkStatus.rejected @@ -406,40 +394,6 @@ def test_auto_approve_payments_approved_visit_task( assert access.payment_accrued == completed_work.payment_accrued -def test_auto_approve_payments_rejected_visit_task( - user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity -): - form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) - form_json["metadata"]["timeEnd"] = "2023-06-07T12:36:10.178000Z" - opportunity.auto_approve_payments = True - opportunity.save() - make_request(api_client, form_json, user_with_connectid_link) - rejected_reason = [] - visit = UserVisit.objects.get(user=user_with_connectid_link) - visit.status = VisitValidationStatus.rejected - visit.reason = "rejected" - rejected_reason.append(visit.reason) - visit.save() - - duplicate_json = deepcopy(form_json) - duplicate_json["id"] = str(uuid4()) - make_request(api_client, duplicate_json, user_with_connectid_link) - visit = UserVisit.objects.get(xform_id=duplicate_json["id"]) - visit.status = VisitValidationStatus.rejected - visit.reason = "duplicate" - rejected_reason.append(visit.reason) - visit.save() - - # Payment Approval - bulk_approve_completed_work() - access = OpportunityAccess.objects.get(user=user_with_connectid_link, opportunity=opportunity) - completed_work = CompletedWork.objects.get(opportunity_access=access) - assert completed_work.status == CompletedWorkStatus.rejected - for reason in rejected_reason: - assert reason in completed_work.reason - assert access.payment_accrued == completed_work.payment_accrued - - def test_auto_approve_visits_and_payments( user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity ): @@ -460,58 +414,50 @@ def test_auto_approve_visits_and_payments( assert access.payment_accrued == completed_work.payment_accrued +@pytest.mark.parametrize( + "opportunity", + [ + { + "verification_flags": { + "form_submission_start": datetime.time(10, 0), + "form_submission_end": datetime.time(14, 0), + } + } + ], + indirect=True, +) +@pytest.mark.parametrize( + "submission_time_hour, expected_message", + [ + (11, None), + (9, "Form was submitted before the start time"), + (15, "Form was submitted after the end time"), + ], +) def test_reciever_verification_flags_form_submission( - user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity + user_with_connectid_link: User, + api_client: APIClient, + opportunity: Opportunity, + submission_time_hour, + expected_message, ): - verification_flags = OpportunityVerificationFlags.objects.get(opportunity=opportunity) - verification_flags.form_submission_start = datetime.time(hour=10, minute=0) - verification_flags.form_submission_end = datetime.time(hour=12, minute=0) - verification_flags.save() - form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) - time = datetime.datetime(2024, 4, 17, 10, 0, 0) - form_json["metadata"]["timeStart"] = time - form_json["metadata"]["timeEnd"] = time + datetime.timedelta(minutes=10) - make_request(api_client, form_json, user_with_connectid_link) - visit = UserVisit.objects.get(user=user_with_connectid_link) - assert not visit.flagged - + submission_time = datetime.datetime(2024, 5, 17, hour=submission_time_hour, minute=0) + form_json["metadata"]["timeStart"] = submission_time -def test_reciever_verification_flags_form_submission_start( - user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity -): - verification_flags = OpportunityVerificationFlags.objects.get(opportunity=opportunity) - verification_flags.form_submission_start = datetime.time(hour=10, minute=0) - verification_flags.form_submission_end = datetime.time(hour=12, minute=0) - verification_flags.save() - - form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) - time = datetime.datetime(2024, 4, 17, 9, 0, 0) - form_json["metadata"]["timeStart"] = time make_request(api_client, form_json, user_with_connectid_link) - visit = UserVisit.objects.get(user=user_with_connectid_link) - assert visit.flagged - assert ["form_submission_period", "Form was submitted before the start time"] in visit.flag_reason.get("flags", []) - - -def test_reciever_verification_flags_form_submission_end( - user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity -): - verification_flags = OpportunityVerificationFlags.objects.get(opportunity=opportunity) - verification_flags.form_submission_start = datetime.time(hour=10, minute=0) - verification_flags.form_submission_end = datetime.time(hour=12, minute=0) - verification_flags.save() - form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) - time = datetime.datetime(2024, 4, 17, 13, 0, 0) - form_json["metadata"]["timeStart"] = time - make_request(api_client, form_json, user_with_connectid_link) visit = UserVisit.objects.get(user=user_with_connectid_link) - assert visit.flagged - assert ["form_submission_period", "Form was submitted after the end time"] in visit.flag_reason.get("flags", []) + # Assert based on the expected message + if expected_message is None: + assert not visit.flagged + else: + assert visit.flagged + assert ["form_submission_period", expected_message] in visit.flag_reason.get("flags", []) -def test_reciever_verification_flags_duration( + +def test_receiver_verification_flags_duration( user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity ): form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) @@ -524,7 +470,7 @@ def test_reciever_verification_flags_duration( assert ["duration", "The form was completed too quickly."] in visit.flag_reason.get("flags", []) -def test_reciever_verification_flags_check_attachments( +def test_receiver_verification_flags_check_attachments( user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity ): form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) @@ -537,7 +483,7 @@ def test_reciever_verification_flags_check_attachments( assert ["attachment_missing", "Form was submitted without attachements."] in visit.flag_reason.get("flags", []) -def test_reciever_verification_flags_form_json_rule( +def test_receiver_verification_flags_form_json_rule( user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity ): form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) @@ -555,7 +501,7 @@ def test_reciever_verification_flags_form_json_rule( assert not visit.flagged -def test_reciever_verification_flags_form_json_rule_flagged( +def test_receiver_verification_flags_form_json_rule_flagged( user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity ): form_json = _create_opp_and_form_json(opportunity, user=user_with_connectid_link) @@ -577,7 +523,7 @@ def test_reciever_verification_flags_form_json_rule_flagged( ] in visit.flag_reason.get("flags", []) -def test_reciever_verification_flags_catchment_areas( +def test_receiver_verification_flags_catchment_areas( user_with_connectid_link: User, api_client: APIClient, opportunity: Opportunity ): verification_flags = OpportunityVerificationFlags.objects.get(opportunity=opportunity) @@ -596,6 +542,63 @@ def test_reciever_verification_flags_catchment_areas( assert ["catchment", "Visit outside worker catchment areas"] in visit.flag_reason.get("flags", []) +@pytest.mark.parametrize("opportunity", [{"opp_options": {"managed": True, "org_pay_per_visit": 2}}], indirect=True) +@pytest.mark.parametrize( + "visit_status, review_status", + [ + (VisitValidationStatus.approved, VisitReviewStatus.agree), + (VisitValidationStatus.pending, VisitReviewStatus.pending), + ], +) +def test_receiver_visit_review_status( + mobile_user_with_connect_link: User, api_client: APIClient, opportunity: Opportunity, visit_status, review_status +): + assert opportunity.managed + form_json = get_form_json_for_payment_unit(opportunity.paymentunit_set.first()) + if visit_status != VisitValidationStatus.approved: + form_json["metadata"]["location"] = None + make_request(api_client, form_json, mobile_user_with_connect_link) + visit = UserVisit.objects.get(user=mobile_user_with_connect_link) + if visit_status != VisitValidationStatus.approved: + assert visit.flagged + assert visit.status == visit_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) + form_json = get_form_json( + form_block=stub.json, + domain=deliver_unit.app.cc_domain, + app_id=deliver_unit.app.cc_app_id, + ) + return form_json + + def _get_form_json(learn_app, module_id, form_block=None): form_json = get_form_json( form_block=form_block or LearnModuleJsonFactory(id=module_id).json, diff --git a/commcare_connect/opportunity/admin.py b/commcare_connect/opportunity/admin.py index 6bb084a5..0e407574 100644 --- a/commcare_connect/opportunity/admin.py +++ b/commcare_connect/opportunity/admin.py @@ -10,6 +10,7 @@ DeliverUnitFlagRules, DeliveryType, FormJsonValidationRules, + HQApiKey, LearnModule, Opportunity, OpportunityAccess, @@ -26,11 +27,11 @@ admin.site.register(CommCareApp) -admin.site.register(PaymentUnit) admin.site.register(UserInvite) admin.site.register(DeliveryType) admin.site.register(DeliverUnitFlagRules) admin.site.register(FormJsonValidationRules) +admin.site.register(HQApiKey) @admin.register(Opportunity) @@ -48,6 +49,7 @@ class OpportunityAccessAdmin(admin.ModelAdmin): form = OpportunityAccessCreationForm list_display = ["get_opp_name", "get_username"] actions = ["clear_user_progress"] + search_fields = ["user__username"] @admin.display(description="Opportunity Name") def get_opp_name(self, obj): @@ -102,6 +104,7 @@ class CompletedModuleAdmin(admin.ModelAdmin): @admin.register(UserVisit) class UserVisitAdmin(admin.ModelAdmin): list_display = ["deliver_unit", "user", "opportunity", "status"] + search_fields = ["opportunity_access__user__username", "opportunity_access__opportunity__name"] @admin.register(Assessment) @@ -112,6 +115,7 @@ class AssessmentAdmin(admin.ModelAdmin): @admin.register(CompletedWork) class CompletedWorkAdmin(admin.ModelAdmin): list_display = ["get_username", "get_opp_name", "opportunity_access", "payment_unit", "status"] + search_fields = ["opportunity_access__user__username", "opportunity_access__opportunity__name"] @admin.display(description="Opportunity Name") def get_opp_name(self, obj): @@ -120,3 +124,13 @@ def get_opp_name(self, obj): @admin.display(description="Username") def get_username(self, obj): return obj.opportunity_access.user.username + + +@admin.register(PaymentUnit) +class PaymentUnitAdmin(admin.ModelAdmin): + list_display = ["name", "get_opp_name"] + search_fields = ["name", "opportunity__name"] + + @admin.display(description="Opportunity Name") + def get_opp_name(self, obj): + return obj.opportunity.name diff --git a/commcare_connect/opportunity/api/serializers.py b/commcare_connect/opportunity/api/serializers.py index d7cc92dc..d67d74b7 100644 --- a/commcare_connect/opportunity/api/serializers.py +++ b/commcare_connect/opportunity/api/serializers.py @@ -15,6 +15,7 @@ OpportunityAccess, OpportunityClaim, OpportunityClaimLimit, + OpportunityVerificationFlags, Payment, PaymentUnit, UserVisit, @@ -91,6 +92,12 @@ class Meta: fields = ["id", "name", "latitude", "longitude", "radius", "active"] +class OpportunityVerificationFlagsSerializer(serializers.ModelSerializer): + class Meta: + model = OpportunityVerificationFlags + fields = ["form_submission_start", "form_submission_end"] + + class OpportunitySerializer(serializers.ModelSerializer): organization = serializers.SlugRelatedField(read_only=True, slug_field="slug") learn_app = CommCareAppSerializer() @@ -105,6 +112,7 @@ class OpportunitySerializer(serializers.ModelSerializer): payment_units = serializers.SerializerMethodField() is_user_suspended = serializers.SerializerMethodField() catchment_areas = serializers.SerializerMethodField() + verification_flags = OpportunityVerificationFlagsSerializer(source="opportunityverificationflags", read_only=True) class Meta: model = Opportunity @@ -134,6 +142,7 @@ class Meta: "payment_units", "is_user_suspended", "catchment_areas", + "verification_flags", ] def get_claim(self, obj): diff --git a/commcare_connect/opportunity/forms.py b/commcare_connect/opportunity/forms.py index fa3cf8be..7743d077 100644 --- a/commcare_connect/opportunity/forms.py +++ b/commcare_connect/opportunity/forms.py @@ -33,7 +33,8 @@ class OpportunityUserInviteForm(forms.Form): def __init__(self, *args, **kwargs): - credentials = connect_id_client.fetch_credentials() + org_slug = kwargs.pop("org_slug", None) + credentials = connect_id_client.fetch_credentials(org_slug) super().__init__(*args, **kwargs) self.helper = FormHelper(self) @@ -73,7 +74,10 @@ def clean_users(self): return split_users -class OpportunityChangeForm(forms.ModelForm, OpportunityUserInviteForm): +class OpportunityChangeForm( + OpportunityUserInviteForm, + forms.ModelForm, +): class Meta: model = Opportunity fields = [ @@ -616,7 +620,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", []) @@ -628,6 +640,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")), @@ -688,6 +701,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/management/commands/auto_approval_opportunities.py b/commcare_connect/opportunity/management/commands/auto_approval_opportunities.py index 1292700c..48002a9e 100644 --- a/commcare_connect/opportunity/management/commands/auto_approval_opportunities.py +++ b/commcare_connect/opportunity/management/commands/auto_approval_opportunities.py @@ -9,20 +9,30 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - "--opp", type=int, required=True, help="ID of the opportunity to run auto-approval logic on" + "--opp", + type=int, + required=True, + help="ID of the opportunity to run auto-approval logic on", ) + parser.add_argument( + "--include-over-limit", action="store_true", help="Also run auto-approval logic on over limit works" + ) + parser.add_argument("--update-payment", action="store_true", help="Update payment accrued") def handle(self, *args, opp: int, **options): + include_over_limit = options.get("include_over_limit", False) + update_payment = options.get("update_payment", False) + excluded = [CompletedWorkStatus.rejected] + if not include_over_limit: + excluded.append(CompletedWorkStatus.over_limit) 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) + completed_works = access.completedwork_set.exclude(status__in=excluded) + update_status(completed_works, access, update_payment) self.stdout.write(self.style.SUCCESS(f"Successfully processed opportunity with id {opp}")) diff --git a/commcare_connect/opportunity/management/commands/delete_duplicate_visits.py b/commcare_connect/opportunity/management/commands/delete_duplicate_visits.py new file mode 100644 index 00000000..0dc0d5d7 --- /dev/null +++ b/commcare_connect/opportunity/management/commands/delete_duplicate_visits.py @@ -0,0 +1,62 @@ +from django.core.management import BaseCommand +from django.db import transaction +from django.db.models import Count + +from commcare_connect.opportunity.models import UserVisit + + +class Command(BaseCommand): + help = "Clean up duplicate visits." + + def add_arguments(self, parser, *args, **kwargs): + parser.add_argument("--opp", type=int, help="Opportunity ID to clean up duplicate visits.") + parser.add_argument( + "--dry-run", + action="store_true", + help="If set, just print the visits that would be deleted without actually deleting them.", + ) + + def handle(self, *args, **options): + opportunity_id = options.get("opp") + dry_run = options.get("dry_run") + + duplicates = ( + UserVisit.objects.filter(opportunity_id=opportunity_id) + .values("opportunity", "entity_id", "deliver_unit", "xform_id") + .annotate(visit_count=Count("id")) + .filter(visit_count__gt=1) + ) + + if dry_run: + self.stdout.write("Running in dry-run mode. No records will be deleted.") + else: + self.stdout.write("Attention: Records will be deleted!!") + + with transaction.atomic(): + for duplicate in duplicates: + visits = UserVisit.objects.filter( + opportunity_id=opportunity_id, + entity_id=duplicate["entity_id"], + deliver_unit=duplicate["deliver_unit"], + xform_id=duplicate["xform_id"], + ).order_by("id") + + visits_to_delete = visits[1:] + + for visit in visits_to_delete: + message = ( + f"Identified duplicate visit: id={visit.id}, " + f"xform_id={visit.xform_id}, entity_id={visit.entity_id}, " + f"deliver_unit={visit.deliver_unit}, status={visit.status}" + ) + self.stdout.write(message) + + if not dry_run: + visit.delete() + + if not dry_run: + self.stdout.write( + self.style.SUCCESS(f"Duplicate visits for opportunity {opportunity_id} deleted successfully.") + ) + else: + self.stdout.write(f"Dry-run complete for opportunity {opportunity_id}") diff --git a/commcare_connect/opportunity/management/commands/update_completed_work_paid_date.py b/commcare_connect/opportunity/management/commands/update_completed_work_paid_date.py new file mode 100644 index 00000000..000d1e4f --- /dev/null +++ b/commcare_connect/opportunity/management/commands/update_completed_work_paid_date.py @@ -0,0 +1,23 @@ +from django.core.management import BaseCommand +from django.db import transaction + +from commcare_connect.opportunity.models import OpportunityAccess +from commcare_connect.opportunity.utils.completed_work import update_work_payment_date + + +class Command(BaseCommand): + help = "Updates paid dates from payments for all opportunity accesses" + + def handle(self, *args, **kwargs): + try: + with transaction.atomic(): + accesses = OpportunityAccess.objects.all() + self.stdout.write("Starting to process to update the paid date...") + + for access in accesses: + update_work_payment_date(access) + + self.stdout.write("Process completed") + + except Exception as e: + self.stdout.write(self.style.ERROR(f"An error occurred: {str(e)}")) diff --git a/commcare_connect/opportunity/migrations/0060_completedwork_payment_date.py b/commcare_connect/opportunity/migrations/0060_completedwork_payment_date.py index d8c8622a..d1478347 100644 --- a/commcare_connect/opportunity/migrations/0060_completedwork_payment_date.py +++ b/commcare_connect/opportunity/migrations/0060_completedwork_payment_date.py @@ -1,16 +1,6 @@ # Generated by Django 4.2.5 on 2024-10-07 08:54 -from django.db import migrations, models, transaction - -from commcare_connect.opportunity.models import OpportunityAccess -from commcare_connect.opportunity.utils.completed_work import update_work_payment_date - - -@transaction.atomic -def update_paid_date_from_payments(apps, schema_editor): - accesses = OpportunityAccess.objects.all() - for access in accesses: - update_work_payment_date(access) +from django.db import migrations, models class Migration(migrations.Migration): @@ -24,5 +14,4 @@ class Migration(migrations.Migration): name="payment_date", field=models.DateTimeField(null=True), ), - migrations.RunPython(update_paid_date_from_payments, migrations.RunPython.noop), ] 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 db2b2462..448fef1e 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -161,7 +161,16 @@ def approved_visits(self): @property def number_of_users(self): - return self.total_budget / self.budget_per_user + if not self.managed: + return self.total_budget / self.budget_per_user + + budget_per_user = 0 + payment_units = self.paymentunit_set.all() + org_pay = self.managedopportunity.org_pay_per_visit + for pu in payment_units: + budget_per_user += pu.max_total * (pu.amount + org_pay) + + return self.total_budget / budget_per_user @property def allotted_visits(self): @@ -237,6 +246,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"])] @@ -356,6 +366,11 @@ 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 class DeliverUnit(models.Model): @@ -586,6 +601,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 +631,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..c4f19f2e 100644 --- a/commcare_connect/opportunity/tables.py +++ b/commcare_connect/opportunity/tables.py @@ -153,7 +153,7 @@ class UserStatusTable(OrgContextTable): passed_assessment = BooleanAggregateColumn(verbose_name="Passed Assessment") started_delivery = AggregateColumn(verbose_name="Started Delivery", accessor="date_deliver_started") last_visit_date = columns.Column(accessor="last_visit_date_d") - view_profile = columns.Column("View Profile", empty_values=(), footer=lambda table: f"Invited: {len(table.rows)}") + view_profile = columns.Column("", empty_values=(), footer=lambda table: f"Invited: {len(table.rows)}") class Meta: model = UserInvite @@ -173,22 +173,29 @@ class Meta: orderable = False def render_display_name(self, record): - if record.opportunity_access is None: - return record.phone_number - if not record.opportunity_access.accepted: + if not getattr(record.opportunity_access, "accepted", False): return "---" return record.opportunity_access.display_name def render_view_profile(self, record): - if record.opportunity_access is None: - return "---" - if not record.opportunity_access.accepted: - return "---" + if not getattr(record.opportunity_access, "accepted", False): + invite_delete_url = reverse( + "opportunity:user_invite_delete", + args=(self.org_slug, record.opportunity.id, record.id), + ) + return format_html( + ( + '' + ), + invite_delete_url, + ) url = reverse( "opportunity:user_profile", kwargs={"org_slug": self.org_slug, "opp_id": record.opportunity.id, "pk": record.opportunity_access_id}, ) - return format_html('View Profile', url) + return format_html('View Profile', url) def render_started_learning(self, record, value): return date_with_time_popup(self, value) @@ -374,7 +381,7 @@ class Meta: ) -class UserVisitReviewTable(tables.Table): +class UserVisitReviewTable(OrgContextTable): pk = columns.CheckBoxColumn( accessor="pk", verbose_name="", @@ -389,12 +396,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 +414,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/tests/factories.py b/commcare_connect/opportunity/tests/factories.py index daa64837..1a7cb7df 100644 --- a/commcare_connect/opportunity/tests/factories.py +++ b/commcare_connect/opportunity/tests/factories.py @@ -72,6 +72,8 @@ class Meta: class OpportunityVerificationFlagsFactory(DjangoModelFactory): opportunity = SubFactory(OpportunityFactory) + form_submission_start = None # Default to None + form_submission_end = None # Default to None class Meta: model = "opportunity.OpportunityVerificationFlags" @@ -131,6 +133,7 @@ class UserVisitFactory(DjangoModelFactory): visit_date = Faker("date_time", tzinfo=timezone.utc) form_json = Faker("pydict", value_types=[str, int, float, bool]) xform_id = Faker("uuid4") + completed_work = SubFactory(CompletedWorkFactory) class Meta: model = "opportunity.UserVisit" @@ -231,7 +234,7 @@ class Meta: class PaymentFactory(DjangoModelFactory): opportunity_access = SubFactory(OpportunityAccessFactory) amount = Faker("pyint", min_value=1, max_value=10000) - date_paid = Faker("past_date") + date_paid = Faker("date_time", tzinfo=timezone.utc) class Meta: model = "opportunity.Payment" diff --git a/commcare_connect/opportunity/tests/test_api_views.py b/commcare_connect/opportunity/tests/test_api_views.py index e7d26a88..0471ebbd 100644 --- a/commcare_connect/opportunity/tests/test_api_views.py +++ b/commcare_connect/opportunity/tests/test_api_views.py @@ -146,8 +146,22 @@ def test_learn_progress_endpoint(mobile_user: User, api_client: APIClient): assert list(response.data["assessments"][0].keys()) == ["date", "score", "passing_score", "passed"] +@pytest.mark.parametrize( + "opportunity", + [ + { + "verification_flags": { + "form_submission_start": datetime.time(10, 0), + "form_submission_end": datetime.time(14, 0), + } + } + ], + indirect=True, +) def test_opportunity_list_endpoint( - mobile_user_with_connect_link: User, api_client: APIClient, opportunity: Opportunity + mobile_user_with_connect_link: User, + api_client: APIClient, + opportunity: Opportunity, ): api_client.force_authenticate(mobile_user_with_connect_link) response = api_client.get("/api/opportunity/") @@ -160,6 +174,11 @@ def test_opportunity_list_endpoint( assert response.data[0]["budget_per_visit"] == max([pu.amount for pu in payment_units]) claim_limits = OpportunityClaimLimit.objects.filter(opportunity_claim__opportunity_access__opportunity=opportunity) assert response.data[0]["claim"]["max_payments"] == sum([cl.max_visits for cl in claim_limits]) + verification_flags = opportunity.opportunityverificationflags + assert response.data[0]["verification_flags"]["form_submission_start"] == str( + verification_flags.form_submission_start + ) + assert response.data[0]["verification_flags"]["form_submission_end"] == str(verification_flags.form_submission_end) def test_delivery_progress_endpoint( diff --git a/commcare_connect/opportunity/tests/test_commands.py b/commcare_connect/opportunity/tests/test_commands.py new file mode 100644 index 00000000..ace7e60e --- /dev/null +++ b/commcare_connect/opportunity/tests/test_commands.py @@ -0,0 +1,66 @@ +from io import StringIO +from uuid import uuid4 + +import pytest +from django.core.management import call_command + +from commcare_connect.opportunity.models import UserVisit +from commcare_connect.opportunity.tests.factories import DeliverUnitFactory, UserVisitFactory + + +@pytest.fixture +def setup_opportunity_with_duplicates(db): + def _setup(opportunity, xform_id, entity_id, deliver_unit, num_duplicates): + first_visit = UserVisitFactory.create( + opportunity=opportunity, deliver_unit=deliver_unit, xform_id=xform_id, entity_id=entity_id + ) + for _ in range(num_duplicates - 1): + UserVisitFactory.create( + opportunity=opportunity, deliver_unit=deliver_unit, xform_id=xform_id, entity_id=entity_id + ) + return first_visit + + return _setup + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "num_duplicates,expected_remaining,dry_run", + [ + (1, 1, True), # No duplicates, dry-run mode + (2, 2, True), # One duplicate, dry-run mode + (3, 3, True), # Two duplicates, dry-run mode + (2, 1, False), # One duplicate, actual deletion + (3, 1, False), # Two duplicates, actual deletion + ], +) +def test_delete_duplicate_visits( + opportunity, setup_opportunity_with_duplicates, num_duplicates, expected_remaining, dry_run +): + xform_id = str(uuid4()) + entity_id = str(uuid4()) + deliver_unit = DeliverUnitFactory() + + first_visit = setup_opportunity_with_duplicates(opportunity, xform_id, entity_id, deliver_unit, num_duplicates) + + out = StringIO() + + if dry_run: + call_command("delete_duplicate_visits", "--opp", str(opportunity.id), "--dry-run", stdout=out) + else: + call_command("delete_duplicate_visits", "--opp", str(opportunity.id), stdout=out) + + remaining_visits = UserVisit.objects.filter( + opportunity=opportunity, entity_id=entity_id, deliver_unit=deliver_unit, xform_id=xform_id + ) + + # Verify the count of remaining visits matches the expectation + assert remaining_visits.count() == expected_remaining + + if not dry_run: + # Ensure the first visit is still present after actual deletion + assert remaining_visits.filter(id=first_visit.id).exists() + assert f"Duplicate visits for opportunity {opportunity.id} deleted successfully." in out.getvalue() + else: + assert remaining_visits.count() == num_duplicates + assert f"Dry-run complete for opportunity {opportunity.id}" in out.getvalue() diff --git a/commcare_connect/opportunity/tests/test_forms.py b/commcare_connect/opportunity/tests/test_forms.py index a8f422be..58f6db95 100644 --- a/commcare_connect/opportunity/tests/test_forms.py +++ b/commcare_connect/opportunity/tests/test_forms.py @@ -5,8 +5,8 @@ import pytest from factory.fuzzy import FuzzyDate, FuzzyText -from commcare_connect.opportunity.forms import OpportunityCreationForm -from commcare_connect.opportunity.tests.factories import ApplicationFactory +from commcare_connect.opportunity.forms import OpportunityChangeForm, OpportunityCreationForm +from commcare_connect.opportunity.tests.factories import ApplicationFactory, CommCareAppFactory, OpportunityFactory class TestOpportunityCreationForm: @@ -111,3 +111,197 @@ def test_save(self, user, organization): ) form.is_valid() form.save() + + +@pytest.mark.django_db +class TestOpportunityChangeForm: + @pytest.fixture(autouse=True) + def setup_credentials_mock(self, monkeypatch): + self.mock_credentials = [ + type("Credential", (), {"slug": "cert1", "name": "Work for test"}), + type("Credential", (), {"slug": "cert2", "name": "Work for test"}), + ] + monkeypatch.setattr( + "commcare_connect.connect_id_client.fetch_credentials", lambda org_slug: self.mock_credentials + ) + + @pytest.fixture + def valid_opportunity(self, organization): + return OpportunityFactory( + organization=organization, + active=True, + learn_app=CommCareAppFactory(cc_app_id="test_learn_app"), + deliver_app=CommCareAppFactory(cc_app_id="test_deliver_app"), + name="Test Opportunity", + description="Test Description", + short_description="Short Description", + currency="USD", + is_test=False, + end_date=datetime.date.today() + datetime.timedelta(days=30), + ) + + @pytest.fixture + def base_form_data(self, valid_opportunity): + return { + "name": "Updated Opportunity", + "description": "Updated Description", + "short_description": "Updated Short Description", + "active": True, + "currency": "EUR", + "is_test": False, + "delivery_type": valid_opportunity.delivery_type.id, + "additional_users": 5, + "end_date": (datetime.date.today() + datetime.timedelta(days=60)).isoformat(), + "users": "+1234567890\n+9876543210", + "filter_country": "US", + "filter_credential": "cert1", + } + + def test_form_initialization(self, valid_opportunity, organization): + form = OpportunityChangeForm(instance=valid_opportunity, org_slug=organization.slug) + expected_fields = { + "name", + "description", + "short_description", + "active", + "currency", + "is_test", + "delivery_type", + "additional_users", + "end_date", + "users", + "filter_country", + "filter_credential", + } + assert all(field in form.fields for field in expected_fields) + + expected_initial = { + "name": valid_opportunity.name, + "description": valid_opportunity.description, + "short_description": valid_opportunity.short_description, + "active": valid_opportunity.active, + "currency": valid_opportunity.currency, + "is_test": valid_opportunity.is_test, + "delivery_type": valid_opportunity.delivery_type.id, + "end_date": valid_opportunity.end_date.isoformat(), + "filter_country": [""], + "filter_credential": [""], + } + assert all(form.initial.get(key) == value for key, value in expected_initial.items()) + + @pytest.mark.parametrize( + "field", + [ + "name", + "description", + "short_description", + "currency", + ], + ) + def test_required_fields(self, valid_opportunity, organization, field, base_form_data): + data = base_form_data.copy() + data[field] = "" + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + assert not form.is_valid() + assert field in form.errors + + @pytest.mark.parametrize( + "test_data", + [ + pytest.param( + { + "field": "additional_users", + "value": "invalid", + "error_expected": True, + "error_message": "Enter a whole number.", + }, + id="invalid_additional_users", + ), + pytest.param( + { + "field": "end_date", + "value": "invalid-date", + "error_expected": True, + "error_message": "Enter a valid date.", + }, + id="invalid_end_date", + ), + pytest.param( + { + "field": "users", + "value": " +1234567890 \n +9876543210 ", + "error_expected": False, + "expected_clean": ["+1234567890", "+9876543210"], + }, + id="valid_users_with_whitespace", + ), + ], + ) + def test_field_validation(self, valid_opportunity, organization, base_form_data, test_data): + data = base_form_data.copy() + data[test_data["field"]] = test_data["value"] + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + if test_data["error_expected"]: + assert not form.is_valid() + assert test_data["error_message"] in str(form.errors[test_data["field"]]) + else: + assert form.is_valid() + if "expected_clean" in test_data: + assert form.cleaned_data[test_data["field"]] == test_data["expected_clean"] + + @pytest.mark.parametrize( + "app_scenario", + [ + pytest.param( + { + "active_app_ids": ("unique_app1", "unique_app2"), + "new_app_ids": ("different_app1", "different_app2"), + "expected_valid": True, + }, + id="unique_apps", + ), + pytest.param( + { + "active_app_ids": ("shared_app1", "shared_app2"), + "new_app_ids": ("shared_app1", "shared_app2"), + "expected_valid": False, + }, + id="reused_apps", + ), + ], + ) + def test_app_reuse_validation(self, organization, base_form_data, app_scenario): + OpportunityFactory( + organization=organization, + active=True, + learn_app=CommCareAppFactory(cc_app_id=app_scenario["active_app_ids"][0]), + deliver_app=CommCareAppFactory(cc_app_id=app_scenario["active_app_ids"][1]), + ) + + inactive_opp = OpportunityFactory( + organization=organization, + active=False, + learn_app=CommCareAppFactory(cc_app_id=app_scenario["new_app_ids"][0]), + deliver_app=CommCareAppFactory(cc_app_id=app_scenario["new_app_ids"][1]), + ) + + form = OpportunityChangeForm(data=base_form_data, instance=inactive_opp, org_slug=organization.slug) + + assert form.is_valid() == app_scenario["expected_valid"] + if not app_scenario["expected_valid"]: + assert "Cannot reactivate opportunity with reused applications" in str(form.errors["active"]) + + @pytest.mark.parametrize( + "data_updates,expected_valid", + [ + ({"currency": "USD", "additional_users": 5}, True), + ({"currency": "EUR", "additional_users": 10}, True), + ({"currency": "INVALID", "additional_users": 5}, False), + ({"currency": "USD", "additional_users": -5}, True), + ], + ) + def test_valid_combinations(self, valid_opportunity, organization, base_form_data, data_updates, expected_valid): + data = base_form_data.copy() + data.update(data_updates) + form = OpportunityChangeForm(data=data, instance=valid_opportunity, org_slug=organization.slug) + assert form.is_valid() == expected_valid diff --git a/commcare_connect/opportunity/tests/test_views.py b/commcare_connect/opportunity/tests/test_views.py index 1c121fbf..c1010b7b 100644 --- a/commcare_connect/opportunity/tests/test_views.py +++ b/commcare_connect/opportunity/tests/test_views.py @@ -3,15 +3,26 @@ import pytest from django.test import Client from django.urls import reverse +from django.utils.timezone import now -from commcare_connect.opportunity.models import Opportunity, OpportunityAccess, OpportunityClaimLimit +from commcare_connect.opportunity.models import ( + Opportunity, + OpportunityAccess, + OpportunityClaimLimit, + UserVisit, + VisitReviewStatus, + VisitValidationStatus, +) from commcare_connect.opportunity.payment_number_report import update_payment_number_statuses from commcare_connect.opportunity.tests.factories import ( + OpportunityAccessFactory, OpportunityClaimFactory, OpportunityClaimLimitFactory, PaymentUnitFactory, + UserVisitFactory, ) from commcare_connect.organization.models import Organization, OrgUserPaymentNumberStatus +from commcare_connect.program.tests.factories import ManagedOpportunityFactory, ProgramFactory from commcare_connect.users.models import User from commcare_connect.users.tests.factories import MobileUserFactory, OrganizationFactory @@ -223,3 +234,65 @@ def test_update_payment_number_statuses(scenario, opportunity): except OrgUserPaymentNumberStatus.DoesNotExist: if scenario["expected_result"]["approved"] + scenario["expected_result"]["rejected"] > 0: pytest.fail(f"Expected status entry not found for {entry}") + + +class TestUserVisitReviewView: + @pytest.fixture(autouse=True) + def setup( + self, + client: Client, + program_manager_org: Organization, + program_manager_org_user_admin: User, + organization: Organization, + org_user_admin: User, + ): + self.client = client + self.pm_org = program_manager_org + self.pm_user = program_manager_org_user_admin + self.nm_org = organization + self.nm_user = org_user_admin + self.program = ProgramFactory(organization=self.pm_org) + self.opportunity = ManagedOpportunityFactory(program=self.program, organization=self.nm_org) + access = OpportunityAccessFactory(opportunity=self.opportunity, accepted=True) + self.visits = UserVisitFactory.create_batch( + 10, + opportunity=self.opportunity, + status=VisitValidationStatus.approved, + review_created_on=now(), + review_status=VisitReviewStatus.pending, + opportunity_access=access, + ) + + def test_user_visit_review_program_manager_table(self): + self.url = reverse("opportunity:user_visit_review", args=(self.pm_org.slug, self.opportunity.id)) + self.client.force_login(self.pm_user) + response = self.client.get(self.url) + assert response.status_code == 200 + table = response.context["table"] + assert len(table.rows) == 10 + assert "pk" in table.columns.names() + + @pytest.mark.parametrize("review_status", [(VisitReviewStatus.agree), (VisitReviewStatus.disagree)]) + def test_user_visit_review_program_manager_approval(self, review_status): + self.url = reverse("opportunity:user_visit_review", args=(self.pm_org.slug, self.opportunity.id)) + self.client.force_login(self.pm_user) + response = self.client.post(self.url, {"pk": [], "review_status": review_status.value}) + assert response.status_code == 200 + visits = UserVisit.objects.filter(id__in=[visit.id for visit in self.visits]) + for visit in visits: + assert visit.review_status == VisitReviewStatus.pending + + visit_ids = [visit.id for visit in self.visits][:5] + response = self.client.post(self.url, {"pk": visit_ids, "review_status": review_status.value}) + assert response.status_code == 200 + visits = UserVisit.objects.filter(id__in=visit_ids) + for visit in visits: + assert visit.review_status == review_status + + def test_user_visit_review_network_manager_table(self): + self.url = reverse("opportunity:user_visit_review", args=(self.nm_org.slug, self.opportunity.id)) + self.client.force_login(self.nm_user) + response = self.client.get(self.url) + table = response.context["table"] + assert len(table.rows) == 10 + assert "pk" not in table.columns.names() diff --git a/commcare_connect/opportunity/tests/test_visit_import.py b/commcare_connect/opportunity/tests/test_visit_import.py index 4c5f10ee..c8aa16e5 100644 --- a/commcare_connect/opportunity/tests/test_visit_import.py +++ b/commcare_connect/opportunity/tests/test_visit_import.py @@ -19,6 +19,7 @@ Payment, PaymentUnit, UserVisit, + VisitReviewStatus, VisitValidationStatus, ) from commcare_connect.opportunity.tests.factories import ( @@ -539,3 +540,65 @@ def get_assignable_completed_work_count(access: OpportunityAccess) -> int: total_assigned_count += 1 return total_assigned_count + + +@pytest.mark.parametrize("opportunity", [{"opp_options": {"managed": True}}], indirect=True) +@pytest.mark.parametrize("visit_status", [VisitValidationStatus.approved, VisitValidationStatus.rejected]) +def test_network_manager_flagged_visit_review_status(mobile_user: User, opportunity: Opportunity, visit_status): + assert opportunity.managed + access = OpportunityAccess.objects.get(user=mobile_user, opportunity=opportunity) + visits = UserVisitFactory.create_batch( + 5, opportunity=opportunity, status=VisitValidationStatus.pending, user=mobile_user, opportunity_access=access + ) + dataset = Dataset(headers=["visit id", "status", "rejected reason", "justification"]) + dataset.extend([[visit.xform_id, visit_status.value, "", "justification"] for visit in visits]) + before_update = now() + import_status = _bulk_update_visit_status(opportunity, dataset) + after_update = now() + assert not import_status.missing_visits + updated_visits = UserVisit.objects.filter(opportunity=opportunity) + for visit in updated_visits: + assert visit.status == visit_status + assert visit.status_modified_date is not None + assert before_update <= visit.status_modified_date <= after_update + if visit.status == VisitValidationStatus.approved: + assert before_update <= visit.review_created_on <= after_update + assert visit.review_status == VisitReviewStatus.pending + assert visit.justification == "justification" + + +@pytest.mark.parametrize("opportunity", [{"opp_options": {"managed": True}}], indirect=True) +@pytest.mark.parametrize( + "review_status, cw_status", + [ + (VisitReviewStatus.pending, CompletedWorkStatus.pending), + (VisitReviewStatus.agree, CompletedWorkStatus.approved), + (VisitReviewStatus.disagree, CompletedWorkStatus.pending), + ], +) +def test_review_completed_work_status( + mobile_user_with_connect_link: User, opportunity: Opportunity, review_status, cw_status +): + deliver_unit = DeliverUnitFactory(app=opportunity.deliver_app, payment_unit=opportunity.paymentunit_set.first()) + access = OpportunityAccess.objects.get(user=mobile_user_with_connect_link, opportunity=opportunity) + UserVisitFactory.create_batch( + 2, + opportunity_access=access, + status=VisitValidationStatus.approved, + review_status=review_status, + review_created_on=now(), + completed_work__status=CompletedWorkStatus.pending, + completed_work__opportunity_access=access, + completed_work__payment_unit=opportunity.paymentunit_set.first(), + deliver_unit=deliver_unit, + ) + assert access.payment_accrued == 0 + update_payment_accrued(opportunity, {mobile_user_with_connect_link.id}) + completed_works = CompletedWork.objects.filter(opportunity_access=access) + payment_accrued = 0 + for cw in completed_works: + assert cw.status == cw_status + if cw.status == CompletedWorkStatus.approved: + payment_accrued += cw.payment_accrued + access.refresh_from_db() + assert access.payment_accrued == payment_accrued diff --git a/commcare_connect/opportunity/urls.py b/commcare_connect/opportunity/urls.py index 8eec654c..c5d4fa62 100644 --- a/commcare_connect/opportunity/urls.py +++ b/commcare_connect/opportunity/urls.py @@ -113,5 +113,6 @@ path("/invoice_table/", views.PaymentInvoiceTableView.as_view(), name="invoice_table"), path("/invoice/create/", views.invoice_create, name="invoice_create"), path("/invoice/approve/", views.invoice_approve, name="invoice_approve"), + path("/user_invite_delete//", views.user_invite_delete, name="user_invite_delete"), path("payment_numbers", view=PaymentNumberReport.as_view(), name="payment_number_report"), ] diff --git a/commcare_connect/opportunity/views.py b/commcare_connect/opportunity/views.py index 9b6c00aa..b06b7597 100644 --- a/commcare_connect/opportunity/views.py +++ b/commcare_connect/opportunity/views.py @@ -64,7 +64,9 @@ Payment, PaymentInvoice, PaymentUnit, + UserInvite, UserVisit, + VisitReviewStatus, VisitValidationStatus, ) from commcare_connect.opportunity.tables import ( @@ -211,6 +213,11 @@ class OpportunityEdit(OrganizationUserMemberRoleMixin, UpdateView): def get_success_url(self): return reverse("opportunity:detail", args=(self.request.org.slug, self.object.id)) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["org_slug"] = self.request.org.slug + return kwargs + def form_valid(self, form): opportunity = form.instance opportunity.modified_by = self.request.user.email @@ -357,21 +364,20 @@ def get_queryset(self): @org_member_required -def export_user_visits(request, **kwargs): - opportunity_id = kwargs["pk"] - get_opportunity_or_404(org_slug=request.org.slug, pk=opportunity_id) +def export_user_visits(request, org_slug, pk): + get_opportunity_or_404(org_slug=request.org.slug, pk=pk) form = VisitExportForm(data=request.POST) if not form.is_valid(): messages.error(request, form.errors) - return redirect("opportunity:detail", request.org.slug, opportunity_id) + return redirect("opportunity:detail", request.org.slug, pk) export_format = form.cleaned_data["format"] date_range = DateRanges(form.cleaned_data["date_range"]) status = form.cleaned_data["status"] flatten = form.cleaned_data["flatten_form_data"] - result = generate_visit_export.delay(opportunity_id, date_range, status, export_format, flatten) - redirect_url = reverse("opportunity:detail", args=(request.org.slug, opportunity_id)) + result = generate_visit_export.delay(pk, date_range, status, export_format, flatten) + redirect_url = reverse("opportunity:detail", args=(request.org.slug, pk)) return redirect(f"{redirect_url}?export_task_id={result.id}") @@ -483,17 +489,16 @@ def get_queryset(self): @org_member_required -def export_users_for_payment(request, **kwargs): - opportunity_id = kwargs["pk"] - get_opportunity_or_404(org_slug=request.org.slug, pk=opportunity_id) +def export_users_for_payment(request, org_slug, pk): + get_opportunity_or_404(org_slug=request.org.slug, pk=pk) form = PaymentExportForm(data=request.POST) if not form.is_valid(): messages.error(request, form.errors) - return redirect("opportunity:detail", request.org.slug, opportunity_id) + return redirect("opportunity:detail", request.org.slug, pk) export_format = form.cleaned_data["format"] - result = generate_payment_export.delay(opportunity_id, export_format) - redirect_url = reverse("opportunity:detail", args=(request.org.slug, opportunity_id)) + result = generate_payment_export.delay(pk, export_format) + redirect_url = reverse("opportunity:detail", args=(request.org.slug, pk)) return redirect(f"{redirect_url}?export_task_id={result.id}") @@ -629,17 +634,16 @@ def get_queryset(self): @org_member_required -def export_user_status(request, **kwargs): - opportunity_id = kwargs["pk"] - get_opportunity_or_404(org_slug=request.org.slug, pk=opportunity_id) +def export_user_status(request, org_slug, pk): + get_opportunity_or_404(org_slug=request.org.slug, pk=pk) form = PaymentExportForm(data=request.POST) if not form.is_valid(): messages.error(request, form.errors) - return redirect("opportunity:detail", request.org.slug, opportunity_id) + return redirect("opportunity:detail", request.org.slug, pk) export_format = form.cleaned_data["format"] - result = generate_user_status_export.delay(opportunity_id, export_format) - redirect_url = reverse("opportunity:detail", args=(request.org.slug, opportunity_id)) + result = generate_user_status_export.delay(pk, export_format) + redirect_url = reverse("opportunity:detail", args=(request.org.slug, pk)) return redirect(f"{redirect_url}?export_task_id={result.id}") @@ -658,17 +662,16 @@ def get_queryset(self): @org_member_required -def export_deliver_status(request, **kwargs): - opportunity_id = kwargs["pk"] - get_opportunity_or_404(pk=opportunity_id, org_slug=request.org.slug) +def export_deliver_status(request, org_slug, pk): + get_opportunity_or_404(pk=pk, org_slug=request.org.slug) form = PaymentExportForm(data=request.POST) if not form.is_valid(): messages.error(request, form.errors) - return redirect("opportunity:detail", request.org.slug, opportunity_id) + return redirect("opportunity:detail", request.org.slug, pk) export_format = form.cleaned_data["format"] - result = generate_deliver_status_export.delay(opportunity_id, export_format) - redirect_url = reverse("opportunity:detail", args=(request.org.slug, opportunity_id)) + result = generate_deliver_status_export.delay(pk, export_format) + redirect_url = reverse("opportunity:detail", args=(request.org.slug, pk)) return redirect(f"{redirect_url}?export_task_id={result.id}") @@ -864,16 +867,18 @@ def visit_verification(request, org_slug=None, pk=None): @org_member_required def approve_visit(request, org_slug=None, pk=None): user_visit = UserVisit.objects.get(pk=pk) - user_visit.status = VisitValidationStatus.approved - if user_visit.opportunity.managed: - user_visit.review_created_on = now() - user_visit.save() opp_id = user_visit.opportunity_id - access = OpportunityAccess.objects.get(user_id=user_visit.user_id, opportunity_id=opp_id) - update_payment_accrued(opportunity=access.opportunity, users=[access.user]) + if user_visit.status != VisitValidationStatus.approved: + user_visit.status = VisitValidationStatus.approved + if user_visit.opportunity.managed: + user_visit.review_created_on = now() + user_visit.save() + update_payment_accrued(opportunity=user_visit.opportunity, users=[user_visit.user]) if user_visit.opportunity.managed: return redirect("opportunity:user_visit_review", org_slug, opp_id) - return redirect("opportunity:user_visits_list", org_slug=org_slug, opp_id=user_visit.opportunity.id, pk=access.id) + return redirect( + "opportunity:user_visits_list", org_slug=org_slug, opp_id=opp_id, pk=user_visit.opportunity_access_id + ) @org_member_required @@ -987,17 +992,16 @@ def get_queryset(self): @org_member_required -def export_completed_work(request, **kwargs): - opportunity_id = kwargs["pk"] - get_opportunity_or_404(org_slug=request.org.slug, pk=opportunity_id) +def export_completed_work(request, org_slug, pk): + get_opportunity_or_404(org_slug=request.org.slug, pk=pk) form = PaymentExportForm(data=request.POST) if not form.is_valid(): messages.error(request, form.errors) - return redirect("opportunity:detail", request.org.slug, opportunity_id) + return redirect("opportunity:detail", request.org.slug, pk) export_format = form.cleaned_data["format"] - result = generate_work_status_export.delay(opportunity_id, export_format) - redirect_url = reverse("opportunity:detail", args=(request.org.slug, opportunity_id)) + result = generate_work_status_export.delay(pk, export_format) + redirect_url = reverse("opportunity:detail", args=(request.org.slug, pk)) return redirect(f"{redirect_url}?export_task_id={result.id}") @@ -1052,17 +1056,16 @@ def suspended_users_list(request, org_slug=None, pk=None): @org_member_required -def export_catchment_area(request, **kwargs): - opportunity_id = kwargs["pk"] - get_opportunity_or_404(org_slug=request.org.slug, pk=opportunity_id) +def export_catchment_area(request, org_slug, pk): + get_opportunity_or_404(org_slug=request.org.slug, pk=pk) form = PaymentExportForm(data=request.POST) if not form.is_valid(): messages.error(request, form.errors) - return redirect("opportunity:detail", request.org.slug, opportunity_id) + return redirect("opportunity:detail", request.org.slug, pk) export_format = form.cleaned_data["format"] - result = generate_catchment_area_export.delay(opportunity_id, export_format) - redirect_url = reverse("opportunity:detail", args=(request.org.slug, opportunity_id)) + result = generate_catchment_area_export.delay(pk, export_format) + redirect_url = reverse("opportunity:detail", args=(request.org.slug, pk)) return redirect(f"{redirect_url}?export_task_id={result.id}") @@ -1084,7 +1087,7 @@ def import_catchment_area(request, org_slug=None, pk=None): @org_member_required def opportunity_user_invite(request, org_slug=None, pk=None): opportunity = get_object_or_404(Opportunity, organization=request.org, id=pk) - form = OpportunityUserInviteForm(data=request.POST or None) + form = OpportunityUserInviteForm(data=request.POST or None, org_slug=request.org.slug) if form.is_valid(): users = form.cleaned_data["users"] filter_country = form.cleaned_data["filter_country"] @@ -1112,14 +1115,14 @@ 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: review_status = request.POST.get("review_status").lower() updated_reviews = request.POST.getlist("pk") user_visits = UserVisit.objects.filter(pk__in=updated_reviews) - if review_status in ["agree", "disagree"]: + if review_status in [VisitReviewStatus.agree.value, VisitReviewStatus.disagree.value]: user_visits.update(review_status=review_status) update_payment_accrued(opportunity=opportunity, users=[visit.user for visit in user_visits]) @@ -1222,3 +1225,13 @@ def invoice_approve(request, org_slug, pk): ) payment.save() return HttpResponse(headers={"HX-Trigger": "newInvoice"}) + + +@org_member_required +@require_POST +@csrf_exempt +def user_invite_delete(request, org_slug, opp_id, pk): + opportunity = get_opportunity_or_404(opp_id, org_slug) + invite = get_object_or_404(UserInvite, pk=pk, opportunity=opportunity) + invite.delete() + return HttpResponse(status=200, headers={"HX-Trigger": "userStatusReload"}) diff --git a/commcare_connect/opportunity/visit_import.py b/commcare_connect/opportunity/visit_import.py index e57cbd57..425b0454 100644 --- a/commcare_connect/opportunity/visit_import.py +++ b/commcare_connect/opportunity/visit_import.py @@ -6,6 +6,7 @@ from decimal import Decimal, InvalidOperation from django.conf import settings +from django.core.cache import cache from django.core.files.uploadedfile import UploadedFile from django.db import transaction from django.utils.timezone import now @@ -168,10 +169,11 @@ def update_payment_accrued(opportunity: Opportunity, users): """Updates payment accrued for completed and approved CompletedWork instances.""" access_objects = OpportunityAccess.objects.filter(user__in=users, opportunity=opportunity, suspended=False) for access in access_objects: - completed_works = access.completedwork_set.exclude( - status__in=[CompletedWorkStatus.rejected, CompletedWorkStatus.over_limit] - ).select_related("payment_unit") - update_status(completed_works, access, True) + with cache.lock(f"update_payment_accrued_lock_{access.id}", timeout=900): + completed_works = access.completedwork_set.exclude( + status__in=[CompletedWorkStatus.rejected, CompletedWorkStatus.over_limit] + ).select_related("payment_unit") + update_status(completed_works, access, True) def get_data_by_visit_id(dataset) -> dict[int, VisitData]: diff --git a/commcare_connect/organization/forms.py b/commcare_connect/organization/forms.py index f22a4b7b..4bbde435 100644 --- a/commcare_connect/organization/forms.py +++ b/commcare_connect/organization/forms.py @@ -1,5 +1,6 @@ from crispy_forms import helper, layout from django import forms +from django.core.exceptions import ValidationError from django.utils.translation import gettext from commcare_connect.organization.models import Organization, UserOrganizationMembership @@ -25,29 +26,42 @@ def __init__(self, *args, **kwargs): class MembershipForm(forms.ModelForm): + email = forms.CharField( + max_length=254, + required=True, + label="", + widget=forms.TextInput(attrs={"placeholder": "Enter email address"}), + ) + class Meta: model = UserOrganizationMembership - fields = ("user", "role") - labels = {"user": "", "role": ""} + fields = ("role",) + labels = {"role": ""} def __init__(self, *args, **kwargs): self.organization = kwargs.pop("organization") super().__init__(*args, **kwargs) - self.fields["user"].queryset = User.objects.filter(email__isnull=False).exclude( - memberships__organization=self.organization - ) - self.helper = helper.FormHelper(self) self.helper.layout = layout.Layout( layout.Row( layout.HTML("

Add new member

"), - layout.Field("user", wrapper_class="col-md-5"), + layout.Field("email", wrapper_class="col-md-5"), layout.Field("role", wrapper_class="col-md-5"), layout.Div(layout.Submit("submit", gettext("Submit")), css_class="col-md-2"), ), ) + def clean_email(self): + email = self.cleaned_data["email"] + user = User.objects.filter(email=email).exclude(memberships__organization=self.organization).first() + + if not user: + raise ValidationError("User with this email does not exist or is already a member") + + self.instance.user = user + return email + class AddCredentialForm(forms.Form): credential = forms.CharField(widget=forms.Select) diff --git a/commcare_connect/organization/tests/__init__.py b/commcare_connect/organization/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commcare_connect/organization/tests/test_forms.py b/commcare_connect/organization/tests/test_forms.py new file mode 100644 index 00000000..8f4381a4 --- /dev/null +++ b/commcare_connect/organization/tests/test_forms.py @@ -0,0 +1,54 @@ +import pytest +from django.test import Client +from django.urls import reverse + +from commcare_connect.organization.models import Organization +from commcare_connect.users.tests.factories import UserFactory + + +class TestAddMembersView: + @pytest.fixture(autouse=True) + def setup(self, organization: Organization, client: Client): + self.url = reverse("organization:add_members", kwargs={"org_slug": organization.slug}) + self.user = organization.memberships.filter(role="admin").first().user + self.client = client + client.force_login(self.user) + + @pytest.mark.django_db + @pytest.mark.parametrize( + "email, role, expected_status_code, create_user, expected_role, should_exist", + [ + ("testformember@example.com", "member", 302, True, "member", True), + ("testforadmin@example.com", "admin", 302, True, "admin", True), + ("nonexistent@example.com", "member", 302, False, None, False), + ("existing@example.com", "admin", 302, True, "member", True), + ], + ) + def test_add_member( + self, + email, + role, + expected_status_code, + create_user, + expected_role, + should_exist, + organization, + ): + if create_user: + user = UserFactory(email=email) + + if email == "existing@example.com": + organization.members.add(user, through_defaults={"role": expected_role}) + + data = {"email": email, "role": role} + response = self.client.post(self.url, data) + + membership_filter = {"user__email": email} + + assert response.status_code == expected_status_code + membership_exists = organization.memberships.filter(**membership_filter).exists() + assert membership_exists == should_exist + + if should_exist and expected_role: + membership = organization.memberships.get(**membership_filter) + assert membership.role == expected_role diff --git a/commcare_connect/organization/views.py b/commcare_connect/organization/views.py index f2e5da30..fce5002c 100644 --- a/commcare_connect/organization/views.py +++ b/commcare_connect/organization/views.py @@ -47,7 +47,7 @@ def organization_home(request, org_slug): if not form: form = OrganizationChangeForm(instance=org) - credentials = connect_id_client.fetch_credentials() + credentials = connect_id_client.fetch_credentials(org_slug=request.org.slug) credential_name = f"Worked for {org.name}" if not any(c.name == credential_name for c in credentials): credentials.append(Credential(name=credential_name, slug=slugify(credential_name))) @@ -96,7 +96,7 @@ def accept_invite(request, org_slug, invite_id): @require_POST def add_credential_view(request, org_slug): org = get_object_or_404(Organization, slug=org_slug) - credentials = connect_id_client.fetch_credentials() + credentials = connect_id_client.fetch_credentials(org_slug=request.org.slug) credential_name = f"Worked for {org.name}" if not any(c.name == credential_name for c in credentials): credentials.append(Credential(name=credential_name, slug=slugify(credential_name))) 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/factories.py b/commcare_connect/program/tests/factories.py index f07baff6..00f22762 100644 --- a/commcare_connect/program/tests/factories.py +++ b/commcare_connect/program/tests/factories.py @@ -22,6 +22,7 @@ class Meta: class ManagedOpportunityFactory(OpportunityFactory): program = SubFactory(ProgramFactory) + org_pay_per_visit = Faker("random_int", min=500, max=1000) class Meta: model = ManagedOpportunity 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/tests/test_models.py b/commcare_connect/program/tests/test_models.py new file mode 100644 index 00000000..ef4243d3 --- /dev/null +++ b/commcare_connect/program/tests/test_models.py @@ -0,0 +1,20 @@ +import pytest + +from commcare_connect.opportunity.tests.factories import PaymentUnitFactory +from commcare_connect.program.models import ManagedOpportunity +from commcare_connect.program.tests.factories import ManagedOpportunityFactory + + +@pytest.mark.django_db +def test_managed_opportunity_stats(): + opportunity = ManagedOpportunityFactory(total_budget=3600000, org_pay_per_visit=450) + PaymentUnitFactory(opportunity=opportunity, max_total=600, max_daily=5, amount=750) + + opportunity = ManagedOpportunity.objects.get(id=opportunity.id) + + assert opportunity.budget_per_user == 450000 + assert opportunity.allotted_visits == 3000 + assert opportunity.number_of_users == 5 + assert opportunity.max_visits_per_user_new == 600 + assert opportunity.daily_max_visits_per_user_new == 5 + assert opportunity.budget_per_visit_new == 750 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/reports/queries.py b/commcare_connect/reports/queries.py new file mode 100644 index 00000000..ed9ddf91 --- /dev/null +++ b/commcare_connect/reports/queries.py @@ -0,0 +1,34 @@ +from django.db.models import F +from django.db.models.fields.json import KT + + +def get_visit_map_queryset(base_queryset): + return ( + base_queryset.annotate( + deliver_unit_name=F("deliver_unit__name"), + username_connectid=KT("form_json__metadata__username"), + timestart_str=KT("form_json__metadata__timeStart"), + timeend_str=KT("form_json__metadata__timeEnd"), + location_str=KT("form_json__metadata__location"), + ) + .select_related("deliver_unit", "opportunity", "opportunity__delivery_type", "opportunity__organization") + .values( + "opportunity_id", + "opportunity__delivery_type__name", + "opportunity__delivery_type__slug", + "opportunity__organization__slug", + "opportunity__organization__name", + "xform_id", + "visit_date", + "username_connectid", + "deliver_unit_name", + "entity_id", + "status", + "flagged", + "flag_reason", + "reason", + "timestart_str", + "timeend_str", + "location_str", + ) + ) diff --git a/commcare_connect/reports/tests/test_reports.py b/commcare_connect/reports/tests/test_reports.py index 632a43ff..441090f7 100644 --- a/commcare_connect/reports/tests/test_reports.py +++ b/commcare_connect/reports/tests/test_reports.py @@ -40,7 +40,7 @@ def test_delivery_stats(opportunity: Opportunity): status=VisitValidationStatus.approved.value, opportunity_access=access, completed_work=completed_work, - visit_date=Faker("date_this_month"), + visit_date=Faker("date_time_this_month", tzinfo=datetime.UTC), ) quarter = math.ceil(datetime.datetime.utcnow().month / 12 * 4) @@ -65,19 +65,32 @@ def test_delivery_stats(opportunity: Opportunity): def test_results_to_geojson(): + class MockQuerySet: + def __init__(self, results): + self.results = results + + def all(self): + return self.results + # Test input - results = [ - {"gps_location_long": "10.123", "gps_location_lat": "20.456", "status": "approved", "other_field": "value1"}, - {"gps_location_long": "30.789", "gps_location_lat": "40.012", "status": "rejected", "other_field": "value2"}, - {"gps_location_long": "invalid", "gps_location_lat": "50.678", "status": "unknown", "other_field": "value3"}, - {"status": "approved", "other_field": "value4"}, # Case where lat/lon are not present - { # Case where lat/lon are null - "gps_location_long": None, - "gps_location_lat": None, - "status": "rejected", - "other_field": "value5", - }, - ] + results = MockQuerySet( + [ + {"location_str": "20.456 10.123 0 0", "status": "approved", "other_field": "value1"}, + {"location_str": "40.012 30.789", "status": "rejected", "other_field": "value2"}, + {"location_str": "invalid location", "status": "unknown", "other_field": "value3"}, + {"location_str": "bad location", "status": "unknown", "other_field": "value4"}, + { + "location_str": None, + "status": "approved", + "other_field": "value5", + }, # Case where lat/lon are not present + { # Case where lat/lon are null + "location_str": None, + "status": "rejected", + "other_field": "value5", + }, + ] + ) # Call the function geojson = _results_to_geojson(results) @@ -93,7 +106,7 @@ def test_results_to_geojson(): assert feature1["geometry"]["coordinates"] == [10.123, 20.456] assert feature1["properties"]["status"] == "approved" assert feature1["properties"]["other_field"] == "value1" - assert feature1["properties"]["color"] == "#00FF00" + assert feature1["properties"]["color"] == "#4ade80" # Check the second feature feature2 = geojson["features"][1] @@ -102,7 +115,7 @@ def test_results_to_geojson(): assert feature2["geometry"]["coordinates"] == [30.789, 40.012] assert feature2["properties"]["status"] == "rejected" assert feature2["properties"]["other_field"] == "value2" - assert feature2["properties"]["color"] == "#FF0000" + assert feature2["properties"]["color"] == "#f87171" # Check that the other cases are not included assert all(f["properties"]["other_field"] not in ["value3", "value4", "value5"] for f in geojson["features"]) diff --git a/commcare_connect/reports/urls.py b/commcare_connect/reports/urls.py index 0b780e73..de9ca690 100644 --- a/commcare_connect/reports/urls.py +++ b/commcare_connect/reports/urls.py @@ -6,6 +6,8 @@ urlpatterns = [ path("program_dashboard", views.program_dashboard_report, name="program_dashboard_report"), - path("api/visit_map_data/", views.visit_map_data, name="visit_map_data"), path("delivery_stats", view=views.DeliveryStatsReportView.as_view(), name="delivery_stats_report"), + path("api/visit_map_data/", views.visit_map_data, name="visit_map_data"), + path("api/dashboard_stats/", views.dashboard_stats_api, name="dashboard_stats_api"), + path("api/dashboard_charts/", views.dashboard_charts_api, name="dashboard_charts_api"), ] diff --git a/commcare_connect/reports/views.py b/commcare_connect/reports/views.py index ed60a988..6b890510 100644 --- a/commcare_connect/reports/views.py +++ b/commcare_connect/reports/views.py @@ -1,13 +1,15 @@ -from datetime import date, datetime +from datetime import date, datetime, timedelta import django_filters import django_tables2 as tables +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Column, Layout, Row from django import forms from django.conf import settings from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.db import connection -from django.db.models import Max, Q, Sum +from django.db.models import Count, Max, Q, Sum +from django.db.models.functions import TruncDate from django.http import JsonResponse from django.shortcuts import render from django.urls import reverse @@ -16,7 +18,9 @@ from django_filters.views import FilterView from commcare_connect.cache import quickcache -from commcare_connect.opportunity.models import CompletedWork, CompletedWorkStatus, DeliveryType, Payment +from commcare_connect.opportunity.models import CompletedWork, CompletedWorkStatus, DeliveryType, Payment, UserVisit +from commcare_connect.organization.models import Organization +from commcare_connect.reports.queries import get_visit_map_queryset from .tables import AdminReportTable @@ -151,14 +155,82 @@ def _get_table_data_for_quarter(quarter, delivery_type, group_by_delivery_type=F return data +class DashboardFilters(django_filters.FilterSet): + program = django_filters.ModelChoiceFilter( + queryset=DeliveryType.objects.all(), + field_name="opportunity__delivery_type", + label="Program", + empty_label="All Programs", + required=False, + ) + organization = django_filters.ModelChoiceFilter( + queryset=Organization.objects.all(), + field_name="opportunity__organization", + label="Organization", + empty_label="All Organizations", + required=False, + ) + from_date = django_filters.DateTimeFilter( + widget=forms.DateInput(attrs={"type": "date"}), + field_name="visit_date", + lookup_expr="gt", + label="From Date", + required=False, + ) + to_date = django_filters.DateTimeFilter( + widget=forms.DateInput(attrs={"type": "date"}), + field_name="visit_date", + lookup_expr="lte", + label="To Date", + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.helper = FormHelper() + self.form.helper.form_class = "form-inline" + self.form.helper.layout = Layout( + Row( + Column("program", css_class="col-md-3"), + Column("organization", css_class="col-md-3"), + Column("from_date", css_class="col-md-3"), + Column("to_date", css_class="col-md-3"), + ) + ) + + # Set default values if no data is provided + if not self.data: + # Create a mutable copy of the QueryDict + self.data = self.data.copy() if self.data else {} + + # Set default dates + today = date.today() + default_from = today - timedelta(days=30) + + # Set the default values + self.data["to_date"] = today.strftime("%Y-%m-%d") + self.data["from_date"] = default_from.strftime("%Y-%m-%d") + + # Force the form to bind with the default data + self.form.is_bound = True + self.form.data = self.data + + class Meta: + model = UserVisit + fields = ["program", "organization", "from_date", "to_date"] + + @login_required -@user_passes_test(lambda user: user.is_superuser) -@require_GET +@user_passes_test(lambda u: u.is_superuser) def program_dashboard_report(request): + filterset = DashboardFilters(request.GET) return render( request, "reports/dashboard.html", - context={"mapbox_token": settings.MAPBOX_TOKEN}, + context={ + "mapbox_token": settings.MAPBOX_TOKEN, + "filter": filterset, + }, ) @@ -166,20 +238,18 @@ def program_dashboard_report(request): @user_passes_test(lambda user: user.is_superuser) @require_GET def visit_map_data(request): - with connection.cursor() as cursor: - # Read the SQL file - with open("commcare_connect/reports/sql/visit_map.sql") as sql_file: - sql_query = sql_file.read() + filterset = DashboardFilters(request.GET) + + # Use the filtered queryset to calculate stats - # Execute the query - cursor.execute(sql_query) + queryset = UserVisit.objects.all() + if filterset.is_valid(): + queryset = filterset.filter_queryset(queryset) - # Fetch all results - columns = [col[0] for col in cursor.description] - results = [dict(zip(columns, row)) for row in cursor.fetchall()] + queryset = get_visit_map_queryset(queryset) # Convert to GeoJSON - geojson = _results_to_geojson(results) + geojson = _results_to_geojson(queryset) # Return the GeoJSON as JSON response return JsonResponse(geojson, safe=False) @@ -188,17 +258,23 @@ def visit_map_data(request): def _results_to_geojson(results): geojson = {"type": "FeatureCollection", "features": []} status_to_color = { - "approved": "#00FF00", - "rejected": "#FF0000", + "approved": "#4ade80", + "rejected": "#f87171", } - for result in results: + for i, result in enumerate(results.all()): + location_str = result.get("location_str") # Check if both latitude and longitude are not None and can be converted to float - if result.get("gps_location_long") and result.get("gps_location_lat"): - try: - longitude = float(result["gps_location_long"]) - latitude = float(result["gps_location_lat"]) - except ValueError: - # Skip this result if conversion to float fails + if location_str: + split_location = location_str.split(" ") + if len(split_location) >= 2: + try: + longitude = float(split_location[1]) + latitude = float(split_location[0]) + except ValueError: + # Skip this result if conversion to float fails + continue + else: + # Or if the location string is not in the expected format continue feature = { @@ -211,7 +287,7 @@ def _results_to_geojson(results): key: value for key, value in result.items() if key not in ["gps_location_lat", "gps_location_long"] }, } - color = status_to_color.get(result.get("status", ""), "#FFFF00") + color = status_to_color.get(result.get("status", ""), "#fbbf24") feature["properties"]["color"] = color geojson["features"].append(feature) @@ -330,3 +406,139 @@ def object_list(self): data = _get_table_data_for_quarter(q, delivery_type, group_by_delivery_type) table_data += data return table_data + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def dashboard_stats_api(request): + filterset = DashboardFilters(request.GET) + + # Use the filtered queryset to calculate stats + queryset = UserVisit.objects.all() + if filterset.is_valid(): + queryset = filterset.filter_queryset(queryset) + + # Example stats calculation (adjust based on your needs) + active_users = queryset.values("opportunity_access__user").distinct().count() + total_visits = queryset.count() + verified_visits = queryset.filter(status=CompletedWorkStatus.approved).count() + percent_verified = round(float(verified_visits / total_visits) * 100, 1) if total_visits > 0 else 0 + + return JsonResponse( + { + "total_visits": total_visits, + "active_users": active_users, + "verified_visits": verified_visits, + "percent_verified": f"{percent_verified:.1f}%", + } + ) + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def dashboard_charts_api(request): + filterset = DashboardFilters(request.GET) + queryset = UserVisit.objects.all() + # Use the filtered queryset if available, else use last 30 days + if filterset.is_valid(): + queryset = filterset.filter_queryset(queryset) + from_date = filterset.form.cleaned_data["from_date"] + to_date = filterset.form.cleaned_data["to_date"] + else: + to_date = datetime.now().date() + from_date = to_date - timedelta(days=30) + queryset = queryset.filter(visit_date__gte=from_date, visit_date__lte=to_date) + + return JsonResponse( + { + "time_series": _get_time_series_data(queryset, from_date, to_date), + "program_pie": _get_program_pie_data(queryset), + "status_pie": _get_status_pie_data(queryset), + } + ) + + +def _get_time_series_data(queryset, from_date, to_date): + """Example output: + { + "labels": ["Jan 01", "Jan 02", "Jan 03"], + "datasets": [ + { + "name": "Program A", + "data": [5, 3, 7] + }, + { + "name": "Program B", + "data": [2, 4, 1] + } + ] + } + """ + # Get visits over time by program + visits_by_program_time = ( + queryset.values( + "opportunity__delivery_type__name", + visit_date_date=TruncDate("visit_date"), + ) + .annotate(count=Count("id")) + .order_by("visit_date_date", "opportunity__delivery_type__name") + ) + + # Process time series data + program_data = {} + for visit in visits_by_program_time: + program_name = visit["opportunity__delivery_type__name"] + if program_name not in program_data: + program_data[program_name] = {} + program_data[program_name][visit["visit_date_date"]] = visit["count"] + # Create labels and datasets for time series + labels = [] + time_datasets = [] + current_date = from_date + + while current_date <= to_date: + labels.append(current_date.strftime("%b %d")) + current_date += timedelta(days=1) + + for program_name in program_data.keys(): + data = [] + current_date = from_date + while current_date <= to_date: + # Convert current_date to a date object to avoid timezones making comparisons fail + current_date_date = current_date.date() + data.append(program_data[program_name].get(current_date_date, 0)) + current_date += timedelta(days=1) + + time_datasets.append({"name": program_name or "Unknown", "data": data}) + + return {"labels": labels, "datasets": time_datasets} + + +def _get_program_pie_data(queryset): + """Example output: + { + "labels": ["Program A", "Program B", "Unknown"], + "data": [10, 5, 2] + } + """ + visits_by_program = ( + queryset.values("opportunity__delivery_type__name").annotate(count=Count("id")).order_by("-count") + ) + return { + "labels": [item["opportunity__delivery_type__name"] or "Unknown" for item in visits_by_program], + "data": [item["count"] for item in visits_by_program], + } + + +def _get_status_pie_data(queryset): + """Example output: + { + "labels": ["Approved", "Pending", "Rejected", "Unknown"], + "data": [15, 8, 4, 1] + } + """ + visits_by_status = queryset.values("status").annotate(count=Count("id")).order_by("-count") + return { + "labels": [item["status"] or "Unknown" for item in visits_by_status], + "data": [item["count"] for item in visits_by_status], + } diff --git a/commcare_connect/static/js/dashboard.js b/commcare_connect/static/js/dashboard.js new file mode 100644 index 00000000..53ca2b65 --- /dev/null +++ b/commcare_connect/static/js/dashboard.js @@ -0,0 +1,325 @@ +console.log('dashboard.js loaded'); + +// colors to use for the categories +// soft green, yellow, red +const visitColors = ['#4ade80', '#fbbf24', '#f87171']; + +// after the GeoJSON data is loaded, update markers on the screen on every frame +// objects for caching and keeping track of HTML marker objects (for performance) +const markers = {}; +let markersOnScreen = {}; + +function updateMarkers(map) { + const newMarkers = {}; + const features = map.querySourceFeatures('visits'); + + // for every cluster on the screen, create an HTML marker for it (if we didn't yet), + // and add it to the map if it's not there already + for (const feature of features) { + const coords = feature.geometry.coordinates; + const props = feature.properties; + if (!props.cluster) continue; + const id = props.cluster_id; + + let marker = markers[id]; + if (!marker) { + const el = createDonutChart( + { + ...props, + cluster_id: id, // Make sure cluster_id is passed + coordinates: coords, // Pass the coordinates + }, + map, + ); + marker = markers[id] = new mapboxgl.Marker({ + element: el, + }).setLngLat(coords); + } + newMarkers[id] = marker; + + if (!markersOnScreen[id]) marker.addTo(map); + } + // for every marker we've added previously, remove those that are no longer visible + for (const id in markersOnScreen) { + if (!newMarkers[id]) markersOnScreen[id].remove(); + } + markersOnScreen = newMarkers; +} + +// Function to create a donut chart +function createDonutChart(props, map) { + const offsets = []; + const counts = [props.approved, props.pending, props.rejected]; + let total = 0; + for (const count of counts) { + offsets.push(total); + total += count; + } + const fontSize = + total >= 1000 ? 22 : total >= 100 ? 20 : total >= 10 ? 18 : 16; + const r = total >= 1000 ? 50 : total >= 100 ? 32 : total >= 10 ? 24 : 18; + const r0 = Math.round(r * 0.8); + const w = r * 2; + + let html = `
+ + + + + + `; + + for (let i = 0; i < counts.length; i++) { + html += donutSegment( + offsets[i] / total, + (offsets[i] + counts[i]) / total, + r, + r0, + visitColors[i], + ); + } + html += ` + + ${total.toLocaleString()} + + +
`; + + const el = document.createElement('div'); + el.innerHTML = html; + el.style.cursor = 'pointer'; + + // Click handler to zoom and navigate to the cluster + el.addEventListener('click', (e) => { + map + .getSource('visits') + .getClusterExpansionZoom(props.cluster_id, (err, zoom) => { + if (err) return; + + map.easeTo({ + center: props.coordinates, + zoom: zoom, + }); + }); + }); + + return el; +} + +// Function to create a donut segment +function donutSegment(start, end, r, r0, color) { + if (end - start === 1) end -= 0.00001; + const a0 = 2 * Math.PI * (start - 0.25); + const a1 = 2 * Math.PI * (end - 0.25); + const x0 = Math.cos(a0), + y0 = Math.sin(a0); + const x1 = Math.cos(a1), + y1 = Math.sin(a1); + const largeArc = end - start > 0.5 ? 1 : 0; + + // draw an SVG path + return ``; +} + +const chartColors = [ + { border: 'rgb(75, 192, 192)', background: 'rgba(75, 192, 192, 0.8)' }, + { border: 'rgb(255, 99, 132)', background: 'rgba(255, 99, 132, 0.8)' }, + { border: 'rgb(255, 205, 86)', background: 'rgba(255, 205, 86, 0.8)' }, + { border: 'rgb(54, 162, 235)', background: 'rgba(54, 162, 235, 0.8)' }, +]; + +const statusColors = { + approved: { + background: 'rgba(74, 222, 128, 0.8)', + border: 'rgb(74, 222, 128)', + }, + rejected: { + background: 'rgba(248, 113, 113, 0.8)', + border: 'rgb(248, 113, 113)', + }, + pending: { + background: 'rgba(251, 191, 36, 0.8)', + border: 'rgb(251, 191, 36)', + }, +}; + +function createTimeSeriesChart(ctx, data) { + return new Chart(ctx, { + type: 'bar', + data: { + labels: data.labels, + datasets: data.datasets.map((dataset, index) => ({ + label: dataset.name, + data: dataset.data, + borderColor: chartColors[index % chartColors.length].border, + backgroundColor: chartColors[index % chartColors.length].background, + borderWidth: 1, + })), + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + tooltip: { + mode: 'index', + intersect: false, + }, + }, + scales: { + x: { + stacked: true, + title: { + display: true, + text: 'Date', + }, + }, + y: { + stacked: true, + beginAtZero: true, + title: { + display: true, + text: 'Number of Visits', + }, + }, + }, + }, + }); +} + +function createProgramPieChart(ctx, data) { + // Check if there's no data or empty data + if (!data?.data?.length) { + return new Chart(ctx, { + type: 'pie', + data: { + labels: ['No data'], + datasets: [ + { + data: [1], + backgroundColor: ['rgba(156, 163, 175, 0.3)'], + borderColor: ['rgb(156, 163, 175)'], + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, + color: 'rgb(156, 163, 175)', + }, + }, + }, + }, + }); + } + + return new Chart(ctx, { + type: 'pie', + data: { + labels: data.labels, + datasets: [ + { + data: data.data, + backgroundColor: chartColors.map((c) => c.background), + borderColor: chartColors.map((c) => c.border), + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, + }, + }, + }, + }, + }); +} + +function createStatusPieChart(ctx, data) { + // Check if there's no data or empty data + if (!data?.data?.length) { + return new Chart(ctx, { + type: 'pie', + data: { + labels: ['No data'], + datasets: [ + { + data: [1], + backgroundColor: ['rgba(156, 163, 175, 0.3)'], + borderColor: ['rgb(156, 163, 175)'], + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, + color: 'rgb(156, 163, 175)', + }, + }, + }, + }, + }); + } + + return new Chart(ctx, { + type: 'pie', + data: { + labels: data.labels, + datasets: [ + { + data: data.data, + backgroundColor: data.labels.map( + (status) => + statusColors[status]?.background || 'rgba(156, 163, 175, 0.8)', + ), + borderColor: data.labels.map( + (status) => statusColors[status]?.border || 'rgb(156, 163, 175)', + ), + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 12, + }, + }, + }, + }, + }); +} + +window.updateMarkers = updateMarkers; +window.createDonutChart = createDonutChart; +window.createTimeSeriesChart = createTimeSeriesChart; +window.createProgramPieChart = createProgramPieChart; +window.createStatusPieChart = createStatusPieChart; 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/opportunity_detail.html b/commcare_connect/templates/opportunity/opportunity_detail.html index eeeb7dd8..b1aef96d 100644 --- a/commcare_connect/templates/opportunity/opportunity_detail.html +++ b/commcare_connect/templates/opportunity/opportunity_detail.html @@ -364,7 +364,7 @@

+ hx-trigger="load, userStatusReload from:body"> {% include "tables/table_placeholder.html" with num_cols=4 %}
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/templates/reports/dashboard.html b/commcare_connect/templates/reports/dashboard.html index 3e164d3a..5fe5a5b2 100644 --- a/commcare_connect/templates/reports/dashboard.html +++ b/commcare_connect/templates/reports/dashboard.html @@ -3,153 +3,346 @@ {% load crispy_forms_tags %} {% load django_tables2 %} {% block title %}Admin Dashboard{% endblock %} +{% block javascript %} + {{ block.super }} + + +{% endblock %} {% block content %} -

Visit Dashboard

-
- +

Program Dashboard

+
+
+
+ {% crispy filter.form %} +
+
+
+
+
+
+
+ 0 + + +   + +
+
Active FLWs
+
+
+
+
+
+
+
+ 0 + + +   + +
+
Total Visits
+
+
+
+
+
+
+
+ 0 + + +   + +
+
Verified Visits
+
+
+
+
+
+
+
+ 0 + + +   + +
+
Percent Verified
+
+
+
+
+
+
+
+
+

Service Delivery Map

+
+
+
+
+ Loading map... +
+
+
+
+
+

Visit Breakdown

+
+
+
By Program
+
+ +
+
+
+
By Status
+
+ +
+
+
+
Over time
+
+ +
+
+
+
{% endblock content %} {% block inline_javascript %} {{ block.super }} - {{ user_visits|json_script:"userVisits" }} - + + + {% endblock %} 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}" diff --git a/config/urls.py b/config/urls.py index 9eca1758..4c368e0f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -9,9 +9,12 @@ from commcare_connect.organization.views import organization_create +from . import views + urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"), + path(".well-known/assetlinks.json", views.assetlinks_json, name="assetlinks_json"), # Django Admin, use {% url 'admin:index' %} path(settings.ADMIN_URL, admin.site.urls), path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), diff --git a/config/views.py b/config/views.py new file mode 100644 index 00000000..58135f30 --- /dev/null +++ b/config/views.py @@ -0,0 +1,28 @@ +from django.http import JsonResponse + + +def assetlinks_json(request): + assetfile = [ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.commcare.dalvik", + "sha256_cert_fingerprints": [ + "88:57:18:F8:E8:7D:74:04:97:AE:83:65:74:ED:EF:10:40:D9:4C:E2:54:F0:E0:40:64:77:96:7F:D1:39:F9:81", + "89:55:DF:D8:0E:66:63:06:D2:6D:88:A4:A3:88:A4:D9:16:5A:C4:1A:7E:E1:C6:78:87:00:37:55:93:03:7B:03", + ], + }, + }, + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.commcare.dalvik.debug", + "sha256_cert_fingerprints": [ + "88:57:18:F8:E8:7D:74:04:97:AE:83:65:74:ED:EF:10:40:D9:4C:E2:54:F0:E0:40:64:77:96:7F:D1:39:F9:81" + ], + }, + }, + ] + return JsonResponse(assetfile, safe=False) diff --git a/webpack/base.config.js b/webpack/base.config.js index 74235cf8..8f1fe4de 100644 --- a/webpack/base.config.js +++ b/webpack/base.config.js @@ -8,6 +8,10 @@ module.exports = { context: path.join(__dirname, '../'), entry: { project: path.resolve(__dirname, '../commcare_connect/static/js/project'), + dashboard: path.resolve( + __dirname, + '../commcare_connect/static/js/dashboard', + ), vendors: path.resolve(__dirname, '../commcare_connect/static/js/vendors'), }, output: {