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

155 statements  

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

1import logging 

2import os 

3 

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

5from django.core.exceptions import NON_FIELD_ERRORS, ValidationError 

6from django.db import models 

7from django.urls import reverse 

8from multiselectfield import MultiSelectField 

9 

10from benefits.routes import routes 

11 

12from .common import Environment 

13from .enrollment import EnrollmentFlow 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18class CardSchemes: 

19 VISA = "visa" 

20 MASTERCARD = "mastercard" 

21 DISCOVER = "discover" 

22 AMEX = "amex" 

23 

24 CHOICES = dict( 

25 [ 

26 (VISA, "Visa"), 

27 (MASTERCARD, "Mastercard"), 

28 (DISCOVER, "Discover"), 

29 (AMEX, "American Express"), 

30 ] 

31 ) 

32 

33 

34def agency_logo(instance, filename): 

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

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

37 

38 

39class TransitProcessorConfig(models.Model): 

40 id = models.AutoField(primary_key=True) 

41 environment = models.TextField( 

42 choices=Environment, 

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

44 ) 

45 label = models.TextField( 

46 default="", 

47 blank=True, 

48 help_text="A label for internal use.", 

49 ) 

50 portal_url = models.TextField( 

51 default="", 

52 blank=True, 

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

54 ) 

55 

56 def __str__(self): 

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

58 return f"({environment_label}) {self.label}" 

59 

60 

61class TransitAgency(models.Model): 

62 """An agency offering transit service.""" 

63 

64 class Meta: 

65 verbose_name_plural = "transit agencies" 

66 

67 id = models.AutoField(primary_key=True) 

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

69 slug = models.SlugField( 

70 unique=True, 

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

72 ) 

73 short_name = models.TextField( 

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

75 ) 

76 long_name = models.TextField( 

77 default="", 

78 blank=True, 

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

80 ) 

81 info_url = models.URLField( 

82 default="", 

83 blank=True, 

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

85 ) 

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

87 enrollment_flows = models.ManyToManyField( 

88 EnrollmentFlow, 

89 help_text="Select the enrollment flows this agency supports.", 

90 ) 

91 supported_card_schemes = MultiSelectField( 

92 choices=CardSchemes.CHOICES, 

93 min_choices=1, 

94 max_choices=len(CardSchemes.CHOICES), 

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

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

97 ) 

98 sso_domain = models.TextField( 

99 blank=True, 

100 default="", 

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

102 ) 

103 customer_service_group = models.OneToOneField( 

104 Group, 

105 on_delete=models.PROTECT, 

106 null=True, 

107 blank=True, 

108 default=None, 

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

110 related_name="transit_agency", 

111 ) 

112 logo = models.ImageField( 

113 default="", 

114 blank=True, 

115 upload_to=agency_logo, 

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

117 ) 

118 transit_processor_config = models.ForeignKey( 

119 TransitProcessorConfig, 

120 on_delete=models.PROTECT, 

121 null=True, 

122 blank=True, 

123 default=None, 

124 help_text="The transit processor configuration to use for enrollment.", 

125 ) 

126 

127 def __str__(self): 

128 if self.long_name: 

129 return self.long_name 

130 return self.short_name 

131 

132 @property 

133 def index_url(self): 

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

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

136 

137 @property 

138 def entrypoint_url(self): 

139 """For grouped agencies, we display an interstitial view prior to commencing the eligibility check.""" 

140 if self.group_agencies(): 

141 return reverse(routes.ADDITIONAL_AGENCIES) 

142 

143 return reverse(routes.ELIGIBILITY_INDEX) 

144 

145 @property 

146 def littlepay_config(self): 

147 if self.transit_processor_config and hasattr(self.transit_processor_config, "littlepayconfig"): 

148 return self.transit_processor_config.littlepayconfig 

149 else: 

150 return None 

151 

152 @property 

153 def switchio_config(self): 

154 if hasattr(self, "transit_processor_config") and hasattr(self.transit_processor_config, "switchioconfig"): 

155 return self.transit_processor_config.switchioconfig 

156 else: 

157 return None 

158 

159 @property 

160 def transit_processor(self): 

161 if self.littlepay_config: 

162 return "littlepay" 

163 elif self.switchio_config: 

164 return "switchio" 

165 else: 

166 return None 

167 

168 @property 

169 def in_person_enrollment_index_route(self): 

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

171 if self.littlepay_config: 

172 return routes.IN_PERSON_ENROLLMENT_LITTLEPAY_INDEX 

173 elif self.switchio_config: 

174 return routes.IN_PERSON_ENROLLMENT_SWITCHIO_INDEX 

175 else: 

176 raise ValueError( 

177 ( 

178 "TransitAgency must have either a LittlepayConfig or SwitchioConfig " 

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

180 ) 

181 ) 

182 

183 @property 

184 def enrollment_index_route(self): 

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

186 if self.littlepay_config: 

187 return routes.ENROLLMENT_LITTLEPAY_INDEX 

188 elif self.switchio_config: 

189 return routes.ENROLLMENT_SWITCHIO_INDEX 

190 else: 

191 raise ValueError( 

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

193 ) 

194 

195 @property 

196 def customer_service_group_name(self): 

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

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

199 

200 def group_agencies(self): 

201 """The set of all agencies in all groups associated with this agency, excluding itself. 

202 

203 If an agency is not associated with any other agencies via TransitAgencyGroup, 

204 this returns an empty list. 

205 """ 

206 return list( 

207 TransitAgency.objects.filter(transitagencygroup__in=list(self.transitagencygroup_set.all())) 

208 .distinct() 

209 .exclude(active=False) 

210 .exclude(pk=self.pk) 

211 .order_by("short_name") 

212 ) 

213 

214 def group_agency_short_names(self): 

215 """A list of agency short names for this agency and any agencies it shares a group with. 

216 

217 The list begins with the current agency and the rest follow in alphabetical order. 

218 If an agency is not associated with any other agencies via TransitAgencyGroup, 

219 this returns an empty list. 

220 """ 

221 agencies = [self] + self.group_agencies() 

222 

223 if len(agencies) > 1: 

224 return [agency.short_name for agency in agencies] 

225 else: 

226 return [] 

227 

228 def clean(self): 

229 field_errors = {} 

230 non_field_errors = [] 

231 

232 if self.active: 

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

234 needed = dict( 

235 long_name=self.long_name, 

236 phone=self.phone, 

237 info_url=self.info_url, 

238 logo=self.logo, 

239 ) 

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

241 

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

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

244 else: 

245 if self.littlepay_config: 

246 try: 

247 self.littlepay_config.clean() 

248 except ValidationError as e: 

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

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

251 non_field_errors.append(ValidationError(message)) 

252 

253 if self.switchio_config: 

254 try: 

255 self.switchio_config.clean() 

256 except ValidationError as e: 

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

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

259 non_field_errors.append(ValidationError(message)) 

260 

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

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

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

264 field_errors.update( 

265 { 

266 "customer_service_group": ValidationError( 

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

268 ) 

269 } 

270 ) 

271 

272 all_errors = {} 

273 if field_errors: 

274 all_errors.update(field_errors) 

275 if non_field_errors: 

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

277 if all_errors: 

278 raise ValidationError(all_errors) 

279 

280 @staticmethod 

281 def by_id(id): 

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

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

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

285 

286 @staticmethod 

287 def by_slug(slug): 

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

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

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

291 

292 @staticmethod 

293 def all_active(): 

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

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

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

297 

298 @staticmethod 

299 def for_user(user: User): 

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

301 if hasattr(group, "transit_agency"): 

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

303 

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

305 return None 

306 

307 

308class TransitAgencyGroup(models.Model): 

309 id = models.AutoField(primary_key=True) 

310 label = models.TextField( 

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

312 ) 

313 transit_agencies = models.ManyToManyField( 

314 TransitAgency, 

315 help_text="Select the agencies that belong to this group.", 

316 ) 

317 

318 def __str__(self): 

319 return self.label