Coverage for benefits / core / models / transit.py: 97%

161 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-22 19:08 +0000

1import os 

2import logging 

3 

4from django.core.exceptions import ValidationError, NON_FIELD_ERRORS 

5from django.contrib.auth.models import Group, User 

6from django.db import models 

7from django.urls import reverse 

8from multiselectfield import MultiSelectField 

9 

10from benefits.core import context as core_context 

11from benefits.routes import routes 

12from .common import Environment, PemData 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17class CardSchemes: 

18 VISA = "visa" 

19 MASTERCARD = "mastercard" 

20 DISCOVER = "discover" 

21 AMEX = "amex" 

22 

23 CHOICES = dict( 

24 [ 

25 (VISA, "Visa"), 

26 (MASTERCARD, "Mastercard"), 

27 (DISCOVER, "Discover"), 

28 (AMEX, "American Express"), 

29 ] 

30 ) 

31 

32 

33def agency_logo(instance, filename): 

34 base, ext = os.path.splitext(filename) 

35 return f"agencies/{instance.slug}" + ext 

36 

37 

38class TransitProcessorConfig(models.Model): 

39 id = models.AutoField(primary_key=True) 

40 environment = models.TextField( 

41 choices=Environment, 

42 help_text="A label to indicate which environment this configuration is for.", 

43 ) 

44 transit_agency = models.OneToOneField( 

45 "TransitAgency", 

46 on_delete=models.PROTECT, 

47 null=True, 

48 blank=True, 

49 default=None, 

50 help_text="The transit agency that uses this configuration.", 

51 ) 

52 portal_url = models.TextField( 

53 default="", 

54 blank=True, 

55 help_text="The absolute base URL for the TransitProcessor's control portal, including https://.", 

56 ) 

57 

58 def __str__(self): 

59 environment_label = Environment(self.environment).label if self.environment else "unknown" 

60 agency_slug = self.transit_agency.slug if self.transit_agency else "(no agency)" 

61 return f"({environment_label}) {agency_slug}" 

62 

63 

64class EligibilityApiConfig(models.Model): 

65 """Per-agency configuration for Eligibility Server integrations via the Eligibility API.""" 

66 

67 id = models.AutoField(primary_key=True) 

68 api_id = models.SlugField( 

69 help_text="The identifier for this agency used in Eligibility API calls.", 

70 ) 

71 api_private_key = models.ForeignKey( 

72 PemData, 

73 related_name="+", 

74 on_delete=models.PROTECT, 

75 help_text="Private key used to sign Eligibility API tokens created on behalf of this Agency.", 

76 ) 

77 api_public_key = models.ForeignKey( 

78 PemData, 

79 related_name="+", 

80 on_delete=models.PROTECT, 

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

82 ) 

83 

84 def __str__(self): 

85 return self.api_id 

86 

87 

88class TransitAgency(models.Model): 

89 """An agency offering transit service.""" 

90 

91 class Meta: 

92 verbose_name_plural = "transit agencies" 

93 

94 id = models.AutoField(primary_key=True) 

95 active = models.BooleanField(default=False, help_text="Determines if this Agency is enabled for users") 

96 slug = models.SlugField( 

97 choices=core_context.AgencySlug, 

98 help_text="Used for URL navigation for this agency, e.g. the agency homepage url is /{slug}", 

99 ) 

100 short_name = models.TextField( 

101 default="", help_text="The user-facing short name for this agency. Often an uppercase acronym." 

102 ) 

103 long_name = models.TextField( 

104 default="", 

105 blank=True, 

106 help_text="The user-facing long name for this agency. Often the short_name acronym, spelled out.", 

107 ) 

108 info_url = models.URLField( 

109 default="", 

110 blank=True, 

111 help_text="URL of a website/page with more information about the agency's discounts", 

112 ) 

113 phone = models.TextField(default="", blank=True, help_text="Agency customer support phone number") 

114 supported_card_schemes = MultiSelectField( 

115 choices=CardSchemes.CHOICES, 

116 min_choices=1, 

117 max_choices=len(CardSchemes.CHOICES), 

118 default=[CardSchemes.VISA, CardSchemes.MASTERCARD], 

119 help_text="The contactless card schemes this agency supports.", 

120 ) 

121 eligibility_api_config = models.ForeignKey( 

122 EligibilityApiConfig, 

123 on_delete=models.PROTECT, 

124 null=True, 

125 blank=True, 

126 default=None, 

127 help_text="The Eligibility API configuration for this transit agency.", 

128 ) 

129 sso_domain = models.TextField( 

130 blank=True, 

131 default="", 

132 help_text="The email domain of users to automatically add to this agency's staff group upon login.", 

133 ) 

134 customer_service_group = models.OneToOneField( 

135 Group, 

136 on_delete=models.PROTECT, 

137 null=True, 

138 blank=True, 

139 default=None, 

140 help_text="The group of users who are allowed to do in-person eligibility verification and enrollment.", 

141 related_name="transit_agency", 

142 ) 

143 logo = models.ImageField( 

144 default="", 

145 blank=True, 

146 upload_to=agency_logo, 

147 help_text="The transit agency's logo.", 

148 ) 

149 

150 def __str__(self): 

151 return self.long_name 

152 

153 @property 

154 def index_context(self): 

155 return core_context.agency_index[self.slug].dict() 

156 

157 @property 

158 def index_url(self): 

159 """Public-facing URL to the TransitAgency's landing page.""" 

160 return reverse(routes.AGENCY_INDEX, args=[self.slug]) 

161 

162 @property 

163 def eligibility_index_url(self): 

164 """Public facing URL to the TransitAgency's eligibility page.""" 

165 return reverse(routes.AGENCY_ELIGIBILITY_INDEX, args=[self.slug]) 

166 

167 @property 

168 def eligibility_api_private_key_data(self): 

169 """This Agency's private key as a string.""" 

170 if self.eligibility_api_config: 170 ↛ 172line 170 didn't jump to line 172 because the condition on line 170 was always true

171 return self.eligibility_api_config.api_private_key.data 

172 return None 

173 

174 @property 

175 def eligibility_api_public_key_data(self): 

176 """This Agency's public key as a string.""" 

177 if self.eligibility_api_config: 177 ↛ 179line 177 didn't jump to line 179 because the condition on line 177 was always true

178 return self.eligibility_api_config.api_public_key.data 

179 return None 

180 

181 @property 

182 def littlepay_config(self): 

183 if hasattr(self, "transitprocessorconfig") and hasattr(self.transitprocessorconfig, "littlepayconfig"): 

184 return self.transitprocessorconfig.littlepayconfig 

185 else: 

186 return None 

187 

188 @property 

189 def switchio_config(self): 

190 if hasattr(self, "transitprocessorconfig") and hasattr(self.transitprocessorconfig, "switchioconfig"): 

191 return self.transitprocessorconfig.switchioconfig 

192 else: 

193 return None 

194 

195 @property 

196 def transit_processor(self): 

197 if self.littlepay_config: 

198 return "littlepay" 

199 elif self.switchio_config: 

200 return "switchio" 

201 else: 

202 return None 

203 

204 @property 

205 def in_person_enrollment_index_route(self): 

206 """This Agency's in-person enrollment index route, based on its configured transit processor.""" 

207 if self.littlepay_config: 

208 return routes.IN_PERSON_ENROLLMENT_LITTLEPAY_INDEX 

209 elif self.switchio_config: 

210 return routes.IN_PERSON_ENROLLMENT_SWITCHIO_INDEX 

211 else: 

212 raise ValueError( 

213 ( 

214 "TransitAgency must have either a LittlepayConfig or SwitchioConfig " 

215 "in order to show in-person enrollment index." 

216 ) 

217 ) 

218 

219 @property 

220 def enrollment_index_route(self): 

221 """This Agency's enrollment index route, based on its configured transit processor.""" 

222 if self.littlepay_config: 

223 return routes.ENROLLMENT_LITTLEPAY_INDEX 

224 elif self.switchio_config: 

225 return routes.ENROLLMENT_SWITCHIO_INDEX 

226 else: 

227 raise ValueError( 

228 "TransitAgency must have either a LittlepayConfig or SwitchioConfig in order to show enrollment index." 

229 ) 

230 

231 @property 

232 def enrollment_flows(self): 

233 return self.enrollmentflow_set 

234 

235 @property 

236 def customer_service_group_name(self): 

237 """Returns the standardized name for this Agency's customer service group.""" 

238 return f"{self.short_name} Customer Service" 

239 

240 def clean(self): 

241 field_errors = {} 

242 non_field_errors = [] 

243 

244 if self.active: 

245 message = "This field is required for active transit agencies." 

246 needed = dict( 

247 long_name=self.long_name, 

248 phone=self.phone, 

249 info_url=self.info_url, 

250 logo=self.logo, 

251 ) 

252 field_errors.update({k: ValidationError(message) for k, v in needed.items() if not v}) 

253 

254 if self.littlepay_config is None and self.switchio_config is None: 

255 non_field_errors.append(ValidationError("Must fill out configuration for either Littlepay or Switchio.")) 

256 else: 

257 if self.littlepay_config: 

258 try: 

259 self.littlepay_config.clean() 

260 except ValidationError as e: 

261 message = "Littlepay configuration is missing fields that are required when this agency is active." 

262 message += f" Missing fields: {', '.join(e.error_dict.keys())}" 

263 non_field_errors.append(ValidationError(message)) 

264 

265 if self.switchio_config: 

266 try: 

267 self.switchio_config.clean() 

268 except ValidationError as e: 

269 message = "Switchio configuration is missing fields that are required when this agency is active." 

270 message += f" Missing fields: {', '.join(e.error_dict.keys())}" 

271 non_field_errors.append(ValidationError(message)) 

272 

273 if self.pk: # prohibit updating short_name with blank customer_service_group 273 ↛ 284line 273 didn't jump to line 284 because the condition on line 273 was always true

274 original_obj = TransitAgency.objects.get(pk=self.pk) 

275 if self.short_name != original_obj.short_name and not self.customer_service_group: 

276 field_errors.update( 

277 { 

278 "customer_service_group": ValidationError( 

279 "Blank not allowed. Set to its original value if changing the Short Name." 

280 ) 

281 } 

282 ) 

283 

284 all_errors = {} 

285 if field_errors: 

286 all_errors.update(field_errors) 

287 if non_field_errors: 

288 all_errors.update({NON_FIELD_ERRORS: value for value in non_field_errors}) 

289 if all_errors: 

290 raise ValidationError(all_errors) 

291 

292 @staticmethod 

293 def by_id(id): 

294 """Get a TransitAgency instance by its ID.""" 

295 logger.debug(f"Get {TransitAgency.__name__} by id: {id}") 

296 return TransitAgency.objects.get(id=id) 

297 

298 @staticmethod 

299 def by_slug(slug): 

300 """Get a TransitAgency instance by its slug.""" 

301 logger.debug(f"Get {TransitAgency.__name__} by slug: {slug}") 

302 return TransitAgency.objects.filter(slug=slug).first() 

303 

304 @staticmethod 

305 def all_active(): 

306 """Get all TransitAgency instances marked active.""" 

307 logger.debug(f"Get all active {TransitAgency.__name__}") 

308 return TransitAgency.objects.filter(active=True).order_by("long_name") 

309 

310 @staticmethod 

311 def for_user(user: User): 

312 for group in user.groups.all(): 

313 if hasattr(group, "transit_agency"): 

314 return group.transit_agency # this is looking at the TransitAgency's customer_service_group 

315 

316 # the loop above returns the first match found. Return None if no match was found. 

317 return None