Coverage for benefits/enrollment/enrollment.py: 99%
74 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 19:50 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 19:50 +0000
1from dataclasses import dataclass
2from datetime import datetime, timedelta
3from enum import Enum
5import sentry_sdk
6from django.shortcuts import redirect
7from django.utils import timezone
9from benefits.core import models, session
10from benefits.routes import routes
12from . import analytics
15class Status(Enum):
16 # SUCCESS means the enrollment went through successfully
17 SUCCESS = 1
19 # SYSTEM_ERROR means the enrollment system encountered an internal error (returned a 500 HTTP status)
20 SYSTEM_ERROR = 2
22 # EXCEPTION means the enrollment system is working, but something unexpected happened
23 # because of a misconfiguration or invalid request from our side
24 EXCEPTION = 3
26 # REENROLLMENT_ERROR means that the user tried to re-enroll but is not within the reenrollment window
27 REENROLLMENT_ERROR = 4
30@dataclass
31class EnrollmentDecision:
32 status: Status
33 expiry_to_store: datetime | None = None
34 expiry_to_send: datetime | None = None
35 should_enroll: bool = False
36 should_remove_expiry: bool = False
39def _is_expired(expiry_date: datetime):
40 """Returns whether the given datetime is in the past or not."""
41 return expiry_date <= timezone.now()
44def _is_within_reenrollment_window(expiry_date: datetime, reenrollment_date: datetime):
45 """Returns if the current datetime is between the reenrollment_date and the expiry_date (the reenrollment window)."""
46 return reenrollment_date <= timezone.now() < expiry_date
49def _calculate_expiry(expiration_days: int):
50 """Returns the expiry datetime, which should be midnight in the configured timezone of the (N + 1)th day from now,
51 where N is expiration_days."""
52 default_time_zone = timezone.get_default_timezone()
53 expiry_date = timezone.localtime(timezone=default_time_zone) + timedelta(days=expiration_days + 1)
54 expiry_datetime = expiry_date.replace(hour=0, minute=0, second=0, microsecond=0)
56 return expiry_datetime
59def _calculate_reenrollment_start(expiry: datetime, reenrollment_days: int) -> datetime:
60 """Returns the first date at which reenrollment can occur (renenrollment_days before the expiry)."""
61 return expiry - timedelta(days=reenrollment_days)
64def resolve_enrollment_decision(
65 flow: models.EnrollmentFlow, already_enrolled: bool, existing_expiry: datetime | None
66) -> EnrollmentDecision:
67 """
68 Make a (provider-agnostic) enrollment decision given the inputs based on:
70 - If the flow supports expiration or not
71 - If the user is already enrolled or not
72 - If the enrollment has an existing expiry or not
73 - If the existing expiry is valid, within the reenrollment window, or expired
74 """
75 # flow does not support expiration
76 if not flow.supports_expiration:
77 if not already_enrolled:
78 # not yet enrolled
79 return EnrollmentDecision(status=Status.SUCCESS, should_enroll=True)
81 if existing_expiry is not None:
82 # already enrolled with existing expiry: remove
83 return EnrollmentDecision(status=Status.SUCCESS, should_remove_expiry=True)
85 # already enrolled without existing expiry: no-op
86 return EnrollmentDecision(status=Status.SUCCESS)
88 # flow supports expiration
89 new_expiry = _calculate_expiry(flow.expiration_days)
91 # not yet enrolled and/or no existing expiry
92 if not already_enrolled or existing_expiry is None:
93 return EnrollmentDecision(
94 status=Status.SUCCESS,
95 expiry_to_store=new_expiry,
96 expiry_to_send=new_expiry,
97 should_enroll=True,
98 )
100 # already enrolled, with expired enrollment or within reenrollment window
101 reenrollment_start = _calculate_reenrollment_start(existing_expiry, flow.expiration_reenrollment_days)
102 if _is_expired(existing_expiry) or _is_within_reenrollment_window(existing_expiry, reenrollment_start):
103 return EnrollmentDecision(
104 status=Status.SUCCESS,
105 expiry_to_store=new_expiry,
106 expiry_to_send=new_expiry,
107 should_enroll=True,
108 )
110 # already enrolled, not expired and not within reenrollment window
111 return EnrollmentDecision(status=Status.REENROLLMENT_ERROR, expiry_to_store=existing_expiry)
114def handle_enrollment_results(
115 request,
116 status: Status,
117 verified_by: str,
118 exception: Exception = None,
119 enrollment_method: str = models.EnrollmentMethods.SELF_SERVICE,
120 route_reenrollment_error=routes.ENROLLMENT_REENROLLMENT_ERROR,
121 route_success=routes.ENROLLMENT_SUCCESS,
122 route_system_error=routes.ENROLLMENT_SYSTEM_ERROR,
123 card_category: str = None,
124 card_scheme: str = None,
125):
126 """
127 Handle the results of a provider enrollment call by sending the appropriate analytics events,
128 and redirecting the user to the correct route.
129 """
130 flow = session.flow(request)
131 agency = session.agency(request)
132 group_id = str(session.group(request).group_id) # needs to be a string for the API call
133 match (status):
134 case Status.SUCCESS:
135 expiry = session.enrollment_expiry(request)
136 oauth_extra_claims = session.oauth_extra_claims(request)
137 # EnrollmentEvent expects a string value for extra_claims
138 if oauth_extra_claims:
139 str_extra_claims = ", ".join(oauth_extra_claims)
140 else:
141 str_extra_claims = ""
143 agencies_to_report = [agency]
144 agencies_to_report.extend(agency.group_agencies())
146 for agency in agencies_to_report:
147 event = models.EnrollmentEvent.objects.create(
148 transit_agency=agency,
149 enrollment_flow=flow,
150 enrollment_method=enrollment_method,
151 verified_by=verified_by,
152 expiration_datetime=expiry,
153 extra_claims=str_extra_claims,
154 )
155 event.save()
157 analytics.returned_success(
158 request,
159 agency=agency,
160 enrollment_group=group_id,
161 transit_processor=agency.transit_processor_system_name,
162 enrollment_method=enrollment_method,
163 extra_claims=oauth_extra_claims,
164 card_scheme=card_scheme,
165 card_category=card_category,
166 )
168 return redirect(route_success)
170 case Status.SYSTEM_ERROR:
171 analytics.returned_error(
172 request,
173 str(exception),
174 agency=agency,
175 enrollment_group=group_id,
176 transit_processor=agency.transit_processor_system_name,
177 enrollment_method=enrollment_method,
178 )
179 sentry_sdk.capture_exception(exception)
180 return redirect(route_system_error)
182 case Status.EXCEPTION:
183 analytics.returned_error(
184 request,
185 str(exception),
186 agency=agency,
187 enrollment_group=group_id,
188 transit_processor=agency.transit_processor_system_name,
189 enrollment_method=enrollment_method,
190 )
191 raise exception
193 case Status.REENROLLMENT_ERROR: 193 ↛ exitline 193 didn't return from function 'handle_enrollment_results' because the pattern on line 193 always matched
194 analytics.returned_error(
195 request,
196 "Re-enrollment error.",
197 agency=agency,
198 enrollment_group=group_id,
199 transit_processor=agency.transit_processor_system_name,
200 enrollment_method=enrollment_method,
201 )
202 return redirect(route_reenrollment_error)