Coverage for src/couchers/servicers/postal_verification.py: 94%

123 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-06 23:17 +0000

1import json 

2import logging 

3 

4import grpc 

5from google.protobuf import empty_pb2 

6from sqlalchemy import exists 

7from sqlalchemy.orm import Session 

8 

9from couchers.config import config 

10from couchers.constants import ( 

11 POSTAL_VERIFICATION_CODE_LIFETIME, 

12 POSTAL_VERIFICATION_MAX_ATTEMPTS, 

13 POSTAL_VERIFICATION_RATE_LIMIT, 

14) 

15from couchers.context import CouchersContext 

16from couchers.helpers.postal_verification import generate_postal_verification_code, has_postal_verification 

17from couchers.jobs.enqueue import queue_job 

18from couchers.models import User 

19from couchers.models.postal_verification import PostalVerificationAttempt, PostalVerificationStatus 

20from couchers.notifications.notify import notify 

21from couchers.postal.address_validation import AddressValidationError, validate_address 

22from couchers.proto import notification_data_pb2, postal_verification_pb2, postal_verification_pb2_grpc 

23from couchers.proto.internal import jobs_pb2 

24from couchers.sql import couchers_select as select 

25from couchers.utils import Timestamp_from_datetime, now 

26 

27logger = logging.getLogger(__name__) 

28 

29postalverificationstatus2pb = { 

30 PostalVerificationStatus.pending_address_confirmation: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_PENDING_ADDRESS_CONFIRMATION, 

31 PostalVerificationStatus.in_progress: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_IN_PROGRESS, 

32 PostalVerificationStatus.awaiting_verification: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_AWAITING_VERIFICATION, 

33 PostalVerificationStatus.succeeded: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_SUCCEEDED, 

34 PostalVerificationStatus.failed: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_FAILED, 

35 PostalVerificationStatus.cancelled: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_CANCELLED, 

36} 

37 

38 

39def _attempt_to_address_pb(attempt: PostalVerificationAttempt) -> postal_verification_pb2.PostalAddress: 

40 return postal_verification_pb2.PostalAddress( 

41 address_line_1=attempt.address_line_1, 

42 address_line_2=attempt.address_line_2 or "", 

43 city=attempt.city, 

44 state=attempt.state or "", 

45 postal_code=attempt.postal_code or "", 

46 country=attempt.country, 

47 ) 

48 

49 

50class PostalVerification(postal_verification_pb2_grpc.PostalVerificationServicer): 

51 def InitiatePostalVerification( 

52 self, 

53 request: postal_verification_pb2.InitiatePostalVerificationReq, 

54 context: CouchersContext, 

55 session: Session, 

56 ) -> postal_verification_pb2.InitiatePostalVerificationRes: 

57 """ 

58 Step 1: User submits address for validation. 

59 """ 

60 if not config["ENABLE_POSTAL_VERIFICATION"]: 

61 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "postal_verification_disabled") 

62 

63 # Check if there's an active attempt 

64 has_active_attempt = session.execute( 

65 select( 

66 exists( 

67 select(PostalVerificationAttempt) 

68 .where(PostalVerificationAttempt.user_id == context.user_id) 

69 .where( 

70 PostalVerificationAttempt.status.in_( 

71 [ 

72 PostalVerificationStatus.pending_address_confirmation, 

73 PostalVerificationStatus.in_progress, 

74 PostalVerificationStatus.awaiting_verification, 

75 ] 

76 ) 

77 ) 

78 ) 

79 ) 

80 ).scalar() 

81 

82 if has_active_attempt: 

83 context.abort_with_error_code( 

84 grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_already_in_progress" 

85 ) 

86 

87 # Check rate limit: one initiation per 30 days 

88 has_recent_attempt = session.execute( 

89 select( 

90 exists( 

91 select(PostalVerificationAttempt) 

92 .where(PostalVerificationAttempt.user_id == context.user_id) 

93 .where(PostalVerificationAttempt.created > now() - POSTAL_VERIFICATION_RATE_LIMIT) 

94 ) 

95 ) 

96 ).scalar() 

97 

98 if has_recent_attempt: 

99 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "postal_verification_rate_limited") 

100 

101 # Validate required fields 

102 if not request.address.address_line_1: 

103 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "address_line_1_required") 

104 if not request.address.city: 

105 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "city_required") 

106 if not request.address.country: 

107 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "country_required") 

108 

109 # Validate address 

110 try: 

111 validated = validate_address( 

112 address_line_1=request.address.address_line_1, 

113 address_line_2=request.address.address_line_2 or None, 

114 city=request.address.city, 

115 state=request.address.state or None, 

116 postal_code=request.address.postal_code or None, 

117 country=request.address.country, 

118 ) 

119 except AddressValidationError: 

120 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "postal_address_invalid") 

121 

122 if not validated.is_deliverable: 

123 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "postal_address_undeliverable") 

124 

125 # Create attempt 

126 attempt = PostalVerificationAttempt( 

127 user_id=context.user_id, 

128 status=PostalVerificationStatus.pending_address_confirmation, 

129 address_line_1=validated.address_line_1, 

130 address_line_2=validated.address_line_2, 

131 city=validated.city, 

132 state=validated.state, 

133 postal_code=validated.postal_code, 

134 country=validated.country, 

135 original_address_json=json.dumps( 

136 { 

137 "address_line_1": request.address.address_line_1, 

138 "address_line_2": request.address.address_line_2, 

139 "city": request.address.city, 

140 "state": request.address.state, 

141 "postal_code": request.address.postal_code, 

142 "country": request.address.country, 

143 } 

144 ), 

145 ) 

146 session.add(attempt) 

147 session.flush() 

148 

149 return postal_verification_pb2.InitiatePostalVerificationRes( 

150 postal_verification_attempt_id=attempt.id, 

151 corrected_address=postal_verification_pb2.PostalAddress( 

152 address_line_1=validated.address_line_1, 

153 address_line_2=validated.address_line_2 or "", 

154 city=validated.city, 

155 state=validated.state or "", 

156 postal_code=validated.postal_code or "", 

157 country=validated.country, 

158 ), 

159 address_was_corrected=validated.was_corrected, 

160 ) 

161 

162 def ConfirmPostalAddress( 

163 self, 

164 request: postal_verification_pb2.ConfirmPostalAddressReq, 

165 context: CouchersContext, 

166 session: Session, 

167 ) -> postal_verification_pb2.ConfirmPostalAddressRes: 

168 """ 

169 Step 2: User confirms address, we generate code and send postcard. 

170 """ 

171 attempt = session.execute( 

172 select(PostalVerificationAttempt) 

173 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id) 

174 .where(PostalVerificationAttempt.user_id == context.user_id) 

175 ).scalar_one_or_none() 

176 

177 if not attempt: 

178 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found") 

179 

180 if attempt.status != PostalVerificationStatus.pending_address_confirmation: 

181 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_wrong_state") 

182 

183 attempt.verification_code = generate_postal_verification_code() 

184 attempt.status = PostalVerificationStatus.in_progress 

185 attempt.address_confirmed_at = now() 

186 

187 # Queue background job to send postcard 

188 queue_job( 

189 session, 

190 "send_postal_verification_postcard", 

191 jobs_pb2.SendPostalVerificationPostcardPayload( 

192 postal_verification_attempt_id=attempt.id, 

193 ), 

194 ) 

195 

196 return postal_verification_pb2.ConfirmPostalAddressRes() 

197 

198 def GetPostalVerificationStatus( 

199 self, 

200 request: postal_verification_pb2.GetPostalVerificationStatusReq, 

201 context: CouchersContext, 

202 session: Session, 

203 ) -> postal_verification_pb2.GetPostalVerificationStatusRes: 

204 """ 

205 Returns the user's postal verification status and current/latest attempt details. 

206 """ 

207 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

208 

209 has_verification = has_postal_verification(session, user) 

210 

211 # Always get the latest attempt for determining can_initiate and has_active_attempt 

212 latest_attempt = session.execute( 

213 select(PostalVerificationAttempt) 

214 .where(PostalVerificationAttempt.user_id == user.id) 

215 .order_by(PostalVerificationAttempt.created.desc()) 

216 .limit(1) 

217 ).scalar_one_or_none() 

218 

219 # Check if user can initiate a new attempt (based on latest attempt) 

220 can_initiate = True 

221 next_attempt_allowed_at = None 

222 has_active_attempt = False 

223 

224 if latest_attempt: 

225 # Can't initiate if there's an active attempt 

226 if latest_attempt.status in [ 

227 PostalVerificationStatus.pending_address_confirmation, 

228 PostalVerificationStatus.in_progress, 

229 PostalVerificationStatus.awaiting_verification, 

230 ]: 

231 can_initiate = False 

232 has_active_attempt = True 

233 else: 

234 # Check rate limit 

235 time_since_last = now() - latest_attempt.created 

236 if time_since_last < POSTAL_VERIFICATION_RATE_LIMIT: 

237 can_initiate = False 

238 next_attempt_allowed_at = latest_attempt.created + POSTAL_VERIFICATION_RATE_LIMIT 

239 

240 res = postal_verification_pb2.GetPostalVerificationStatusRes( 

241 has_postal_verification=has_verification, 

242 can_initiate_new_attempt=can_initiate, 

243 has_active_attempt=has_active_attempt, 

244 ) 

245 

246 if next_attempt_allowed_at: 

247 res.next_attempt_allowed_at.CopyFrom(Timestamp_from_datetime(next_attempt_allowed_at)) 

248 

249 # Get specific attempt if requested, otherwise use latest 

250 if request.postal_verification_attempt_id: 

251 attempt = session.execute( 

252 select(PostalVerificationAttempt) 

253 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id) 

254 .where(PostalVerificationAttempt.user_id == context.user_id) 

255 ).scalar_one_or_none() 

256 else: 

257 attempt = latest_attempt 

258 

259 if attempt: 

260 res.postal_verification_attempt_id = attempt.id 

261 res.status = postalverificationstatus2pb.get( 

262 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN 

263 ) 

264 res.address.CopyFrom(_attempt_to_address_pb(attempt)) 

265 res.created.CopyFrom(Timestamp_from_datetime(attempt.created)) 

266 if attempt.postcard_sent_at: 

267 res.postcard_sent_at.CopyFrom(Timestamp_from_datetime(attempt.postcard_sent_at)) 

268 

269 return res 

270 

271 def VerifyPostalCode( 

272 self, 

273 request: postal_verification_pb2.VerifyPostalCodeReq, 

274 context: CouchersContext, 

275 session: Session, 

276 ) -> postal_verification_pb2.VerifyPostalCodeRes: 

277 """ 

278 User submits the code from the postcard. 

279 Looks up the user's active attempt (awaiting_verification status). 

280 """ 

281 attempt = session.execute( 

282 select(PostalVerificationAttempt) 

283 .where(PostalVerificationAttempt.user_id == context.user_id) 

284 .where(PostalVerificationAttempt.status == PostalVerificationStatus.awaiting_verification) 

285 ).scalar_one_or_none() 

286 

287 if not attempt: 

288 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found") 

289 

290 # Check code expiry 

291 if attempt.postcard_sent_at and (now() - attempt.postcard_sent_at) > POSTAL_VERIFICATION_CODE_LIFETIME: 

292 attempt.status = PostalVerificationStatus.failed 

293 notify( 

294 session, 

295 user_id=context.user_id, 

296 topic_action="postal_verification:failed", 

297 data=notification_data_pb2.PostalVerificationFailed( 

298 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED 

299 ), 

300 ) 

301 session.commit() 

302 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_code_expired") 

303 

304 # Normalize submitted code 

305 submitted_code = request.code.strip().upper() 

306 

307 if submitted_code != attempt.verification_code: 

308 attempt.code_attempts += 1 

309 remaining = POSTAL_VERIFICATION_MAX_ATTEMPTS - attempt.code_attempts 

310 

311 if remaining <= 0: 

312 attempt.status = PostalVerificationStatus.failed 

313 notify( 

314 session, 

315 user_id=context.user_id, 

316 topic_action="postal_verification:failed", 

317 data=notification_data_pb2.PostalVerificationFailed( 

318 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS 

319 ), 

320 ) 

321 return postal_verification_pb2.VerifyPostalCodeRes( 

322 success=False, 

323 remaining_attempts=0, 

324 ) 

325 

326 return postal_verification_pb2.VerifyPostalCodeRes( 

327 success=False, 

328 remaining_attempts=remaining, 

329 ) 

330 

331 # Success! 

332 attempt.status = PostalVerificationStatus.succeeded 

333 attempt.verified_at = now() 

334 

335 notify( 

336 session, 

337 user_id=context.user_id, 

338 topic_action="postal_verification:success", 

339 ) 

340 

341 return postal_verification_pb2.VerifyPostalCodeRes( 

342 success=True, 

343 remaining_attempts=0, 

344 ) 

345 

346 def CancelPostalVerification( 

347 self, 

348 request: postal_verification_pb2.CancelPostalVerificationReq, 

349 context: CouchersContext, 

350 session: Session, 

351 ) -> empty_pb2.Empty: 

352 """ 

353 Cancels an active postal verification attempt. 

354 """ 

355 attempt = session.execute( 

356 select(PostalVerificationAttempt) 

357 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id) 

358 .where(PostalVerificationAttempt.user_id == context.user_id) 

359 ).scalar_one_or_none() 

360 

361 if not attempt: 

362 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found") 

363 

364 # Can cancel any active attempt (not terminal states) 

365 if attempt.status not in [ 

366 PostalVerificationStatus.pending_address_confirmation, 

367 PostalVerificationStatus.in_progress, 

368 PostalVerificationStatus.awaiting_verification, 

369 ]: 

370 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_cannot_cancel") 

371 

372 attempt.status = PostalVerificationStatus.cancelled 

373 # Clear the verification code (required by db constraint and makes sense - code is no longer valid) 

374 attempt.verification_code = None 

375 

376 return empty_pb2.Empty() 

377 

378 def ListPostalVerificationAttempts( 

379 self, 

380 request: postal_verification_pb2.ListPostalVerificationAttemptsReq, 

381 context: CouchersContext, 

382 session: Session, 

383 ) -> postal_verification_pb2.ListPostalVerificationAttemptsRes: 

384 """ 

385 Returns all postal verification attempts for the user. 

386 """ 

387 attempts = session.execute( 

388 select(PostalVerificationAttempt) 

389 .where(PostalVerificationAttempt.user_id == context.user_id) 

390 .order_by(PostalVerificationAttempt.created.desc()) 

391 ).scalars() 

392 

393 return postal_verification_pb2.ListPostalVerificationAttemptsRes( 

394 attempts=[ 

395 postal_verification_pb2.PostalVerificationAttemptSummary( 

396 postal_verification_attempt_id=attempt.id, 

397 status=postalverificationstatus2pb.get( 

398 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN 

399 ), 

400 address=_attempt_to_address_pb(attempt), 

401 created=Timestamp_from_datetime(attempt.created), 

402 verified_at=Timestamp_from_datetime(attempt.verified_at) if attempt.verified_at else None, 

403 ) 

404 for attempt in attempts 

405 ] 

406 )