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

144 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-17 22:53 +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, size): 

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

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

36 

37 

38def agency_logo_small(instance, filename): 

39 return _agency_logo(instance, filename, "sm") 

40 

41 

42def agency_logo_large(instance, filename): 

43 return _agency_logo(instance, filename, "lg") 

44 

45 

46class TransitProcessorConfig(models.Model): 

47 id = models.AutoField(primary_key=True) 

48 environment = models.TextField( 

49 choices=Environment, 

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

51 ) 

52 transit_agency = models.OneToOneField( 

53 "TransitAgency", 

54 on_delete=models.PROTECT, 

55 null=True, 

56 blank=True, 

57 default=None, 

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

59 ) 

60 portal_url = models.TextField( 

61 default="", 

62 blank=True, 

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

64 ) 

65 

66 def __str__(self): 

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

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

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

70 

71 

72class TransitAgency(models.Model): 

73 """An agency offering transit service.""" 

74 

75 class Meta: 

76 verbose_name_plural = "transit agencies" 

77 

78 id = models.AutoField(primary_key=True) 

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

80 slug = models.SlugField( 

81 choices=core_context.AgencySlug, 

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

83 ) 

84 short_name = models.TextField( 

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

86 ) 

87 long_name = models.TextField( 

88 default="", 

89 blank=True, 

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

91 ) 

92 info_url = models.URLField( 

93 default="", 

94 blank=True, 

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

96 ) 

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

98 supported_card_schemes = MultiSelectField( 

99 choices=CardSchemes.CHOICES, 

100 min_choices=1, 

101 max_choices=len(CardSchemes.CHOICES), 

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

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

104 ) 

105 eligibility_api_id = models.TextField( 

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

107 blank=True, 

108 default="", 

109 ) 

110 eligibility_api_private_key = models.ForeignKey( 

111 PemData, 

112 related_name="+", 

113 on_delete=models.PROTECT, 

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

115 null=True, 

116 blank=True, 

117 default=None, 

118 ) 

119 eligibility_api_public_key = models.ForeignKey( 

120 PemData, 

121 related_name="+", 

122 on_delete=models.PROTECT, 

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

124 null=True, 

125 blank=True, 

126 default=None, 

127 ) 

128 staff_group = models.OneToOneField( 

129 Group, 

130 on_delete=models.PROTECT, 

131 null=True, 

132 blank=True, 

133 default=None, 

134 help_text="The group of users associated with this TransitAgency.", 

135 related_name="transit_agency", 

136 ) 

137 sso_domain = models.TextField( 

138 blank=True, 

139 default="", 

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

141 ) 

142 customer_service_group = models.OneToOneField( 

143 Group, 

144 on_delete=models.PROTECT, 

145 null=True, 

146 blank=True, 

147 default=None, 

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

149 related_name="+", 

150 ) 

151 logo_large = models.ImageField( 

152 default="", 

153 blank=True, 

154 upload_to=agency_logo_large, 

155 help_text="The large version of the transit agency's logo.", 

156 ) 

157 logo_small = models.ImageField( 

158 default="", 

159 blank=True, 

160 upload_to=agency_logo_small, 

161 help_text="The small version of the transit agency's logo.", 

162 ) 

163 

164 def __str__(self): 

165 return self.long_name 

166 

167 @property 

168 def index_context(self): 

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

170 

171 @property 

172 def index_url(self): 

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

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

175 

176 @property 

177 def eligibility_index_url(self): 

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

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

180 

181 @property 

182 def eligibility_api_private_key_data(self): 

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

184 return self.eligibility_api_private_key.data 

185 

186 @property 

187 def eligibility_api_public_key_data(self): 

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

189 return self.eligibility_api_public_key.data 

190 

191 @property 

192 def littlepay_config(self): 

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

194 return self.transitprocessorconfig.littlepayconfig 

195 else: 

196 return None 

197 

198 @property 

199 def switchio_config(self): 

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

201 return self.transitprocessorconfig.switchioconfig 

202 else: 

203 return None 

204 

205 @property 

206 def in_person_enrollment_index_route(self): 

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

208 if self.littlepay_config: 

209 return routes.IN_PERSON_ENROLLMENT_LITTLEPAY_INDEX 

210 elif self.switchio_config: 

211 return routes.IN_PERSON_ENROLLMENT_SWITCHIO_INDEX 

212 else: 

213 raise ValueError( 

214 ( 

215 "TransitAgency must have either a LittlepayConfig or SwitchioConfig " 

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

217 ) 

218 ) 

219 

220 @property 

221 def enrollment_index_route(self): 

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

223 if self.littlepay_config: 

224 return routes.ENROLLMENT_LITTLEPAY_INDEX 

225 elif self.switchio_config: 

226 return routes.ENROLLMENT_SWITCHIO_INDEX 

227 else: 

228 raise ValueError( 

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

230 ) 

231 

232 @property 

233 def enrollment_flows(self): 

234 return self.enrollmentflow_set 

235 

236 def clean(self): 

237 field_errors = {} 

238 non_field_errors = [] 

239 

240 if self.active: 

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

242 needed = dict( 

243 short_name=self.short_name, 

244 long_name=self.long_name, 

245 phone=self.phone, 

246 info_url=self.info_url, 

247 logo_large=self.logo_large, 

248 logo_small=self.logo_small, 

249 ) 

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

251 

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

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

254 else: 

255 if self.littlepay_config: 

256 try: 

257 self.littlepay_config.clean() 

258 except ValidationError as e: 

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

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

261 non_field_errors.append(ValidationError(message)) 

262 

263 if self.switchio_config: 

264 try: 

265 self.switchio_config.clean() 

266 except ValidationError as e: 

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

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

269 non_field_errors.append(ValidationError(message)) 

270 

271 all_errors = {} 

272 if field_errors: 

273 all_errors.update(field_errors) 

274 if non_field_errors: 

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

276 if all_errors: 

277 raise ValidationError(all_errors) 

278 

279 @staticmethod 

280 def by_id(id): 

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

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

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

284 

285 @staticmethod 

286 def by_slug(slug): 

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

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

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

290 

291 @staticmethod 

292 def all_active(): 

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

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

295 return TransitAgency.objects.filter(active=True) 

296 

297 @staticmethod 

298 def for_user(user: User): 

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

300 if hasattr(group, "transit_agency"): 

301 return group.transit_agency # this is looking at the TransitAgency's staff_group 

302 

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

304 return None