Coverage for benefits/core/models/enrollment.py: 96%
128 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
1import logging
2import uuid
4from cdt_identity.models import ClaimsVerificationRequest, IdentityGatewayConfig
5from django.db import models
6from django.utils import timezone
7from multiselectfield import MultiSelectField
9from .common import PemData, SecretNameField
11logger = logging.getLogger(__name__)
14class EnrollmentMethods:
15 SELF_SERVICE = "self_service"
16 IN_PERSON = "in_person"
19SUPPORTED_METHODS = (
20 (EnrollmentMethods.SELF_SERVICE, EnrollmentMethods.SELF_SERVICE.replace("_", "-").capitalize()),
21 (EnrollmentMethods.IN_PERSON, EnrollmentMethods.IN_PERSON.replace("_", "-").capitalize()),
22)
25class SystemName(models.TextChoices):
26 CALFRESH = "calfresh"
27 COURTESY_CARD = "courtesy_card"
28 GCTD_CARD = "gctd_card"
29 MEDICARE = "medicare"
30 METRO_MOBILITY_WALLET = "metro_mobility_wallet"
31 OLDER_ADULT = "senior"
32 REDUCED_FARE_MOBILITY_ID = "mobility_pass"
33 SCMETRO_CARD = "scmetro_card"
34 VETERAN = "veteran"
37class EligibilityApiVerificationRequest(models.Model):
38 """Represents configuration for eligibility verification via Eligibility API calls."""
40 id = models.AutoField(primary_key=True)
41 label = models.SlugField(
42 help_text="A human readable label, used as the display text in Admin.",
43 )
44 api_url = models.URLField(help_text="Fully qualified URL for an Eligibility API server.")
45 api_auth_header = models.CharField(
46 help_text="The auth header to send in Eligibility API requests.",
47 max_length=50,
48 )
49 api_auth_key_secret_name = SecretNameField(
50 help_text="The name of a secret containing the value of the auth header to send in Eligibility API requests.",
51 )
52 client_private_key = models.ForeignKey(
53 PemData,
54 related_name="+",
55 on_delete=models.PROTECT,
56 default=None,
57 null=True,
58 help_text="Private key used to sign Eligibility API tokens created on behalf of the Benefits client.",
59 )
60 client_public_key = models.ForeignKey(
61 PemData,
62 related_name="+",
63 on_delete=models.PROTECT,
64 default=None,
65 null=True,
66 help_text="Public key corresponding to the Benefits client's private key, used by Eligibility Verification servers to encrypt responses.", # noqa: E501
67 )
68 api_public_key = models.ForeignKey(
69 PemData,
70 related_name="+",
71 on_delete=models.PROTECT,
72 help_text="The public key used to encrypt Eligibility API requests and to verify signed Eligibility API responses.",
73 )
74 api_jwe_cek_enc = models.CharField(
75 help_text="The JWE-compatible Content Encryption Key (CEK) key-length and mode to use in Eligibility API requests.",
76 max_length=50,
77 )
78 api_jwe_encryption_alg = models.CharField(
79 help_text="The JWE-compatible encryption algorithm to use in Eligibility API requests.",
80 max_length=50,
81 )
82 api_jws_signing_alg = models.CharField(
83 help_text="The JWS-compatible signing algorithm to use in Eligibility API requests.",
84 max_length=50,
85 )
87 def __str__(self):
88 return self.label
90 @property
91 def api_auth_key(self):
92 """The Eligibility API auth key as a string."""
93 secret_field = self._meta.get_field("api_auth_key_secret_name")
94 return secret_field.secret_value(self)
96 @property
97 def client_private_key_data(self):
98 """The private key used to sign Eligibility API tokens created by the Benefits client as a string."""
99 return self.client_private_key.data
101 @property
102 def client_public_key_data(self):
103 """The public key corresponding to the Benefits client's private key as a string."""
104 return self.client_public_key.data
106 @property
107 def api_public_key_data(self):
108 """The Eligibility API public key as a string."""
109 return self.api_public_key.data
112class EnrollmentFlow(models.Model):
113 """Represents a user journey through the Benefits app for a single eligibility type."""
115 id = models.AutoField(primary_key=True)
116 system_name = models.SlugField(
117 choices=SystemName,
118 help_text="Primary internal system name for this EnrollmentFlow instance, e.g. in analytics and Eligibility API requests.", # noqa: 501
119 )
120 label = models.CharField(
121 blank=True,
122 default="",
123 help_text="A human readable label, used as the display text in Admin.",
124 )
125 supported_enrollment_methods = MultiSelectField(
126 choices=SUPPORTED_METHODS,
127 max_choices=2,
128 max_length=50,
129 default=[EnrollmentMethods.SELF_SERVICE, EnrollmentMethods.IN_PERSON],
130 help_text="If the flow is supported by self-service enrollment, in-person enrollment, or both",
131 )
132 in_person_policy = models.TextField(
133 default="",
134 blank=True,
135 help_text="The policy language used by transit agency staff to verify a user's eligibility in-person.",
136 )
137 sign_out_button_template = models.CharField(default="", blank=True, help_text="Template that renders sign-out button")
138 sign_out_link_template = models.CharField(default="", blank=True, help_text="Template that renders sign-out link")
139 oauth_config = models.ForeignKey(
140 IdentityGatewayConfig,
141 on_delete=models.PROTECT,
142 null=True,
143 blank=True,
144 help_text="The IdG connection details for this flow.",
145 )
146 claims_request = models.ForeignKey(
147 ClaimsVerificationRequest,
148 on_delete=models.PROTECT,
149 null=True,
150 blank=True,
151 help_text="The claims request details for this flow.",
152 )
153 api_request = models.ForeignKey(
154 EligibilityApiVerificationRequest,
155 on_delete=models.PROTECT,
156 null=True,
157 blank=True,
158 help_text="The Eligibility API request details for this flow.",
159 )
160 supports_expiration = models.BooleanField(
161 default=False, help_text="Indicates if the enrollment expires or does not expire"
162 )
163 expiration_days = models.PositiveSmallIntegerField(
164 null=True, blank=True, help_text="If the enrollment supports expiration, number of days before the eligibility expires"
165 )
166 expiration_reenrollment_days = models.PositiveSmallIntegerField(
167 null=True,
168 blank=True,
169 help_text="If the enrollment supports expiration, number of days preceding the expiration date during which a user can re-enroll in the eligibilty", # noqa: E501
170 )
171 display_order = models.PositiveSmallIntegerField(default=0, blank=False, null=False)
173 class Meta:
174 ordering = ["display_order"]
176 def __str__(self):
177 return self.label
179 @property
180 def eligibility_api_auth_key(self):
181 if self.uses_api_verification: 181 ↛ 184line 181 didn't jump to line 184 because the condition on line 181 was always true
182 return self.api_request.api_auth_key
183 else:
184 return None
186 @property
187 def eligibility_api_public_key_data(self):
188 """This flow's Eligibility API public key as a string."""
189 if self.uses_api_verification: 189 ↛ 192line 189 didn't jump to line 192 because the condition on line 189 was always true
190 return self.api_request.api_public_key_data
191 else:
192 return None
194 @property
195 def selection_label_template(self):
196 return self.get_selection_label_template(self.system_name)
198 @property
199 def uses_claims_verification(self):
200 """True if this flow verifies via the Identity Gateway and has a scope and claim. False otherwise."""
201 return (
202 self.oauth_config is not None and bool(self.claims_request.scopes) and bool(self.claims_request.eligibility_claim)
203 )
205 @property
206 def uses_api_verification(self):
207 """True if this flow verifies via the Eligibility API. False otherwise."""
208 return self.api_request is not None
210 @property
211 def claims_scheme(self):
212 if self.uses_claims_verification: 212 ↛ 215line 212 didn't jump to line 215 because the condition on line 212 was always true
213 return self.claims_request.scheme or self.oauth_config.scheme
214 else:
215 return None
217 @property
218 def eligibility_verifier(self):
219 """A str representing the entity that verifies eligibility for this flow.
221 Either the client name of the flow's claims provider, or the URL to the eligibility API.
222 """
223 if self.uses_claims_verification:
224 return self.oauth_config.client_name
225 elif self.uses_api_verification:
226 return self.api_request.api_url
227 else:
228 return "undefined"
230 @property
231 def supports_sign_out(self):
232 return bool(self.sign_out_button_template) or bool(self.sign_out_link_template)
234 @staticmethod
235 def by_id(id):
236 """Get an EnrollmentFlow instance by its ID."""
237 logger.debug(f"Get {EnrollmentFlow.__name__} by id: {id}")
238 return EnrollmentFlow.objects.get(id=id)
240 @staticmethod
241 def get_selection_label_template(system_name):
242 return f"eligibility/includes/selection-label--{system_name}.html"
245class EnrollmentGroup(models.Model):
246 id = models.AutoField(primary_key=True)
247 transit_agency = models.ForeignKey(
248 "core.TransitAgency",
249 on_delete=models.PROTECT,
250 help_text="The transit agency that this group is for.",
251 )
252 enrollment_flow = models.ForeignKey(
253 EnrollmentFlow,
254 on_delete=models.PROTECT,
255 help_text="The enrollment flow that this group is for.",
256 )
258 def __str__(self):
259 return f"{self.enrollment_flow} ({self.transit_agency.slug})"
262class EnrollmentEvent(models.Model):
263 """A record of a successful enrollment."""
265 id = models.UUIDField(primary_key=True, default=uuid.uuid4)
266 transit_agency = models.ForeignKey("core.TransitAgency", on_delete=models.PROTECT)
267 enrollment_flow = models.ForeignKey(EnrollmentFlow, on_delete=models.PROTECT)
268 enrollment_method = models.CharField(
269 choices={
270 EnrollmentMethods.SELF_SERVICE: EnrollmentMethods.SELF_SERVICE,
271 EnrollmentMethods.IN_PERSON: EnrollmentMethods.IN_PERSON,
272 }
273 )
274 verified_by = models.CharField()
275 enrollment_datetime = models.DateTimeField(default=timezone.now)
276 expiration_datetime = models.DateTimeField(blank=True, null=True)
277 extra_claims = models.CharField(blank=True, default="")
279 def __str__(self):
280 dt = timezone.localtime(self.enrollment_datetime)
281 ts = dt.strftime("%b %d, %Y, %I:%M %p")
282 return f"{ts}, {self.transit_agency}, {self.enrollment_flow}"