Coverage for benefits / enrollment / views.py: 98%

114 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-13 19:35 +0000

1""" 

2The enrollment application: view definitions for the benefits enrollment flow. 

3""" 

4 

5import logging 

6from dataclasses import asdict, dataclass 

7from typing import Any, Optional 

8 

9from django.template.defaultfilters import date 

10from django.urls import reverse 

11from django.views.generic import RedirectView, TemplateView 

12 

13from benefits.core import models, session 

14from benefits.core.context import formatted_gettext_lazy as _ 

15from benefits.core.context.flow import SystemName 

16from benefits.core.mixins import ( 

17 AgencySessionRequiredMixin, 

18 EligibleSessionRequiredMixin, 

19 FlowSessionRequiredMixin, 

20 PageViewMixin, 

21) 

22from benefits.core.models import AgencySlug 

23from benefits.routes import routes 

24 

25from . import analytics 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30@dataclass 

31class EnrollmentIndex: 

32 headline: str = _("Your eligibility is confirmed! You’re almost there.") 

33 next_step: str = _("The next step is to enroll the contactless card you will use to tap to ride for a reduced fare.") 

34 partner_post_link: str = _(", to enter your contactless card details.") 

35 alert_include: Optional[str] = "" 

36 

37 def dict(self): 

38 return asdict(self) 

39 

40 

41@dataclass 

42class AgencyCardEnrollmentIndex(EnrollmentIndex): 

43 def __init__(self): 

44 super().__init__(headline=_("We found your record! Now let’s enroll your contactless card.")) 

45 

46 

47@dataclass 

48class CalFreshEnrollmentIndex(EnrollmentIndex): 

49 def __init__(self): 

50 super().__init__( 

51 next_step=_("The next step is to connect your contactless card to your transit benefit"), 

52 partner_post_link=".", 

53 alert_include="enrollment/includes/alert-box--warning--calfresh.html", 

54 ) 

55 

56 

57class IndexContextMixin(FlowSessionRequiredMixin): 

58 def get_context_data(self, **kwargs) -> dict[str, Any]: 

59 enrollment_index = { 

60 SystemName.AGENCY_CARD: AgencyCardEnrollmentIndex(), 

61 SystemName.COURTESY_CARD: AgencyCardEnrollmentIndex(), 

62 SystemName.REDUCED_FARE_MOBILITY_ID: AgencyCardEnrollmentIndex(), 

63 SystemName.CALFRESH: CalFreshEnrollmentIndex(), 

64 } 

65 context = enrollment_index.get(self.flow.system_name, EnrollmentIndex()) 

66 return context.dict() 

67 

68 

69class IndexView(AgencySessionRequiredMixin, EligibleSessionRequiredMixin, RedirectView): 

70 """CBV for the enrollment landing page.""" 

71 

72 route_origin = routes.ENROLLMENT_INDEX 

73 

74 def get_redirect_url(self, *args, **kwargs): 

75 route_name = self.agency.enrollment_index_route 

76 return reverse(route_name) 

77 

78 def get(self, request, *args, **kwargs): 

79 session.update(request, origin=reverse(self.route_origin)) 

80 return super().get(request, *args, **kwargs) 

81 

82 

83class ReenrollmentErrorView(FlowSessionRequiredMixin, EligibleSessionRequiredMixin, TemplateView): 

84 """View handler for a re-enrollment attempt that is not yet within the re-enrollment window.""" 

85 

86 template_name = "enrollment/reenrollment-error.html" 

87 

88 def get_context_data(self, **kwargs): 

89 context = super().get_context_data(**kwargs) 

90 

91 request = self.request 

92 

93 flow = self.flow 

94 expiry = session.enrollment_expiry(request) 

95 reenrollment = session.enrollment_reenrollment(request) 

96 

97 if flow.system_name == SystemName.CALFRESH: 

98 does_not_expire_until = _("Your CalFresh Cardholder transit benefit does not expire until") 

99 reenroll_on = _("You can re-enroll for this benefit beginning on") 

100 try_again = _("Please try again then.") 

101 

102 context["paragraphs"] = [ 

103 f"{does_not_expire_until} {date(expiry)}. {reenroll_on} {date(reenrollment)}. {try_again}" 

104 ] 

105 

106 return context 

107 

108 def get(self, request, *args, **kwargs): 

109 flow = self.flow 

110 

111 if session.logged_in(request) and flow.supports_sign_out: 

112 # overwrite origin for a logged in user 

113 # if they click the logout button, they are taken to the new route 

114 session.update(request, origin=reverse(routes.LOGGED_OUT)) 

115 

116 return super().get(request, *args, **kwargs) 

117 

118 

119class RetryView(AgencySessionRequiredMixin, FlowSessionRequiredMixin, EligibleSessionRequiredMixin, TemplateView): 

120 """View handler for a recoverable failure condition.""" 

121 

122 template_name = "enrollment/retry.html" 

123 enrollment_method = models.EnrollmentMethods.DIGITAL 

124 

125 def dispatch(self, request, *args, **kwargs): 

126 # for Littlepay, the Javascript in enrollment_littlepay/index.html sends a form POST to this view 

127 # to simplify, we check the method here and process POSTs rather than implementing as a FormView, which requires 

128 # instantiating the enrollment.forms.CardTokenizeFailForm, needing additional parameters such as a form id and 

129 # action_url, that are already specified in the enrollment_littlepay/views.IndexView 

130 # 

131 # Switchio doesn't use this view at all 

132 

133 # call super().dispatch() first to ensure all mixins are processed (i.e. so we have a self.flow and self.agency) 

134 response = super().dispatch(request, *args, **kwargs) 

135 if request.method == "POST": 

136 enrollment_group = self.flow.group_id 

137 transit_processor = self.agency.transit_processor 

138 analytics.returned_retry( 

139 request, 

140 enrollment_group=enrollment_group, 

141 transit_processor=transit_processor, 

142 enrollment_method=self.enrollment_method, 

143 ) 

144 # TemplateView doesn't implement POST, just return the template via GET 

145 return super().get(request, *args, **kwargs) 

146 # for other request methods, we don't want/need to serve the retry template since users should only arrive via the form 

147 # POST, returning super().dispatch() results in a 200 User Error response 

148 return response 

149 

150 

151class SystemErrorView(AgencySessionRequiredMixin, FlowSessionRequiredMixin, EligibleSessionRequiredMixin, TemplateView): 

152 """View handler for an enrollment system error.""" 

153 

154 template_name = "enrollment/system_error.html" 

155 

156 def get_origin_url(self): 

157 return self.agency.index_url 

158 

159 def get(self, request, *args, **kwargs): 

160 # overwrite origin so that CTA takes user to agency index 

161 origin_url = self.get_origin_url() 

162 session.update(request, origin=origin_url) 

163 return super().get(request, *args, **kwargs) 

164 

165 def post(self, request, *args, **kwargs): 

166 # the Javascript in enrollment_littlepay/index.html and enrollment_switchio/index.html sends a form POST to this view 

167 # rather than implementing this view as a FormView, which requires instantiating the 

168 # enrollment.forms.CardTokenizeFailForm, we implement post() to simply return the template via get() 

169 # we thus avoid interfering with the view's lifecycle and dispatch() method 

170 return self.get(request, *args, **kwargs) 

171 

172 

173@dataclass 

174class EnrollmentSuccess: 

175 success_message: str 

176 thank_you_message: str 

177 

178 def dict(self): 

179 return asdict(self) 

180 

181 

182class DefaultEnrollmentSuccess(EnrollmentSuccess): 

183 def __init__(self, transportation_type): 

184 super().__init__( 

185 success_message=_( 

186 "You were not charged anything today. When boarding {transportation_type}, tap your contactless card and you " 

187 "will be charged a reduced fare. You will need to re-enroll if you choose to change the card you use to " 

188 "pay for transit service.", 

189 transportation_type=transportation_type, 

190 ), 

191 thank_you_message=_("Thank you for using Cal-ITP Benefits!"), 

192 ) 

193 

194 

195class AgencyCardEnrollmentSuccess(EnrollmentSuccess): 

196 def __init__(self, transit_benefit, transportation_type): 

197 super().__init__( 

198 success_message=_( 

199 "Your contactless card is now enrolled in {transit_benefit}. When boarding {transportation_type}, tap this " 

200 "card and you will be charged a reduced fare. You will need to re-enroll if you choose to change the card you " 

201 "use to pay for transit service.", 

202 transit_benefit=transit_benefit, 

203 transportation_type=transportation_type, 

204 ), 

205 thank_you_message=_("You were not charged anything today. Thank you for using Cal-ITP Benefits!"), 

206 ) 

207 

208 

209class SuccessView(PageViewMixin, FlowSessionRequiredMixin, EligibleSessionRequiredMixin, TemplateView): 

210 """View handler for the final success page.""" 

211 

212 template_name = "enrollment/success.html" 

213 

214 def get_context_data(self, **kwargs): 

215 context = super().get_context_data(**kwargs) 

216 

217 request = self.request 

218 flow = self.flow 

219 

220 context = {"redirect_to": request.path} 

221 copy = { 

222 AgencySlug.CST.value: DefaultEnrollmentSuccess(transportation_type=_("a CST bus")), 

223 AgencySlug.EDCTA.value: DefaultEnrollmentSuccess(transportation_type=_("an EDCTA bus")), 

224 AgencySlug.MST.value: DefaultEnrollmentSuccess(transportation_type=_("an MST bus")), 

225 AgencySlug.NEVCO.value: DefaultEnrollmentSuccess(transportation_type=_("a Nevada County Connects bus")), 

226 AgencySlug.RABA.value: DefaultEnrollmentSuccess(transportation_type=_("a RABA bus")), 

227 AgencySlug.ROSEVILLE.value: DefaultEnrollmentSuccess(transportation_type=_("a Roseville bus")), 

228 AgencySlug.SACRT.value: DefaultEnrollmentSuccess(transportation_type=_("a SacRT bus")), 

229 AgencySlug.SLORTA.value: DefaultEnrollmentSuccess(transportation_type=_("a RTA bus")), 

230 AgencySlug.SBMTD.value: DefaultEnrollmentSuccess(transportation_type=_("an SBMTD bus")), 

231 AgencySlug.VCTC.value: DefaultEnrollmentSuccess( 

232 transportation_type=_("a Ventura County Transportation Commission bus") 

233 ), 

234 SystemName.AGENCY_CARD.value: AgencyCardEnrollmentSuccess( 

235 transit_benefit=_("a CST Agency Card transit benefit"), transportation_type=_("a CST bus") 

236 ), 

237 SystemName.COURTESY_CARD.value: AgencyCardEnrollmentSuccess( 

238 transit_benefit=_("an MST Courtesy Card transit benefit"), transportation_type="an MST bus" 

239 ), 

240 SystemName.REDUCED_FARE_MOBILITY_ID.value: AgencyCardEnrollmentSuccess( 

241 transit_benefit=_("an SBMTD Reduced Fare Mobility ID transit benefit"), transportation_type=_("an SBMTD bus") 

242 ), 

243 } 

244 

245 if flow.uses_api_verification: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true

246 copy_context = copy[flow.system_name].dict() 

247 else: 

248 copy_context = copy[flow.transit_agency.slug].dict() 

249 

250 context.update(copy_context) 

251 

252 return context 

253 

254 def get(self, request, *args, **kwargs): 

255 session.update(request, origin=reverse(routes.ENROLLMENT_SUCCESS)) 

256 

257 flow = self.flow 

258 

259 if session.logged_in(request) and flow.supports_sign_out: 

260 # overwrite origin for a logged in user 

261 # if they click the logout button, they are taken to the new route 

262 session.update(request, origin=reverse(routes.LOGGED_OUT)) 

263 

264 return super().get(request, *args, **kwargs)