Coverage for benefits / core / models / enrollment.py: 96%

133 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 15:39 +0000

1import logging 

2import uuid 

3 

4from cdt_identity.models import ClaimsVerificationRequest, IdentityGatewayConfig 

5from django.core.exceptions import ValidationError 

6from django.db import models 

7from django.utils import timezone 

8from multiselectfield import MultiSelectField 

9 

10from .common import PemData, SecretNameField, template_path 

11 

12logger = logging.getLogger(__name__) 

13 

14 

15class EnrollmentMethods: 

16 DIGITAL = "digital" 

17 IN_PERSON = "in_person" 

18 

19 

20SUPPORTED_METHODS = ( 

21 (EnrollmentMethods.DIGITAL, EnrollmentMethods.DIGITAL.capitalize()), 

22 (EnrollmentMethods.IN_PERSON, EnrollmentMethods.IN_PERSON.replace("_", "-").capitalize()), 

23) 

24 

25 

26class SystemName(models.TextChoices): 

27 CALFRESH = "calfresh" 

28 COURTESY_CARD = "courtesy_card" 

29 MEDICARE = "medicare" 

30 OLDER_ADULT = "senior" 

31 REDUCED_FARE_MOBILITY_ID = "mobility_pass" 

32 VETERAN = "veteran" 

33 

34 

35class EligibilityApiVerificationRequest(models.Model): 

36 """Represents configuration for eligibility verification via Eligibility API calls.""" 

37 

38 id = models.AutoField(primary_key=True) 

39 label = models.SlugField( 

40 help_text="A human readable label, used as the display text in Admin.", 

41 ) 

42 api_url = models.URLField(help_text="Fully qualified URL for an Eligibility API server.") 

43 api_auth_header = models.CharField( 

44 help_text="The auth header to send in Eligibility API requests.", 

45 max_length=50, 

46 ) 

47 api_auth_key_secret_name = SecretNameField( 

48 help_text="The name of a secret containing the value of the auth header to send in Eligibility API requests.", 

49 ) 

50 client_private_key = models.ForeignKey( 

51 PemData, 

52 related_name="+", 

53 on_delete=models.PROTECT, 

54 default=None, 

55 null=True, 

56 help_text="Private key used to sign Eligibility API tokens created on behalf of the Benefits client.", 

57 ) 

58 client_public_key = models.ForeignKey( 

59 PemData, 

60 related_name="+", 

61 on_delete=models.PROTECT, 

62 default=None, 

63 null=True, 

64 help_text="Public key corresponding to the Benefits client's private key, used by Eligibility Verification servers to encrypt responses.", # noqa: E501 

65 ) 

66 api_public_key = models.ForeignKey( 

67 PemData, 

68 related_name="+", 

69 on_delete=models.PROTECT, 

70 help_text="The public key used to encrypt Eligibility API requests and to verify signed Eligibility API responses.", 

71 ) 

72 api_jwe_cek_enc = models.CharField( 

73 help_text="The JWE-compatible Content Encryption Key (CEK) key-length and mode to use in Eligibility API requests.", 

74 max_length=50, 

75 ) 

76 api_jwe_encryption_alg = models.CharField( 

77 help_text="The JWE-compatible encryption algorithm to use in Eligibility API requests.", 

78 max_length=50, 

79 ) 

80 api_jws_signing_alg = models.CharField( 

81 help_text="The JWS-compatible signing algorithm to use in Eligibility API requests.", 

82 max_length=50, 

83 ) 

84 

85 def __str__(self): 

86 return self.label 

87 

88 @property 

89 def api_auth_key(self): 

90 """The Eligibility API auth key as a string.""" 

91 secret_field = self._meta.get_field("api_auth_key_secret_name") 

92 return secret_field.secret_value(self) 

93 

94 @property 

95 def client_private_key_data(self): 

96 """The private key used to sign Eligibility API tokens created by the Benefits client as a string.""" 

97 return self.client_private_key.data 

98 

99 @property 

100 def client_public_key_data(self): 

101 """The public key corresponding to the Benefits client's private key as a string.""" 

102 return self.client_public_key.data 

103 

104 @property 

105 def api_public_key_data(self): 

106 """The Eligibility API public key as a string.""" 

107 return self.api_public_key.data 

108 

109 

110class EnrollmentFlow(models.Model): 

111 """Represents a user journey through the Benefits app for a single eligibility type.""" 

112 

113 supported_in_person_flows = ( 

114 SystemName.COURTESY_CARD.value, 

115 SystemName.MEDICARE.value, 

116 SystemName.OLDER_ADULT.value, 

117 SystemName.REDUCED_FARE_MOBILITY_ID, 

118 ) 

119 

120 id = models.AutoField(primary_key=True) 

121 system_name = models.SlugField( 

122 choices=SystemName, 

123 help_text="Primary internal system name for this EnrollmentFlow instance, e.g. in analytics and Eligibility API requests.", # noqa: 501 

124 ) 

125 label = models.TextField( 

126 blank=True, 

127 default="", 

128 help_text="A human readable label, used as the display text in Admin.", 

129 ) 

130 supported_enrollment_methods = MultiSelectField( 

131 choices=SUPPORTED_METHODS, 

132 max_choices=2, 

133 max_length=50, 

134 default=[EnrollmentMethods.DIGITAL, EnrollmentMethods.IN_PERSON], 

135 help_text="If the flow is supported by digital enrollment, in-person enrollment, or both", 

136 ) 

137 sign_out_button_template = models.TextField(default="", blank=True, help_text="Template that renders sign-out button") 

138 sign_out_link_template = models.TextField(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) 

172 

173 class Meta: 

174 ordering = ["display_order"] 

175 

176 def __str__(self): 

177 return self.label 

178 

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 

185 

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 

193 

194 @property 

195 def selection_label_template(self): 

196 return f"eligibility/includes/selection-label--{self.system_name}.html" 

197 

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 ) 

204 

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 

209 

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 

216 

217 @property 

218 def eligibility_verifier(self): 

219 """A str representing the entity that verifies eligibility for this flow. 

220 

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" 

229 

230 @property 

231 def supports_sign_out(self): 

232 return bool(self.sign_out_button_template) or bool(self.sign_out_link_template) 

233 

234 def clean(self): 

235 errors = [] 

236 

237 templates = [ 

238 self.selection_label_template, 

239 ] 

240 

241 # since templates are calculated from the pattern or the override field 

242 # we can't add a field-level validation error 

243 # so just create directly for a missing template 

244 for t in templates: 

245 if not template_path(t): 

246 errors.append(ValidationError(f"Template not found: {t}")) 

247 

248 if ( 

249 EnrollmentMethods.IN_PERSON in self.supported_enrollment_methods 

250 and self.system_name not in self.supported_in_person_flows 

251 ): 

252 errors.append( 

253 ValidationError(f"{self.system_name} not configured for in-person enrollment. Please uncheck to continue.") 

254 ) 

255 

256 if errors: 

257 raise ValidationError(errors) 

258 

259 @staticmethod 

260 def by_id(id): 

261 """Get an EnrollmentFlow instance by its ID.""" 

262 logger.debug(f"Get {EnrollmentFlow.__name__} by id: {id}") 

263 return EnrollmentFlow.objects.get(id=id) 

264 

265 

266class EnrollmentGroup(models.Model): 

267 id = models.AutoField(primary_key=True) 

268 transit_agency = models.ForeignKey( 

269 "core.TransitAgency", 

270 on_delete=models.PROTECT, 

271 help_text="The transit agency that this group is for.", 

272 ) 

273 enrollment_flow = models.ForeignKey( 

274 EnrollmentFlow, 

275 on_delete=models.PROTECT, 

276 help_text="The enrollment flow that this group is for.", 

277 ) 

278 

279 def __str__(self): 

280 return f"{self.enrollment_flow} ({self.transit_agency.slug})" 

281 

282 

283class EnrollmentEvent(models.Model): 

284 """A record of a successful enrollment.""" 

285 

286 id = models.UUIDField(primary_key=True, default=uuid.uuid4) 

287 transit_agency = models.ForeignKey("core.TransitAgency", on_delete=models.PROTECT) 

288 enrollment_flow = models.ForeignKey(EnrollmentFlow, on_delete=models.PROTECT) 

289 enrollment_method = models.TextField( 

290 choices={ 

291 EnrollmentMethods.DIGITAL: EnrollmentMethods.DIGITAL, 

292 EnrollmentMethods.IN_PERSON: EnrollmentMethods.IN_PERSON, 

293 } 

294 ) 

295 verified_by = models.TextField() 

296 enrollment_datetime = models.DateTimeField(default=timezone.now) 

297 expiration_datetime = models.DateTimeField(blank=True, null=True) 

298 extra_claims = models.TextField(blank=True, default="") 

299 

300 def __str__(self): 

301 dt = timezone.localtime(self.enrollment_datetime) 

302 ts = dt.strftime("%b %d, %Y, %I:%M %p") 

303 return f"{ts}, {self.transit_agency}, {self.enrollment_flow}"