From a57126430abe3b9f40cccc8d181ec536daae775c Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Fri, 13 Sep 2024 05:00:56 +0000 Subject: [PATCH 1/9] feat(models): define EnrollmentEvent class, migration EnrollmentMethod helper class acts as a container for the constants "digital" and "in_person" --- .../core/migrations/0026_enrollmentevent.py | 28 +++++++++++++++ benefits/core/models.py | 23 ++++++++++++ tests/pytest/core/test_models.py | 36 ++++++++++++++++++- 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 benefits/core/migrations/0026_enrollmentevent.py diff --git a/benefits/core/migrations/0026_enrollmentevent.py b/benefits/core/migrations/0026_enrollmentevent.py new file mode 100644 index 000000000..3f9ae784d --- /dev/null +++ b/benefits/core/migrations/0026_enrollmentevent.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1 on 2024-09-13 04:55 + +import uuid +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0025_transitprocessor_portal_url"), + ] + + operations = [ + migrations.CreateModel( + name="EnrollmentEvent", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("enrollment_method", models.TextField(choices=[("digital", "digital"), ("in_person", "in_person")])), + ("verified_by", models.TextField()), + ("enrollment_datetime", models.DateTimeField(default=django.utils.timezone.now)), + ("expiration_datetime", models.DateTimeField(blank=True, null=True)), + ("enrollment_flow", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="core.enrollmentflow")), + ("transit_agency", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="core.transitagency")), + ], + ), + ] diff --git a/benefits/core/models.py b/benefits/core/models.py index 5a968995a..9a8222d47 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -5,6 +5,7 @@ from functools import cached_property import importlib import logging +import uuid from django.conf import settings from django.core.exceptions import ValidationError @@ -422,3 +423,25 @@ def for_user(user: User): # the loop above returns the first match found. Return None if no match was found. return None + + +class EnrollmentMethods: + DIGITAL = "digital" + IN_PERSON = "in_person" + + +class EnrollmentEvent(models.Model): + """A record of a successful enrollment.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + transit_agency = models.ForeignKey(TransitAgency, on_delete=models.PROTECT) + enrollment_flow = models.ForeignKey(EnrollmentFlow, on_delete=models.PROTECT) + enrollment_method = models.TextField( + choices={ + EnrollmentMethods.DIGITAL: EnrollmentMethods.DIGITAL, + EnrollmentMethods.IN_PERSON: EnrollmentMethods.IN_PERSON, + } + ) + verified_by = models.TextField() + enrollment_datetime = models.DateTimeField(default=timezone.now) + expiration_datetime = models.DateTimeField(blank=True, null=True) diff --git a/tests/pytest/core/test_models.py b/tests/pytest/core/test_models.py index a482203b0..f1dd59ea5 100644 --- a/tests/pytest/core/test_models.py +++ b/tests/pytest/core/test_models.py @@ -1,10 +1,12 @@ +from datetime import timedelta from django.conf import settings from django.contrib.auth.models import Group, User from django.core.exceptions import ValidationError +from django.utils import timezone import pytest -from benefits.core.models import SecretNameField, EnrollmentFlow, TransitAgency +from benefits.core.models import SecretNameField, EnrollmentFlow, TransitAgency, EnrollmentEvent, EnrollmentMethods import benefits.secrets @@ -375,3 +377,35 @@ def test_TransitAgency_for_user_in_group_not_linked_to_any_agency(): user.groups.add(group) assert TransitAgency.for_user(user) is None + + +@pytest.mark.django_db +def test_EnrollmentEvent_create(model_TransitAgency, model_EnrollmentFlow): + ts = timezone.now() + event = EnrollmentEvent.objects.create( + transit_agency=model_TransitAgency, + enrollment_flow=model_EnrollmentFlow, + enrollment_method=EnrollmentMethods.DIGITAL, + verified_by="Test", + ) + + assert event.transit_agency == model_TransitAgency + assert event.enrollment_flow == model_EnrollmentFlow + assert event.enrollment_method == EnrollmentMethods.DIGITAL + assert event.verified_by == "Test" + # default enrollment_datetime should be nearly the same as the timestamp + assert event.enrollment_datetime - ts <= timedelta(milliseconds=1) + + dt = timezone.datetime(2024, 9, 13, 13, 30, 0, tzinfo=timezone.get_default_timezone()) + expiry = dt + timedelta(weeks=52) + event = EnrollmentEvent( + transit_agency=model_TransitAgency, + enrollment_flow=model_EnrollmentFlow, + enrollment_method=EnrollmentMethods.DIGITAL, + verified_by="Test", + enrollment_datetime=dt, + expiration_datetime=expiry, + ) + # enrollment_datetime should equal the given value exactly + assert event.enrollment_datetime == dt + assert event.expiration_datetime == expiry From 254faea3f691692218e273dd84a84b0494ae41a9 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Fri, 13 Sep 2024 05:22:30 +0000 Subject: [PATCH 2/9] feat(models): implement EnrollmentEvent.__str__ return the local enrollment_datetime as a human-readable string, with information about the agency and flow --- benefits/core/models.py | 6 ++++++ tests/pytest/core/test_models.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/benefits/core/models.py b/benefits/core/models.py index 9a8222d47..25abfea16 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -12,6 +12,7 @@ from django.contrib.auth.models import Group, User from django.db import models from django.urls import reverse +from django.utils import timezone import requests @@ -445,3 +446,8 @@ class EnrollmentEvent(models.Model): verified_by = models.TextField() enrollment_datetime = models.DateTimeField(default=timezone.now) expiration_datetime = models.DateTimeField(blank=True, null=True) + + def __str__(self): + dt = timezone.localtime(self.enrollment_datetime) + ts = dt.strftime("%b %d, %Y, %I:%M %p") + return f"{ts}, {self.transit_agency}, {self.enrollment_flow}" diff --git a/tests/pytest/core/test_models.py b/tests/pytest/core/test_models.py index f1dd59ea5..ac49a08df 100644 --- a/tests/pytest/core/test_models.py +++ b/tests/pytest/core/test_models.py @@ -409,3 +409,21 @@ def test_EnrollmentEvent_create(model_TransitAgency, model_EnrollmentFlow): # enrollment_datetime should equal the given value exactly assert event.enrollment_datetime == dt assert event.expiration_datetime == expiry + + +@pytest.mark.django_db +def test_EnrollmentEvent_str(model_TransitAgency, model_EnrollmentFlow): + ts = timezone.datetime(2024, 9, 13, 13, 30, 0, tzinfo=timezone.get_default_timezone()) + + event = EnrollmentEvent.objects.create( + transit_agency=model_TransitAgency, + enrollment_flow=model_EnrollmentFlow, + enrollment_method=EnrollmentMethods.DIGITAL, + verified_by="Test", + enrollment_datetime=ts, + ) + event_str = str(event) + + assert "Sep 13, 2024, 01:30 PM" in event_str + assert str(event.transit_agency) in event_str + assert str(event.enrollment_flow) in event_str From 8ba6f6c93eb2e8dd2e2faf6ce1844b4597aa0ef3 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Fri, 13 Sep 2024 05:29:11 +0000 Subject: [PATCH 3/9] feat(admin): staff permissions for EnrollmentEvent when running in production EnrollmentEvent fields are readonly ease this restriction for testing in other environments for superusers helper class RUNTIME_ENVS to contain environment strings --- benefits/core/admin.py | 40 +++++++++++++++++++ .../core/migrations/0026_enrollmentevent.py | 2 +- benefits/settings.py | 15 +++++-- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/benefits/core/admin.py b/benefits/core/admin.py index a21f8128c..c53a7e607 100644 --- a/benefits/core/admin.py +++ b/benefits/core/admin.py @@ -9,6 +9,8 @@ from django.conf import settings from django.contrib import admin from django.contrib.auth.models import Group +from django.http import HttpRequest + from . import models logger = logging.getLogger(__name__) @@ -120,6 +122,39 @@ def get_readonly_fields(self, request, obj=None): return super().get_readonly_fields(request, obj) +@admin.register(models.EnrollmentEvent) +class EnrollmentEventAdmin(admin.ModelAdmin): # pragma: no cover + def has_add_permission(self, request: HttpRequest, obj=None): + if settings.RUNTIME_ENVIRONMENT() == settings.RUNTIME_ENVS.PROD: + return False + elif request.user and (request.user.is_superuser or is_staff_member(request.user)): + return True + else: + return False + + def has_change_permission(self, request: HttpRequest, obj=None): + if settings.RUNTIME_ENVIRONMENT() == settings.RUNTIME_ENVS.PROD: + return False + elif request.user and request.user.is_superuser: + return True + else: + return False + + def has_delete_permission(self, request: HttpRequest, obj=None): + if settings.RUNTIME_ENVIRONMENT() == settings.RUNTIME_ENVS.PROD: + return False + elif request.user and (request.user.is_superuser or is_staff_member(request.user)): + return True + else: + return False + + def has_view_permission(self, request: HttpRequest, obj=None): + if request.user and (request.user.is_superuser or is_staff_member(request.user)): + return True + else: + return False + + def pre_login_user(user, request): logger.debug(f"Running pre-login callback for user: {user.username}") add_google_sso_userinfo(user, request) @@ -160,3 +195,8 @@ def add_transit_agency_staff_user_to_group(user, request): agency = models.TransitAgency.objects.filter(sso_domain=user_sso_domain).first() if agency is not None and agency.staff_group: agency.staff_group.user_set.add(user) + + +def is_staff_member(user): + staff_group = Group.objects.get(name=settings.STAFF_GROUP_NAME) + return staff_group.user_set.contains(user) diff --git a/benefits/core/migrations/0026_enrollmentevent.py b/benefits/core/migrations/0026_enrollmentevent.py index 3f9ae784d..e4740bd78 100644 --- a/benefits/core/migrations/0026_enrollmentevent.py +++ b/benefits/core/migrations/0026_enrollmentevent.py @@ -24,5 +24,5 @@ class Migration(migrations.Migration): ("enrollment_flow", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="core.enrollmentflow")), ("transit_agency", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="core.transitagency")), ], - ), + ) ] diff --git a/benefits/settings.py b/benefits/settings.py index 1310c1b6e..c4202eff8 100644 --- a/benefits/settings.py +++ b/benefits/settings.py @@ -25,18 +25,25 @@ def _filter_empty(ls): ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",")) +class RUNTIME_ENVS: + LOCAL = "local" + DEV = "dev" + TEST = "test" + PROD = "prod" + + def RUNTIME_ENVIRONMENT(): """Helper calculates the current runtime environment from ALLOWED_HOSTS.""" # usage of django.conf.settings.ALLOWED_HOSTS here (rather than the module variable directly) # is to ensure dynamic calculation, e.g. for unit tests and elsewhere this setting is needed - env = "local" + env = RUNTIME_ENVS.LOCAL if "dev-benefits.calitp.org" in settings.ALLOWED_HOSTS: - env = "dev" + env = RUNTIME_ENVS.DEV elif "test-benefits.calitp.org" in settings.ALLOWED_HOSTS: - env = "test" + env = RUNTIME_ENVS.TEST elif "benefits.calitp.org" in settings.ALLOWED_HOSTS: - env = "prod" + env = RUNTIME_ENVS.PROD return env From 8cb5acab4e7116286726ae55945f509a47082195 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Fri, 13 Sep 2024 06:42:45 +0000 Subject: [PATCH 4/9] feat(admin): basic EnrollmentEvent table --- benefits/core/admin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/benefits/core/admin.py b/benefits/core/admin.py index c53a7e607..28b8e76b6 100644 --- a/benefits/core/admin.py +++ b/benefits/core/admin.py @@ -124,6 +124,8 @@ def get_readonly_fields(self, request, obj=None): @admin.register(models.EnrollmentEvent) class EnrollmentEventAdmin(admin.ModelAdmin): # pragma: no cover + list_display = ("enrollment_datetime", "transit_agency", "enrollment_flow", "enrollment_method", "verified_by") + def has_add_permission(self, request: HttpRequest, obj=None): if settings.RUNTIME_ENVIRONMENT() == settings.RUNTIME_ENVS.PROD: return False From 1bbe0c203d1b879e08a4bed95551acc3dbe06a99 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Fri, 13 Sep 2024 16:04:17 +0000 Subject: [PATCH 5/9] chore(fixtures): load some sample EnrollmentEvents --- benefits/core/migrations/local_fixtures.json | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/benefits/core/migrations/local_fixtures.json b/benefits/core/migrations/local_fixtures.json index a1f63994b..48994f3e4 100644 --- a/benefits/core/migrations/local_fixtures.json +++ b/benefits/core/migrations/local_fixtures.json @@ -202,5 +202,53 @@ "groups": [2], "user_permissions": [] } + }, + { + "model": "core.enrollmentevent", + "pk": "49328a98-f829-4009-a16a-d71ece9e51e3", + "fields": { + "transit_agency": 1, + "enrollment_flow": 3, + "enrollment_method": "digital", + "verified_by": "http://server:8000/verify", + "enrollment_datetime": "2024-09-13T16:00:00.000Z", + "expiration_datetime": null + } + }, + { + "model": "core.enrollmentevent", + "pk": "b1906c39-ae91-4b17-ae71-a93c4d4e546b", + "fields": { + "transit_agency": 1, + "enrollment_flow": 2, + "enrollment_method": "in_person", + "verified_by": "Test User", + "enrollment_datetime": "2024-09-13T15:45:30.000Z", + "expiration_datetime": null + } + }, + { + "model": "core.enrollmentevent", + "pk": "b2038a25-ce2a-46f0-80a0-aea49e45e1e9", + "fields": { + "transit_agency": 1, + "enrollment_flow": 1, + "enrollment_method": "digital", + "verified_by": "benefits-oauth-client-name", + "enrollment_datetime": "2024-09-12T18:25:25.000Z", + "expiration_datetime": null + } + }, + { + "model": "core.enrollmentevent", + "pk": "cf28906c-5709-4055-b6fd-554563ca1286", + "fields": { + "transit_agency": 1, + "enrollment_flow": 4, + "enrollment_method": "digital", + "verified_by": "benefits-oauth-client-name", + "enrollment_datetime": "2024-09-11T20:00:00.000Z", + "expiration_datetime": "2025-09-12T07:00:00.000Z" + } } ] From 249fd2b19a4dd7ffe86ed55104cab9dd5a4cf3ea Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Fri, 13 Sep 2024 21:41:28 +0000 Subject: [PATCH 6/9] feat(enrollment): EnrollmentEvent for digital success refactor the view tests to move away from expiration vs. non-expiration; this distinction isn't relevant in the tests since they are mocking the enrollment.enroll() function, so expiration calculation never occurs instead test an enrollment for eligibility API vs. claims verification, the respective EnrollmentEvents should contain different data --- benefits/enrollment/views.py | 13 ++++++++++ tests/pytest/enrollment/test_views.py | 37 +++++++++++++++++++++------ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 71f07e848..6f92b6757 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -15,6 +15,7 @@ from benefits.core import session from benefits.core.middleware import EligibleSessionRequired, FlowSessionRequired, pageview_decorator +from benefits.core import models from . import analytics, forms from .enrollment import Status, request_card_tokenization_access, enroll @@ -67,6 +68,18 @@ def index(request): match (status): case Status.SUCCESS: + agency = session.agency(request) + flow = session.flow(request) + expiry = session.enrollment_expiry(request) + verified_by = flow.claims_provider.client_name if flow.uses_claims_verification else flow.eligibility_api_url + event = models.EnrollmentEvent.objects.create( + transit_agency=agency, + enrollment_flow=flow, + enrollment_method=models.EnrollmentMethods.DIGITAL, + verified_by=verified_by, + expiration_datetime=expiry, + ) + event.save() return success(request) case Status.SYSTEM_ERROR: diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 29fffd4cc..ec4af6c6d 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -5,6 +5,7 @@ from django.urls import reverse from requests import HTTPError +from benefits.core import models from benefits.routes import routes import benefits.enrollment.views from benefits.enrollment.enrollment import Status, CardTokenizationAccessResponse @@ -297,42 +298,62 @@ def test_index_eligible_post_valid_form_exception(mocker, client, card_tokenize_ @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_flow", "mocked_session_eligible") -def test_index_eligible_post_valid_form_success_does_not_support_expiration( +def test_index_eligible_post_valid_form_success_claims( mocker, client, card_tokenize_form_data, mocked_analytics_module, - model_EnrollmentFlow_does_not_support_expiration, + model_TransitAgency, + model_EnrollmentFlow_with_scope_and_claim, ): mocker.patch("benefits.enrollment.views.enroll", return_value=(Status.SUCCESS, None)) + spy = mocker.spy(benefits.enrollment.views.models.EnrollmentEvent.objects, "create") path = reverse(routes.ENROLLMENT_INDEX) response = client.post(path, card_tokenize_form_data) + spy.assert_called_once_with( + transit_agency=model_TransitAgency, + enrollment_flow=model_EnrollmentFlow_with_scope_and_claim, + enrollment_method=models.EnrollmentMethods.DIGITAL, + verified_by=model_EnrollmentFlow_with_scope_and_claim.claims_provider.client_name, + expiration_datetime=None, + ) + assert response.status_code == 200 - assert response.template_name == model_EnrollmentFlow_does_not_support_expiration.enrollment_success_template + assert response.template_name == model_EnrollmentFlow_with_scope_and_claim.enrollment_success_template mocked_analytics_module.returned_success.assert_called_once() - assert model_EnrollmentFlow_does_not_support_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + assert model_EnrollmentFlow_with_scope_and_claim.group_id in mocked_analytics_module.returned_success.call_args.args @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_flow", "mocked_session_eligible") -def test_index_eligible_post_valid_form_success_supports_expiration( +def test_index_eligible_post_valid_form_success_eligibility_api( mocker, client, card_tokenize_form_data, mocked_analytics_module, - model_EnrollmentFlow_supports_expiration, + model_TransitAgency, + model_EnrollmentFlow_with_eligibility_api, ): mocker.patch("benefits.enrollment.views.enroll", return_value=(Status.SUCCESS, None)) + spy = mocker.spy(benefits.enrollment.views.models.EnrollmentEvent.objects, "create") path = reverse(routes.ENROLLMENT_INDEX) response = client.post(path, card_tokenize_form_data) + spy.assert_called_once_with( + transit_agency=model_TransitAgency, + enrollment_flow=model_EnrollmentFlow_with_eligibility_api, + enrollment_method=models.EnrollmentMethods.DIGITAL, + verified_by=model_EnrollmentFlow_with_eligibility_api.eligibility_api_url, + expiration_datetime=None, + ) + assert response.status_code == 200 - assert response.template_name == model_EnrollmentFlow_supports_expiration.enrollment_success_template + assert response.template_name == model_EnrollmentFlow_with_eligibility_api.enrollment_success_template mocked_analytics_module.returned_success.assert_called_once() - assert model_EnrollmentFlow_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + assert model_EnrollmentFlow_with_eligibility_api.group_id in mocked_analytics_module.returned_success.call_args.args @pytest.mark.django_db From 4f5355818990fb895d6d0727f377aa47314dbaba Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Fri, 13 Sep 2024 21:44:53 +0000 Subject: [PATCH 7/9] refactor(enrollment): send success analytics event in POST back prevent sending false positive events if the success view is requested directly this aligns with where error events are sent --- benefits/enrollment/views.py | 2 +- tests/pytest/enrollment/test_views.py | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 6f92b6757..666845042 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -80,6 +80,7 @@ def index(request): expiration_datetime=expiry, ) event.save() + analytics.returned_success(request, flow.group_id) return success(request) case Status.SYSTEM_ERROR: @@ -181,6 +182,5 @@ def success(request): # if they click the logout button, they are taken to the new route session.update(request, origin=reverse(routes.LOGGED_OUT)) - analytics.returned_success(request, flow.group_id) context = {"redirect_to": request.path} return TemplateResponse(request, flow.enrollment_success_template, context) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index ec4af6c6d..81b027aa0 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -461,7 +461,7 @@ def test_success_no_flow(client): @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_flow_uses_claims_verification", "mocked_session_eligible") -def test_success_authentication_logged_in(mocker, client, model_TransitAgency, model_EnrollmentFlow, mocked_analytics_module): +def test_success_authentication_logged_in(mocker, client, model_TransitAgency, model_EnrollmentFlow): mock_session = mocker.patch("benefits.enrollment.views.session") mock_session.logged_in.return_value = True mock_session.agency.return_value = model_TransitAgency @@ -473,14 +473,11 @@ def test_success_authentication_logged_in(mocker, client, model_TransitAgency, m assert response.status_code == 200 assert response.template_name == model_EnrollmentFlow.enrollment_success_template assert {"origin": reverse(routes.LOGGED_OUT)} in mock_session.update.call_args - mocked_analytics_module.returned_success.assert_called_once() @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_flow_uses_claims_verification", "mocked_session_eligible") -def test_success_authentication_not_logged_in( - mocker, client, model_TransitAgency, model_EnrollmentFlow, mocked_analytics_module -): +def test_success_authentication_not_logged_in(mocker, client, model_TransitAgency, model_EnrollmentFlow): mock_session = mocker.patch("benefits.enrollment.views.session") mock_session.logged_in.return_value = False mock_session.agency.return_value = model_TransitAgency @@ -491,12 +488,11 @@ def test_success_authentication_not_logged_in( assert response.status_code == 200 assert response.template_name == model_EnrollmentFlow.enrollment_success_template - mocked_analytics_module.returned_success.assert_called_once() @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligible") -def test_success_no_authentication(client, mocked_session_flow_does_not_use_claims_verification, mocked_analytics_module): +def test_success_no_authentication(client, mocked_session_flow_does_not_use_claims_verification): path = reverse(routes.ENROLLMENT_SUCCESS) response = client.get(path) @@ -504,4 +500,3 @@ def test_success_no_authentication(client, mocked_session_flow_does_not_use_clai assert ( response.template_name == mocked_session_flow_does_not_use_claims_verification.return_value.enrollment_success_template ) - mocked_analytics_module.returned_success.assert_called_once() From 074971df189ac5988b056a1421e02e1cb955a27a Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Mon, 16 Sep 2024 22:01:52 +0000 Subject: [PATCH 8/9] feat(enrollment): EnrollmentEvent for in-person success moved the model_User test fixture so it could be re-used to confirm the EnrollmentEvent.verified_by field for in-person --- benefits/in_person/views.py | 17 ++++++++++++++--- tests/pytest/conftest.py | 6 ++++++ tests/pytest/in_person/test_views.py | 19 ++++++++++++++++++- tests/pytest/test_admin.py | 7 +------ 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/benefits/in_person/views.py b/benefits/in_person/views.py index 71a2f2bfb..7a222fc3b 100644 --- a/benefits/in_person/views.py +++ b/benefits/in_person/views.py @@ -9,8 +9,7 @@ from benefits.routes import routes -from benefits.core import session -from benefits.core.models import EnrollmentFlow +from benefits.core import models, session from benefits.enrollment.enrollment import Status, request_card_tokenization_access, enroll from benefits.in_person import forms @@ -29,7 +28,7 @@ def eligibility(request): if form.is_valid(): flow_id = form.cleaned_data.get("flow") - flow = EnrollmentFlow.objects.get(id=flow_id) + flow = models.EnrollmentFlow.objects.get(id=flow_id) session.update(request, flow=flow) in_person_enrollment = reverse(routes.IN_PERSON_ENROLLMENT) @@ -79,6 +78,18 @@ def enrollment(request): match (status): case Status.SUCCESS: + agency = session.agency(request) + flow = session.flow(request) + expiry = session.enrollment_expiry(request) + verified_by = f"{request.user.first_name} {request.user.last_name}" + event = models.EnrollmentEvent.objects.create( + transit_agency=agency, + enrollment_flow=flow, + enrollment_method=models.EnrollmentMethods.IN_PERSON, + verified_by=verified_by, + expiration_datetime=expiry, + ) + event.save() return redirect(routes.IN_PERSON_ENROLLMENT_SUCCESS) case Status.SYSTEM_ERROR: diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index a775d2a9f..6be63571e 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -1,4 +1,5 @@ from unittest.mock import create_autospec +from django.contrib.auth.models import User from django.contrib.sessions.middleware import SessionMiddleware from django.middleware.locale import LocaleMiddleware from django.utils import timezone @@ -33,6 +34,11 @@ def app_request(rf): return app_request +@pytest.fixture +def model_User(): + return User.objects.create(is_active=True, is_staff=True, first_name="Test", last_name="User") + + # autouse this fixture so we never call out to the real secret store @pytest.fixture(autouse=True) def mock_models_get_secret_by_name(mocker): diff --git a/tests/pytest/in_person/test_views.py b/tests/pytest/in_person/test_views.py index 3871a31e1..0bbcd0f32 100644 --- a/tests/pytest/in_person/test_views.py +++ b/tests/pytest/in_person/test_views.py @@ -6,7 +6,9 @@ from requests import HTTPError +from benefits.core import models from benefits.enrollment.enrollment import Status, CardTokenizationAccessResponse +import benefits.in_person.views from benefits.routes import routes @@ -242,12 +244,27 @@ def test_enrollment_post_invalid_form(admin_client, invalid_form_data): @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_flow", "model_EnrollmentFlow") -def test_enrollment_post_valid_form_success(mocker, admin_client, card_tokenize_form_data): +def test_enrollment_post_valid_form_success( + mocker, admin_client, card_tokenize_form_data, model_TransitAgency, model_EnrollmentFlow, model_User +): mocker.patch("benefits.in_person.views.enroll", return_value=(Status.SUCCESS, None)) + spy = mocker.spy(benefits.in_person.views.models.EnrollmentEvent.objects, "create") + + # force the model_User to be the logged in user + # e.g. the TransitAgency staff person assisting this in-person enrollment + admin_client.force_login(model_User) path = reverse(routes.IN_PERSON_ENROLLMENT) response = admin_client.post(path, card_tokenize_form_data) + spy.assert_called_once_with( + transit_agency=model_TransitAgency, + enrollment_flow=model_EnrollmentFlow, + enrollment_method=models.EnrollmentMethods.IN_PERSON, + verified_by=f"{model_User.first_name} {model_User.last_name}", + expiration_datetime=None, + ) + assert response.status_code == 302 assert response.url == reverse(routes.IN_PERSON_ENROLLMENT_SUCCESS) diff --git a/tests/pytest/test_admin.py b/tests/pytest/test_admin.py index 83e0feb10..91e78d27e 100644 --- a/tests/pytest/test_admin.py +++ b/tests/pytest/test_admin.py @@ -1,15 +1,10 @@ import pytest from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group from django.urls import reverse from benefits.routes import routes -@pytest.fixture -def model_User(): - return User.objects.create(is_active=True, is_staff=True) - - @pytest.fixture def model_AgencyUser(model_User): cst_group = Group.objects.create(name="CST") From 784abb02b972730965473a4444e322c625a96c05 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 17 Sep 2024 21:59:21 +0000 Subject: [PATCH 9/9] chore(admin): ensure EnrollmentEvent.id is always read-only --- benefits/core/admin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/benefits/core/admin.py b/benefits/core/admin.py index 28b8e76b6..f9bf8adf2 100644 --- a/benefits/core/admin.py +++ b/benefits/core/admin.py @@ -126,6 +126,9 @@ def get_readonly_fields(self, request, obj=None): class EnrollmentEventAdmin(admin.ModelAdmin): # pragma: no cover list_display = ("enrollment_datetime", "transit_agency", "enrollment_flow", "enrollment_method", "verified_by") + def get_readonly_fields(self, request: HttpRequest, obj=None): + return ["id"] + def has_add_permission(self, request: HttpRequest, obj=None): if settings.RUNTIME_ENVIRONMENT() == settings.RUNTIME_ENVS.PROD: return False