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

134 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-14 01:41 +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 

8 

9from benefits.core import context as core_context 

10from benefits.eligibility import context as eligibility_context 

11from benefits.routes import routes 

12from .common import PemData 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17def _agency_logo(instance, filename, size): 

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

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

20 

21 

22def agency_logo_small(instance, filename): 

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

24 

25 

26def agency_logo_large(instance, filename): 

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

28 

29 

30class TransitProcessor(models.Model): 

31 """An entity that applies transit agency fare rules to rider transactions.""" 

32 

33 id = models.AutoField(primary_key=True) 

34 name = models.TextField(help_text="Primary internal display name for this TransitProcessor instance, e.g. in the Admin.") 

35 api_base_url = models.TextField(help_text="The absolute base URL for the TransitProcessor's API, including https://.") 

36 portal_url = models.TextField( 

37 default="", 

38 blank=True, 

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

40 ) 

41 

42 def __str__(self): 

43 return self.name 

44 

45 

46class TransitAgency(models.Model): 

47 """An agency offering transit service.""" 

48 

49 class Meta: 

50 verbose_name_plural = "transit agencies" 

51 

52 id = models.AutoField(primary_key=True) 

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

54 slug = models.SlugField( 

55 choices=core_context.AgencySlug, 

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

57 ) 

58 short_name = models.TextField( 

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

60 ) 

61 long_name = models.TextField( 

62 default="", 

63 blank=True, 

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

65 ) 

66 info_url = models.URLField( 

67 default="", 

68 blank=True, 

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

70 ) 

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

72 eligibility_api_id = models.TextField( 

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

74 blank=True, 

75 default="", 

76 ) 

77 eligibility_api_private_key = models.ForeignKey( 

78 PemData, 

79 related_name="+", 

80 on_delete=models.PROTECT, 

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

82 null=True, 

83 blank=True, 

84 default=None, 

85 ) 

86 eligibility_api_public_key = models.ForeignKey( 

87 PemData, 

88 related_name="+", 

89 on_delete=models.PROTECT, 

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

91 null=True, 

92 blank=True, 

93 default=None, 

94 ) 

95 transit_processor = models.ForeignKey( 

96 TransitProcessor, 

97 on_delete=models.PROTECT, 

98 null=True, 

99 blank=True, 

100 default=None, 

101 help_text="This agency's TransitProcessor.", 

102 ) 

103 littlepay_config = models.OneToOneField( 

104 "enrollment_littlepay.LittlepayConfig", 

105 on_delete=models.PROTECT, 

106 null=True, 

107 blank=True, 

108 default=None, 

109 help_text="The Littlepay configuration used by this agency for enrollment.", 

110 ) 

111 switchio_config = models.ForeignKey( 

112 "enrollment_switchio.SwitchioConfig", 

113 on_delete=models.PROTECT, 

114 null=True, 

115 blank=True, 

116 default=None, 

117 help_text="The Switchio configuration used by this agency for enrollment.", 

118 ) 

119 staff_group = models.OneToOneField( 

120 Group, 

121 on_delete=models.PROTECT, 

122 null=True, 

123 blank=True, 

124 default=None, 

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

126 related_name="transit_agency", 

127 ) 

128 sso_domain = models.TextField( 

129 blank=True, 

130 default="", 

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

132 ) 

133 customer_service_group = models.OneToOneField( 

134 Group, 

135 on_delete=models.PROTECT, 

136 null=True, 

137 blank=True, 

138 default=None, 

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

140 related_name="+", 

141 ) 

142 logo_large = models.ImageField( 

143 default="", 

144 blank=True, 

145 upload_to=agency_logo_large, 

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

147 ) 

148 logo_small = models.ImageField( 

149 default="", 

150 blank=True, 

151 upload_to=agency_logo_small, 

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

153 ) 

154 

155 def __str__(self): 

156 return self.long_name 

157 

158 @property 

159 def index_context(self): 

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

161 

162 @property 

163 def index_url(self): 

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

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

166 

167 @property 

168 def eligibility_index_context(self): 

169 return eligibility_context.eligibility_index[self.slug].dict() 

170 

171 @property 

172 def eligibility_index_url(self): 

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

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

175 

176 @property 

177 def eligibility_api_private_key_data(self): 

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

179 return self.eligibility_api_private_key.data 

180 

181 @property 

182 def eligibility_api_public_key_data(self): 

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

184 return self.eligibility_api_public_key.data 

185 

186 @property 

187 def enrollment_index_template(self): 

188 if self.littlepay_config: 

189 template = self.littlepay_config.enrollment_index_template 

190 elif self.switchio_config: 190 ↛ 193line 190 didn't jump to line 193 because the condition on line 190 was always true

191 template = self.switchio_config.enrollment_index_template 

192 else: 

193 raise ValueError("Transit agency does not have a Littlepay or Switchio config") 

194 

195 return template 

196 

197 @property 

198 def enrollment_flows(self): 

199 return self.enrollmentflow_set 

200 

201 @property 

202 def transit_processor_context(self): 

203 if self.littlepay_config: 203 ↛ 205line 203 didn't jump to line 205 because the condition on line 203 was always true

204 context = self.littlepay_config.transit_processor_context 

205 elif self.switchio_config: 

206 context = self.switchio_config.transit_processor_context 

207 else: 

208 raise ValueError("Transit agency does not have a Littlepay or Switchio config") 

209 

210 return context 

211 

212 def clean(self): 

213 field_errors = {} 

214 non_field_errors = [] 

215 

216 if self.active: 

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

218 needed = dict( 

219 short_name=self.short_name, 

220 long_name=self.long_name, 

221 phone=self.phone, 

222 info_url=self.info_url, 

223 logo_large=self.logo_large, 

224 logo_small=self.logo_small, 

225 ) 

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

227 

228 if self.transit_processor: 228 ↛ 232line 228 didn't jump to line 232 because the condition on line 228 was always true

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

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

231 

232 if self.littlepay_config: 

233 try: 

234 self.littlepay_config.clean() 

235 except ValidationError as e: 

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

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

238 non_field_errors.append(ValidationError(message)) 

239 

240 if self.switchio_config: 

241 try: 

242 self.switchio_config.clean(agency=self) 

243 except ValidationError as e: 

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

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

246 non_field_errors.append(ValidationError(message)) 

247 

248 all_errors = {} 

249 if field_errors: 

250 all_errors.update(field_errors) 

251 if non_field_errors: 

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

253 if all_errors: 

254 raise ValidationError(all_errors) 

255 

256 @staticmethod 

257 def by_id(id): 

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

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

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

261 

262 @staticmethod 

263 def by_slug(slug): 

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

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

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

267 

268 @staticmethod 

269 def all_active(): 

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

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

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

273 

274 @staticmethod 

275 def for_user(user: User): 

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

277 if hasattr(group, "transit_agency"): 

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

279 

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

281 return None