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

117 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-06 20:07 +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 portal_url = models.TextField( 

36 default="", 

37 blank=True, 

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

39 ) 

40 

41 def __str__(self): 

42 return self.name 

43 

44 

45class TransitAgency(models.Model): 

46 """An agency offering transit service.""" 

47 

48 class Meta: 

49 verbose_name_plural = "transit agencies" 

50 

51 id = models.AutoField(primary_key=True) 

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

53 slug = models.SlugField( 

54 choices=core_context.AgencySlug, 

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

56 ) 

57 short_name = models.TextField( 

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

59 ) 

60 long_name = models.TextField( 

61 default="", 

62 blank=True, 

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

64 ) 

65 info_url = models.URLField( 

66 default="", 

67 blank=True, 

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

69 ) 

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

71 eligibility_api_id = models.TextField( 

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

73 blank=True, 

74 default="", 

75 ) 

76 eligibility_api_private_key = models.ForeignKey( 

77 PemData, 

78 related_name="+", 

79 on_delete=models.PROTECT, 

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

81 null=True, 

82 blank=True, 

83 default=None, 

84 ) 

85 eligibility_api_public_key = models.ForeignKey( 

86 PemData, 

87 related_name="+", 

88 on_delete=models.PROTECT, 

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

90 null=True, 

91 blank=True, 

92 default=None, 

93 ) 

94 transit_processor = models.ForeignKey( 

95 TransitProcessor, 

96 on_delete=models.PROTECT, 

97 null=True, 

98 blank=True, 

99 default=None, 

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

101 ) 

102 littlepay_config = models.OneToOneField( 

103 "enrollment_littlepay.LittlepayConfig", 

104 on_delete=models.PROTECT, 

105 null=True, 

106 blank=True, 

107 default=None, 

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

109 ) 

110 switchio_config = models.ForeignKey( 

111 "enrollment_switchio.SwitchioConfig", 

112 on_delete=models.PROTECT, 

113 null=True, 

114 blank=True, 

115 default=None, 

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

117 ) 

118 staff_group = models.OneToOneField( 

119 Group, 

120 on_delete=models.PROTECT, 

121 null=True, 

122 blank=True, 

123 default=None, 

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

125 related_name="transit_agency", 

126 ) 

127 sso_domain = models.TextField( 

128 blank=True, 

129 default="", 

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

131 ) 

132 customer_service_group = models.OneToOneField( 

133 Group, 

134 on_delete=models.PROTECT, 

135 null=True, 

136 blank=True, 

137 default=None, 

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

139 related_name="+", 

140 ) 

141 logo_large = models.ImageField( 

142 default="", 

143 blank=True, 

144 upload_to=agency_logo_large, 

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

146 ) 

147 logo_small = models.ImageField( 

148 default="", 

149 blank=True, 

150 upload_to=agency_logo_small, 

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

152 ) 

153 

154 def __str__(self): 

155 return self.long_name 

156 

157 @property 

158 def index_context(self): 

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

160 

161 @property 

162 def index_url(self): 

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

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

165 

166 @property 

167 def eligibility_index_context(self): 

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

169 

170 @property 

171 def eligibility_index_url(self): 

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

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

174 

175 @property 

176 def eligibility_api_private_key_data(self): 

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

178 return self.eligibility_api_private_key.data 

179 

180 @property 

181 def eligibility_api_public_key_data(self): 

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

183 return self.eligibility_api_public_key.data 

184 

185 @property 

186 def enrollment_flows(self): 

187 return self.enrollmentflow_set 

188 

189 def clean(self): 

190 field_errors = {} 

191 non_field_errors = [] 

192 

193 if self.active: 

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

195 needed = dict( 

196 short_name=self.short_name, 

197 long_name=self.long_name, 

198 phone=self.phone, 

199 info_url=self.info_url, 

200 logo_large=self.logo_large, 

201 logo_small=self.logo_small, 

202 ) 

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

204 

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

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

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

208 

209 if self.littlepay_config: 

210 try: 

211 self.littlepay_config.clean() 

212 except ValidationError as e: 

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

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

215 non_field_errors.append(ValidationError(message)) 

216 

217 if self.switchio_config: 

218 try: 

219 self.switchio_config.clean(agency=self) 

220 except ValidationError as e: 

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

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

223 non_field_errors.append(ValidationError(message)) 

224 

225 all_errors = {} 

226 if field_errors: 

227 all_errors.update(field_errors) 

228 if non_field_errors: 

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

230 if all_errors: 

231 raise ValidationError(all_errors) 

232 

233 @staticmethod 

234 def by_id(id): 

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

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

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

238 

239 @staticmethod 

240 def by_slug(slug): 

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

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

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

244 

245 @staticmethod 

246 def all_active(): 

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

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

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

250 

251 @staticmethod 

252 def for_user(user: User): 

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

254 if hasattr(group, "transit_agency"): 

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

256 

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

258 return None