Coverage for src/couchers/servicers/api.py: 97%

398 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-07-14 16:54 +0000

1from datetime import timedelta 

2from urllib.parse import urlencode 

3 

4import grpc 

5from google.protobuf import empty_pb2 

6from sqlalchemy.sql import and_, delete, distinct, func, intersect, or_, union 

7 

8from couchers import errors, urls 

9from couchers.config import config 

10from couchers.crypto import b64encode, generate_hash_signature, random_hex 

11from couchers.materialized_views import lite_users, user_response_rates 

12from couchers.models import ( 

13 FriendRelationship, 

14 FriendStatus, 

15 GroupChatSubscription, 

16 HostingStatus, 

17 HostRequest, 

18 InitiatedUpload, 

19 LanguageAbility, 

20 LanguageFluency, 

21 MeetupStatus, 

22 Message, 

23 Notification, 

24 NotificationDeliveryType, 

25 ParkingDetails, 

26 RateLimitAction, 

27 Reference, 

28 RegionLived, 

29 RegionVisited, 

30 SleepingArrangement, 

31 SmokingLocation, 

32 User, 

33 UserBadge, 

34) 

35from couchers.notifications.notify import notify 

36from couchers.notifications.settings import get_topic_actions_by_delivery_type 

37from couchers.rate_limits.check import process_rate_limits_and_check_abort 

38from couchers.resources import get_badge_dict, language_is_allowed, region_is_allowed 

39from couchers.servicers.account import get_strong_verification_fields 

40from couchers.sql import couchers_select as select 

41from couchers.sql import is_valid_user_id, is_valid_username 

42from couchers.utils import ( 

43 Duration_from_timedelta, 

44 Timestamp_from_datetime, 

45 create_coordinate, 

46 get_coordinates, 

47 is_valid_name, 

48 now, 

49) 

50from proto import api_pb2, api_pb2_grpc, media_pb2, notification_data_pb2, requests_pb2 

51 

52MAX_USERS_PER_QUERY = 200 

53MAX_PAGINATION_LENGTH = 50 

54 

55hostingstatus2sql = { 

56 api_pb2.HOSTING_STATUS_UNKNOWN: None, 

57 api_pb2.HOSTING_STATUS_CAN_HOST: HostingStatus.can_host, 

58 api_pb2.HOSTING_STATUS_MAYBE: HostingStatus.maybe, 

59 api_pb2.HOSTING_STATUS_CANT_HOST: HostingStatus.cant_host, 

60} 

61 

62hostingstatus2api = { 

63 None: api_pb2.HOSTING_STATUS_UNKNOWN, 

64 HostingStatus.can_host: api_pb2.HOSTING_STATUS_CAN_HOST, 

65 HostingStatus.maybe: api_pb2.HOSTING_STATUS_MAYBE, 

66 HostingStatus.cant_host: api_pb2.HOSTING_STATUS_CANT_HOST, 

67} 

68 

69meetupstatus2sql = { 

70 api_pb2.MEETUP_STATUS_UNKNOWN: None, 

71 api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP: MeetupStatus.wants_to_meetup, 

72 api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP: MeetupStatus.open_to_meetup, 

73 api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP: MeetupStatus.does_not_want_to_meetup, 

74} 

75 

76meetupstatus2api = { 

77 None: api_pb2.MEETUP_STATUS_UNKNOWN, 

78 MeetupStatus.wants_to_meetup: api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP, 

79 MeetupStatus.open_to_meetup: api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP, 

80 MeetupStatus.does_not_want_to_meetup: api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP, 

81} 

82 

83smokinglocation2sql = { 

84 api_pb2.SMOKING_LOCATION_UNKNOWN: None, 

85 api_pb2.SMOKING_LOCATION_YES: SmokingLocation.yes, 

86 api_pb2.SMOKING_LOCATION_WINDOW: SmokingLocation.window, 

87 api_pb2.SMOKING_LOCATION_OUTSIDE: SmokingLocation.outside, 

88 api_pb2.SMOKING_LOCATION_NO: SmokingLocation.no, 

89} 

90 

91smokinglocation2api = { 

92 None: api_pb2.SMOKING_LOCATION_UNKNOWN, 

93 SmokingLocation.yes: api_pb2.SMOKING_LOCATION_YES, 

94 SmokingLocation.window: api_pb2.SMOKING_LOCATION_WINDOW, 

95 SmokingLocation.outside: api_pb2.SMOKING_LOCATION_OUTSIDE, 

96 SmokingLocation.no: api_pb2.SMOKING_LOCATION_NO, 

97} 

98 

99sleepingarrangement2sql = { 

100 api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN: None, 

101 api_pb2.SLEEPING_ARRANGEMENT_PRIVATE: SleepingArrangement.private, 

102 api_pb2.SLEEPING_ARRANGEMENT_COMMON: SleepingArrangement.common, 

103 api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM: SleepingArrangement.shared_room, 

104} 

105 

106sleepingarrangement2api = { 

107 None: api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN, 

108 SleepingArrangement.private: api_pb2.SLEEPING_ARRANGEMENT_PRIVATE, 

109 SleepingArrangement.common: api_pb2.SLEEPING_ARRANGEMENT_COMMON, 

110 SleepingArrangement.shared_room: api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM, 

111} 

112 

113parkingdetails2sql = { 

114 api_pb2.PARKING_DETAILS_UNKNOWN: None, 

115 api_pb2.PARKING_DETAILS_FREE_ONSITE: ParkingDetails.free_onsite, 

116 api_pb2.PARKING_DETAILS_FREE_OFFSITE: ParkingDetails.free_offsite, 

117 api_pb2.PARKING_DETAILS_PAID_ONSITE: ParkingDetails.paid_onsite, 

118 api_pb2.PARKING_DETAILS_PAID_OFFSITE: ParkingDetails.paid_offsite, 

119} 

120 

121parkingdetails2api = { 

122 None: api_pb2.PARKING_DETAILS_UNKNOWN, 

123 ParkingDetails.free_onsite: api_pb2.PARKING_DETAILS_FREE_ONSITE, 

124 ParkingDetails.free_offsite: api_pb2.PARKING_DETAILS_FREE_OFFSITE, 

125 ParkingDetails.paid_onsite: api_pb2.PARKING_DETAILS_PAID_ONSITE, 

126 ParkingDetails.paid_offsite: api_pb2.PARKING_DETAILS_PAID_OFFSITE, 

127} 

128 

129fluency2sql = { 

130 api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN: None, 

131 api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER: LanguageFluency.beginner, 

132 api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL: LanguageFluency.conversational, 

133 api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT: LanguageFluency.fluent, 

134} 

135 

136fluency2api = { 

137 None: api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN, 

138 LanguageFluency.beginner: api_pb2.LanguageAbility.Fluency.FLUENCY_BEGINNER, 

139 LanguageFluency.conversational: api_pb2.LanguageAbility.Fluency.FLUENCY_CONVERSATIONAL, 

140 LanguageFluency.fluent: api_pb2.LanguageAbility.Fluency.FLUENCY_FLUENT, 

141} 

142 

143 

144class API(api_pb2_grpc.APIServicer): 

145 def Ping(self, request, context, session): 

146 # auth ought to make sure the user exists 

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

148 

149 sent_reqs_last_seen_message_ids = ( 

150 select(HostRequest.conversation_id, HostRequest.surfer_last_seen_message_id) 

151 .where(HostRequest.surfer_user_id == context.user_id) 

152 .where_users_column_visible(context, HostRequest.host_user_id) 

153 ).subquery() 

154 

155 unseen_sent_host_request_count = session.execute( 

156 select(func.count(distinct(sent_reqs_last_seen_message_ids.c.conversation_id))) 

157 .join( 

158 Message, 

159 Message.conversation_id == sent_reqs_last_seen_message_ids.c.conversation_id, 

160 ) 

161 .where(sent_reqs_last_seen_message_ids.c.surfer_last_seen_message_id < Message.id) 

162 .where(Message.id != None) 

163 ).scalar_one() 

164 

165 received_reqs_last_seen_message_ids = ( 

166 select(HostRequest.conversation_id, HostRequest.host_last_seen_message_id) 

167 .where(HostRequest.host_user_id == context.user_id) 

168 .where_users_column_visible(context, HostRequest.surfer_user_id) 

169 ).subquery() 

170 

171 unseen_received_host_request_count = session.execute( 

172 select(func.count(distinct(received_reqs_last_seen_message_ids.c.conversation_id))) 

173 .join( 

174 Message, 

175 Message.conversation_id == received_reqs_last_seen_message_ids.c.conversation_id, 

176 ) 

177 .where(received_reqs_last_seen_message_ids.c.host_last_seen_message_id < Message.id) 

178 .where(Message.id != None) 

179 ).scalar_one() 

180 

181 unseen_message_count = session.execute( 

182 select(func.count(Message.id)) 

183 .outerjoin(GroupChatSubscription, GroupChatSubscription.group_chat_id == Message.conversation_id) 

184 .where(GroupChatSubscription.user_id == context.user_id) 

185 .where(Message.time >= GroupChatSubscription.joined) 

186 .where(or_(Message.time <= GroupChatSubscription.left, GroupChatSubscription.left == None)) 

187 .where(Message.id > GroupChatSubscription.last_seen_message_id) 

188 ).scalar_one() 

189 

190 pending_friend_request_count = session.execute( 

191 select(func.count(FriendRelationship.id)) 

192 .where(FriendRelationship.to_user_id == context.user_id) 

193 .where_users_column_visible(context, FriendRelationship.from_user_id) 

194 .where(FriendRelationship.status == FriendStatus.pending) 

195 ).scalar_one() 

196 

197 unseen_notification_count = session.execute( 

198 select(func.count()) 

199 .select_from(Notification) 

200 .where(Notification.user_id == context.user_id) 

201 .where(Notification.is_seen == False) 

202 .where( 

203 Notification.topic_action.in_( 

204 get_topic_actions_by_delivery_type(session, user.id, NotificationDeliveryType.push) 

205 ) 

206 ) 

207 ).scalar_one() 

208 

209 return api_pb2.PingRes( 

210 user=user_model_to_pb(user, session, context), 

211 unseen_message_count=unseen_message_count, 

212 unseen_sent_host_request_count=unseen_sent_host_request_count, 

213 unseen_received_host_request_count=unseen_received_host_request_count, 

214 pending_friend_request_count=pending_friend_request_count, 

215 unseen_notification_count=unseen_notification_count, 

216 ) 

217 

218 def GetUser(self, request, context, session): 

219 user = session.execute( 

220 select(User).where_users_visible(context).where_username_or_id(request.user) 

221 ).scalar_one_or_none() 

222 

223 if not user: 

224 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND) 

225 

226 return user_model_to_pb(user, session, context) 

227 

228 def GetLiteUser(self, request, context, session): 

229 lite_user = session.execute( 

230 select(lite_users) 

231 .where_users_visible(context, table=lite_users.c) 

232 .where_username_or_id(request.user, table=lite_users.c) 

233 ).one_or_none() 

234 

235 if not lite_user: 

236 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND) 

237 

238 return lite_user_to_pb(lite_user) 

239 

240 def GetLiteUsers(self, request, context, session): 

241 if len(request.users) > MAX_USERS_PER_QUERY: 

242 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.REQUESTED_TOO_MANY_USERS) 

243 

244 usernames = {u for u in request.users if is_valid_username(u)} 

245 ids = {u for u in request.users if is_valid_user_id(u)} 

246 

247 users = session.execute( 

248 select(lite_users) 

249 .where_users_visible(context, table=lite_users.c) 

250 .where(or_(lite_users.c.username.in_(usernames), lite_users.c.id.in_(ids))) 

251 ).all() 

252 

253 users_by_id = {str(user.id): user for user in users} 

254 users_by_username = {user.username: user for user in users} 

255 

256 res = api_pb2.GetLiteUsersRes() 

257 

258 for user in request.users: 

259 lite_user = None 

260 if user in users_by_id: 

261 lite_user = users_by_id[user] 

262 elif user in users_by_username: 

263 lite_user = users_by_username[user] 

264 

265 res.responses.append( 

266 api_pb2.LiteUserRes( 

267 query=user, 

268 not_found=lite_user is None, 

269 user=lite_user_to_pb(lite_user) if lite_user else None, 

270 ) 

271 ) 

272 

273 return res 

274 

275 def UpdateProfile(self, request, context, session): 

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

277 

278 if request.HasField("name"): 

279 if not is_valid_name(request.name.value): 

280 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_NAME) 

281 user.name = request.name.value 

282 

283 if request.HasField("city"): 

284 user.city = request.city.value 

285 

286 if request.HasField("hometown"): 

287 if request.hometown.is_null: 

288 user.hometown = None 

289 else: 

290 user.hometown = request.hometown.value 

291 

292 if request.HasField("lat") and request.HasField("lng"): 

293 if request.lat.value == 0 and request.lng.value == 0: 

294 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_COORDINATE) 

295 user.geom = create_coordinate(request.lat.value, request.lng.value) 

296 user.randomized_geom = None 

297 

298 if request.HasField("radius"): 

299 user.geom_radius = request.radius.value 

300 

301 if request.HasField("avatar_key"): 

302 if request.avatar_key.is_null: 

303 user.avatar_key = None 

304 else: 

305 user.avatar_key = request.avatar_key.value 

306 

307 # if request.HasField("gender"): 

308 # user.gender = request.gender.value 

309 

310 if request.HasField("pronouns"): 

311 if request.pronouns.is_null: 

312 user.pronouns = None 

313 else: 

314 user.pronouns = request.pronouns.value 

315 

316 if request.HasField("occupation"): 

317 if request.occupation.is_null: 

318 user.occupation = None 

319 else: 

320 user.occupation = request.occupation.value 

321 

322 if request.HasField("education"): 

323 if request.education.is_null: 

324 user.education = None 

325 else: 

326 user.education = request.education.value 

327 

328 if request.HasField("about_me"): 

329 if request.about_me.is_null: 

330 user.about_me = None 

331 else: 

332 user.about_me = request.about_me.value 

333 

334 if request.HasField("things_i_like"): 

335 if request.things_i_like.is_null: 

336 user.things_i_like = None 

337 else: 

338 user.things_i_like = request.things_i_like.value 

339 

340 if request.HasField("about_place"): 

341 if request.about_place.is_null: 

342 user.about_place = None 

343 else: 

344 user.about_place = request.about_place.value 

345 

346 if request.hosting_status != api_pb2.HOSTING_STATUS_UNSPECIFIED: 

347 if user.do_not_email and request.hosting_status != api_pb2.HOSTING_STATUS_CANT_HOST: 

348 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DO_NOT_EMAIL_CANNOT_HOST) 

349 user.hosting_status = hostingstatus2sql[request.hosting_status] 

350 

351 if request.meetup_status != api_pb2.MEETUP_STATUS_UNSPECIFIED: 

352 if user.do_not_email and request.meetup_status != api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP: 

353 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DO_NOT_EMAIL_CANNOT_MEET) 

354 user.meetup_status = meetupstatus2sql[request.meetup_status] 

355 

356 if request.HasField("language_abilities"): 

357 # delete all existing abilities 

358 for ability in user.language_abilities: 

359 session.delete(ability) 

360 session.flush() 

361 

362 # add the new ones 

363 for language_ability in request.language_abilities.value: 

364 if not language_is_allowed(language_ability.code): 

365 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_LANGUAGE) 

366 session.add( 

367 LanguageAbility( 

368 user=user, 

369 language_code=language_ability.code, 

370 fluency=fluency2sql[language_ability.fluency], 

371 ) 

372 ) 

373 

374 if request.HasField("regions_visited"): 

375 session.execute(delete(RegionVisited).where(RegionVisited.user_id == context.user_id)) 

376 

377 for region in request.regions_visited.value: 

378 if not region_is_allowed(region): 

379 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_REGION) 

380 session.add( 

381 RegionVisited( 

382 user_id=user.id, 

383 region_code=region, 

384 ) 

385 ) 

386 

387 if request.HasField("regions_lived"): 

388 session.execute(delete(RegionLived).where(RegionLived.user_id == context.user_id)) 

389 

390 for region in request.regions_lived.value: 

391 if not region_is_allowed(region): 

392 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_REGION) 

393 session.add( 

394 RegionLived( 

395 user_id=user.id, 

396 region_code=region, 

397 ) 

398 ) 

399 

400 if request.HasField("additional_information"): 

401 if request.additional_information.is_null: 

402 user.additional_information = None 

403 else: 

404 user.additional_information = request.additional_information.value 

405 

406 if request.HasField("max_guests"): 

407 if request.max_guests.is_null: 

408 user.max_guests = None 

409 else: 

410 user.max_guests = request.max_guests.value 

411 

412 if request.HasField("last_minute"): 

413 if request.last_minute.is_null: 

414 user.last_minute = None 

415 else: 

416 user.last_minute = request.last_minute.value 

417 

418 if request.HasField("has_pets"): 

419 if request.has_pets.is_null: 

420 user.has_pets = None 

421 else: 

422 user.has_pets = request.has_pets.value 

423 

424 if request.HasField("accepts_pets"): 

425 if request.accepts_pets.is_null: 

426 user.accepts_pets = None 

427 else: 

428 user.accepts_pets = request.accepts_pets.value 

429 

430 if request.HasField("pet_details"): 

431 if request.pet_details.is_null: 

432 user.pet_details = None 

433 else: 

434 user.pet_details = request.pet_details.value 

435 

436 if request.HasField("has_kids"): 

437 if request.has_kids.is_null: 

438 user.has_kids = None 

439 else: 

440 user.has_kids = request.has_kids.value 

441 

442 if request.HasField("accepts_kids"): 

443 if request.accepts_kids.is_null: 

444 user.accepts_kids = None 

445 else: 

446 user.accepts_kids = request.accepts_kids.value 

447 

448 if request.HasField("kid_details"): 

449 if request.kid_details.is_null: 

450 user.kid_details = None 

451 else: 

452 user.kid_details = request.kid_details.value 

453 

454 if request.HasField("has_housemates"): 

455 if request.has_housemates.is_null: 

456 user.has_housemates = None 

457 else: 

458 user.has_housemates = request.has_housemates.value 

459 

460 if request.HasField("housemate_details"): 

461 if request.housemate_details.is_null: 

462 user.housemate_details = None 

463 else: 

464 user.housemate_details = request.housemate_details.value 

465 

466 if request.HasField("wheelchair_accessible"): 

467 if request.wheelchair_accessible.is_null: 

468 user.wheelchair_accessible = None 

469 else: 

470 user.wheelchair_accessible = request.wheelchair_accessible.value 

471 

472 if request.smoking_allowed != api_pb2.SMOKING_LOCATION_UNSPECIFIED: 

473 user.smoking_allowed = smokinglocation2sql[request.smoking_allowed] 

474 

475 if request.HasField("smokes_at_home"): 

476 if request.smokes_at_home.is_null: 

477 user.smokes_at_home = None 

478 else: 

479 user.smokes_at_home = request.smokes_at_home.value 

480 

481 if request.HasField("drinking_allowed"): 

482 if request.drinking_allowed.is_null: 

483 user.drinking_allowed = None 

484 else: 

485 user.drinking_allowed = request.drinking_allowed.value 

486 

487 if request.HasField("drinks_at_home"): 

488 if request.drinks_at_home.is_null: 

489 user.drinks_at_home = None 

490 else: 

491 user.drinks_at_home = request.drinks_at_home.value 

492 

493 if request.HasField("other_host_info"): 

494 if request.other_host_info.is_null: 

495 user.other_host_info = None 

496 else: 

497 user.other_host_info = request.other_host_info.value 

498 

499 if request.sleeping_arrangement != api_pb2.SLEEPING_ARRANGEMENT_UNSPECIFIED: 

500 user.sleeping_arrangement = sleepingarrangement2sql[request.sleeping_arrangement] 

501 

502 if request.HasField("sleeping_details"): 

503 if request.sleeping_details.is_null: 

504 user.sleeping_details = None 

505 else: 

506 user.sleeping_details = request.sleeping_details.value 

507 

508 if request.HasField("area"): 

509 if request.area.is_null: 

510 user.area = None 

511 else: 

512 user.area = request.area.value 

513 

514 if request.HasField("house_rules"): 

515 if request.house_rules.is_null: 

516 user.house_rules = None 

517 else: 

518 user.house_rules = request.house_rules.value 

519 

520 if request.HasField("parking"): 

521 if request.parking.is_null: 

522 user.parking = None 

523 else: 

524 user.parking = request.parking.value 

525 

526 if request.parking_details != api_pb2.PARKING_DETAILS_UNSPECIFIED: 

527 user.parking_details = parkingdetails2sql[request.parking_details] 

528 

529 if request.HasField("camping_ok"): 

530 if request.camping_ok.is_null: 

531 user.camping_ok = None 

532 else: 

533 user.camping_ok = request.camping_ok.value 

534 

535 return empty_pb2.Empty() 

536 

537 def ListFriends(self, request, context, session): 

538 rels = ( 

539 session.execute( 

540 select(FriendRelationship) 

541 .where_users_column_visible(context, FriendRelationship.from_user_id) 

542 .where_users_column_visible(context, FriendRelationship.to_user_id) 

543 .where( 

544 or_( 

545 FriendRelationship.from_user_id == context.user_id, 

546 FriendRelationship.to_user_id == context.user_id, 

547 ) 

548 ) 

549 .where(FriendRelationship.status == FriendStatus.accepted) 

550 ) 

551 .scalars() 

552 .all() 

553 ) 

554 return api_pb2.ListFriendsRes( 

555 user_ids=[rel.from_user.id if rel.from_user.id != context.user_id else rel.to_user.id for rel in rels], 

556 ) 

557 

558 def RemoveFriend(self, request, context, session): 

559 rel = session.execute( 

560 select(FriendRelationship) 

561 .where_users_column_visible(context, FriendRelationship.from_user_id) 

562 .where_users_column_visible(context, FriendRelationship.to_user_id) 

563 .where( 

564 or_( 

565 and_( 

566 FriendRelationship.from_user_id == request.user_id, 

567 FriendRelationship.to_user_id == context.user_id, 

568 ), 

569 and_( 

570 FriendRelationship.from_user_id == context.user_id, 

571 FriendRelationship.to_user_id == request.user_id, 

572 ), 

573 ) 

574 ) 

575 .where(FriendRelationship.status == FriendStatus.accepted) 

576 ).scalar_one_or_none() 

577 

578 if not rel: 

579 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NOT_FRIENDS) 

580 

581 session.delete(rel) 

582 

583 return empty_pb2.Empty() 

584 

585 def ListMutualFriends(self, request, context, session): 

586 if context.user_id == request.user_id: 

587 return api_pb2.ListMutualFriendsRes(mutual_friends=[]) 

588 

589 user = session.execute( 

590 select(User).where_users_visible(context).where(User.id == request.user_id) 

591 ).scalar_one_or_none() 

592 

593 if not user: 

594 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND) 

595 

596 q1 = ( 

597 select(FriendRelationship.from_user_id.label("user_id")) 

598 .where(FriendRelationship.to_user_id == context.user_id) 

599 .where(FriendRelationship.from_user_id != request.user_id) 

600 .where(FriendRelationship.status == FriendStatus.accepted) 

601 ) 

602 

603 q2 = ( 

604 select(FriendRelationship.to_user_id.label("user_id")) 

605 .where(FriendRelationship.from_user_id == context.user_id) 

606 .where(FriendRelationship.to_user_id != request.user_id) 

607 .where(FriendRelationship.status == FriendStatus.accepted) 

608 ) 

609 

610 q3 = ( 

611 select(FriendRelationship.from_user_id.label("user_id")) 

612 .where(FriendRelationship.to_user_id == request.user_id) 

613 .where(FriendRelationship.from_user_id != context.user_id) 

614 .where(FriendRelationship.status == FriendStatus.accepted) 

615 ) 

616 

617 q4 = ( 

618 select(FriendRelationship.to_user_id.label("user_id")) 

619 .where(FriendRelationship.from_user_id == request.user_id) 

620 .where(FriendRelationship.to_user_id != context.user_id) 

621 .where(FriendRelationship.status == FriendStatus.accepted) 

622 ) 

623 

624 mutual = select(intersect(union(q1, q2), union(q3, q4)).subquery()) 

625 

626 mutual_friends = ( 

627 session.execute(select(User).where_users_visible(context).where(User.id.in_(mutual))).scalars().all() 

628 ) 

629 

630 return api_pb2.ListMutualFriendsRes( 

631 mutual_friends=[ 

632 api_pb2.MutualFriend(user_id=mutual_friend.id, username=mutual_friend.username, name=mutual_friend.name) 

633 for mutual_friend in mutual_friends 

634 ] 

635 ) 

636 

637 def SendFriendRequest(self, request, context, session): 

638 if context.user_id == request.user_id: 

639 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.CANT_FRIEND_SELF) 

640 

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

642 to_user = session.execute( 

643 select(User).where_users_visible(context).where(User.id == request.user_id) 

644 ).scalar_one_or_none() 

645 

646 if not to_user: 

647 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND) 

648 

649 if ( 

650 session.execute( 

651 select(FriendRelationship) 

652 .where( 

653 or_( 

654 and_( 

655 FriendRelationship.from_user_id == context.user_id, 

656 FriendRelationship.to_user_id == request.user_id, 

657 ), 

658 and_( 

659 FriendRelationship.from_user_id == request.user_id, 

660 FriendRelationship.to_user_id == context.user_id, 

661 ), 

662 ) 

663 ) 

664 .where( 

665 or_( 

666 FriendRelationship.status == FriendStatus.accepted, 

667 FriendRelationship.status == FriendStatus.pending, 

668 ) 

669 ) 

670 ).scalar_one_or_none() 

671 is not None 

672 ): 

673 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.FRIENDS_ALREADY_OR_PENDING) 

674 

675 # Check if user has been sending friend requests excessively 

676 if process_rate_limits_and_check_abort( 

677 session=session, user_id=context.user_id, action=RateLimitAction.friend_request 

678 ): 

679 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.FRIEND_REQUEST_RATE_LIMIT) 

680 

681 # TODO: Race condition where we can create two friend reqs, needs db constraint! See comment in table 

682 

683 friend_relationship = FriendRelationship(from_user=user, to_user=to_user, status=FriendStatus.pending) 

684 session.add(friend_relationship) 

685 session.flush() 

686 

687 notify( 

688 session, 

689 user_id=friend_relationship.to_user_id, 

690 topic_action="friend_request:create", 

691 key=friend_relationship.from_user_id, 

692 data=notification_data_pb2.FriendRequestCreate( 

693 other_user=user_model_to_pb(friend_relationship.from_user, session, context), 

694 ), 

695 ) 

696 

697 return empty_pb2.Empty() 

698 

699 def ListFriendRequests(self, request, context, session): 

700 # both sent and received 

701 sent_requests = ( 

702 session.execute( 

703 select(FriendRelationship) 

704 .where_users_column_visible(context, FriendRelationship.to_user_id) 

705 .where(FriendRelationship.from_user_id == context.user_id) 

706 .where(FriendRelationship.status == FriendStatus.pending) 

707 ) 

708 .scalars() 

709 .all() 

710 ) 

711 

712 received_requests = ( 

713 session.execute( 

714 select(FriendRelationship) 

715 .where_users_column_visible(context, FriendRelationship.from_user_id) 

716 .where(FriendRelationship.to_user_id == context.user_id) 

717 .where(FriendRelationship.status == FriendStatus.pending) 

718 ) 

719 .scalars() 

720 .all() 

721 ) 

722 

723 return api_pb2.ListFriendRequestsRes( 

724 sent=[ 

725 api_pb2.FriendRequest( 

726 friend_request_id=friend_request.id, 

727 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING, 

728 user_id=friend_request.to_user.id, 

729 sent=True, 

730 ) 

731 for friend_request in sent_requests 

732 ], 

733 received=[ 

734 api_pb2.FriendRequest( 

735 friend_request_id=friend_request.id, 

736 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING, 

737 user_id=friend_request.from_user.id, 

738 sent=False, 

739 ) 

740 for friend_request in received_requests 

741 ], 

742 ) 

743 

744 def RespondFriendRequest(self, request, context, session): 

745 friend_request = session.execute( 

746 select(FriendRelationship) 

747 .where_users_column_visible(context, FriendRelationship.from_user_id) 

748 .where(FriendRelationship.to_user_id == context.user_id) 

749 .where(FriendRelationship.status == FriendStatus.pending) 

750 .where(FriendRelationship.id == request.friend_request_id) 

751 ).scalar_one_or_none() 

752 

753 if not friend_request: 

754 context.abort(grpc.StatusCode.NOT_FOUND, errors.FRIEND_REQUEST_NOT_FOUND) 

755 

756 friend_request.status = FriendStatus.accepted if request.accept else FriendStatus.rejected 

757 friend_request.time_responded = func.now() 

758 

759 session.flush() 

760 

761 if friend_request.status == FriendStatus.accepted: 

762 notify( 

763 session, 

764 user_id=friend_request.from_user_id, 

765 topic_action="friend_request:accept", 

766 key=friend_request.to_user_id, 

767 data=notification_data_pb2.FriendRequestAccept( 

768 other_user=user_model_to_pb(friend_request.to_user, session, context), 

769 ), 

770 ) 

771 

772 return empty_pb2.Empty() 

773 

774 def CancelFriendRequest(self, request, context, session): 

775 friend_request = session.execute( 

776 select(FriendRelationship) 

777 .where_users_column_visible(context, FriendRelationship.to_user_id) 

778 .where(FriendRelationship.from_user_id == context.user_id) 

779 .where(FriendRelationship.status == FriendStatus.pending) 

780 .where(FriendRelationship.id == request.friend_request_id) 

781 ).scalar_one_or_none() 

782 

783 if not friend_request: 

784 context.abort(grpc.StatusCode.NOT_FOUND, errors.FRIEND_REQUEST_NOT_FOUND) 

785 

786 friend_request.status = FriendStatus.cancelled 

787 friend_request.time_responded = func.now() 

788 

789 # note no notifications 

790 

791 session.commit() 

792 

793 return empty_pb2.Empty() 

794 

795 def InitiateMediaUpload(self, request, context, session): 

796 key = random_hex() 

797 

798 created = now() 

799 expiry = created + timedelta(minutes=20) 

800 

801 upload = InitiatedUpload(key=key, created=created, expiry=expiry, initiator_user_id=context.user_id) 

802 session.add(upload) 

803 session.commit() 

804 

805 req = media_pb2.UploadRequest( 

806 key=upload.key, 

807 type=media_pb2.UploadRequest.UploadType.IMAGE, 

808 created=Timestamp_from_datetime(upload.created), 

809 expiry=Timestamp_from_datetime(upload.expiry), 

810 max_width=2000, 

811 max_height=1600, 

812 ).SerializeToString() 

813 

814 data = b64encode(req) 

815 sig = b64encode(generate_hash_signature(req, config["MEDIA_SERVER_SECRET_KEY"])) 

816 

817 path = "upload?" + urlencode({"data": data, "sig": sig}) 

818 

819 return api_pb2.InitiateMediaUploadRes( 

820 upload_url=urls.media_upload_url(path=path), 

821 expiry=Timestamp_from_datetime(expiry), 

822 ) 

823 

824 def ListBadgeUsers(self, request, context, session): 

825 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

826 next_user_id = int(request.page_token) if request.page_token else 0 

827 badge = get_badge_dict().get(request.badge_id) 

828 if not badge: 

829 context.abort(grpc.StatusCode.NOT_FOUND, errors.BADGE_NOT_FOUND) 

830 

831 badge_user_ids = ( 

832 session.execute( 

833 select(UserBadge.user_id) 

834 .where(UserBadge.badge_id == badge["id"]) 

835 .where(UserBadge.user_id >= next_user_id) 

836 .order_by(UserBadge.user_id) 

837 .limit(page_size + 1) 

838 ) 

839 .scalars() 

840 .all() 

841 ) 

842 

843 return api_pb2.ListBadgeUsersRes( 

844 user_ids=badge_user_ids[:page_size], 

845 next_page_token=str(badge_user_ids[-1]) if len(badge_user_ids) > page_size else None, 

846 ) 

847 

848 

849def response_rate_to_pb(response_rates): 

850 if not response_rates: 

851 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()} 

852 

853 _, n, response_rate, _, response_time_p33, response_time_p66 = response_rates 

854 

855 # if n is None, the user is new or they have no requests 

856 if not n or n < 3: 

857 return {"insufficient_data": requests_pb2.ResponseRateInsufficientData()} 

858 

859 if response_rate <= 0.33: 

860 return {"low": requests_pb2.ResponseRateLow()} 

861 

862 response_time_p33_coarsened = Duration_from_timedelta( 

863 timedelta(seconds=round(response_time_p33.total_seconds() / 60) * 60) 

864 ) 

865 

866 if response_rate <= 0.66: 

867 return {"some": requests_pb2.ResponseRateSome(response_time_p33=response_time_p33_coarsened)} 

868 

869 response_time_p66_coarsened = Duration_from_timedelta( 

870 timedelta(seconds=round(response_time_p66.total_seconds() / 60) * 60) 

871 ) 

872 

873 if response_rate <= 0.90: 

874 return { 

875 "most": requests_pb2.ResponseRateMost( 

876 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened 

877 ) 

878 } 

879 else: 

880 return { 

881 "almost_all": requests_pb2.ResponseRateAlmostAll( 

882 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened 

883 ) 

884 } 

885 

886 

887def get_num_references(session, user_ids): 

888 return dict( 

889 session.execute( 

890 select(Reference.to_user_id, func.count(Reference.id)) 

891 .where(Reference.to_user_id.in_(user_ids)) 

892 .where(Reference.is_deleted == False) 

893 .join(User, User.id == Reference.from_user_id) 

894 .where(User.is_visible) 

895 .group_by(Reference.to_user_id) 

896 ).all() 

897 ) 

898 

899 

900def user_model_to_pb(db_user, session, context): 

901 # note that this function should work also for banned/deleted users as it's called from Admin.GetUser 

902 # note that this function is sometimes called by a logged out user, in which case context comes from make_logged_out_context 

903 num_references = get_num_references(session, [db_user.id]).get(db_user.id, 0) 

904 

905 # returns (lat, lng) 

906 # we put people without coords on null island 

907 # https://en.wikipedia.org/wiki/Null_Island 

908 lat, lng = db_user.coordinates or (0, 0) 

909 

910 pending_friend_request = None 

911 if db_user.id == context.user_id: 

912 friends_status = api_pb2.User.FriendshipStatus.NA 

913 else: 

914 friend_relationship = session.execute( 

915 select(FriendRelationship) 

916 .where( 

917 or_( 

918 and_( 

919 FriendRelationship.from_user_id == context.user_id, 

920 FriendRelationship.to_user_id == db_user.id, 

921 ), 

922 and_( 

923 FriendRelationship.from_user_id == db_user.id, 

924 FriendRelationship.to_user_id == context.user_id, 

925 ), 

926 ) 

927 ) 

928 .where( 

929 or_( 

930 FriendRelationship.status == FriendStatus.accepted, 

931 FriendRelationship.status == FriendStatus.pending, 

932 ) 

933 ) 

934 ).scalar_one_or_none() 

935 

936 if friend_relationship: 

937 if friend_relationship.status == FriendStatus.accepted: 

938 friends_status = api_pb2.User.FriendshipStatus.FRIENDS 

939 else: 

940 friends_status = api_pb2.User.FriendshipStatus.PENDING 

941 if friend_relationship.from_user_id == context.user_id: 

942 # we sent it 

943 pending_friend_request = api_pb2.FriendRequest( 

944 friend_request_id=friend_relationship.id, 

945 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING, 

946 user_id=friend_relationship.to_user.id, 

947 sent=True, 

948 ) 

949 else: 

950 # we received it 

951 pending_friend_request = api_pb2.FriendRequest( 

952 friend_request_id=friend_relationship.id, 

953 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING, 

954 user_id=friend_relationship.from_user.id, 

955 sent=False, 

956 ) 

957 else: 

958 friends_status = api_pb2.User.FriendshipStatus.NOT_FRIENDS 

959 

960 response_rates = session.execute( 

961 select(user_response_rates).where(user_response_rates.c.user_id == db_user.id) 

962 ).one_or_none() 

963 

964 verification_score = 0.0 

965 if db_user.phone_verification_verified: 

966 verification_score += 1.0 * db_user.phone_is_verified 

967 

968 user = api_pb2.User( 

969 user_id=db_user.id, 

970 username=db_user.username, 

971 name=db_user.name, 

972 city=db_user.city, 

973 hometown=db_user.hometown, 

974 timezone=db_user.timezone, 

975 lat=lat, 

976 lng=lng, 

977 radius=db_user.geom_radius, 

978 verification=verification_score, 

979 community_standing=db_user.community_standing, 

980 num_references=num_references, 

981 gender=db_user.gender, 

982 pronouns=db_user.pronouns, 

983 age=int(db_user.age), 

984 joined=Timestamp_from_datetime(db_user.display_joined), 

985 last_active=Timestamp_from_datetime(db_user.display_last_active), 

986 hosting_status=hostingstatus2api[db_user.hosting_status], 

987 meetup_status=meetupstatus2api[db_user.meetup_status], 

988 occupation=db_user.occupation, 

989 education=db_user.education, 

990 about_me=db_user.about_me, 

991 things_i_like=db_user.things_i_like, 

992 about_place=db_user.about_place, 

993 language_abilities=[ 

994 api_pb2.LanguageAbility(code=ability.language_code, fluency=fluency2api[ability.fluency]) 

995 for ability in db_user.language_abilities 

996 ], 

997 regions_visited=[region.code for region in db_user.regions_visited], 

998 regions_lived=[region.code for region in db_user.regions_lived], 

999 additional_information=db_user.additional_information, 

1000 friends=friends_status, 

1001 pending_friend_request=pending_friend_request, 

1002 smoking_allowed=smokinglocation2api[db_user.smoking_allowed], 

1003 sleeping_arrangement=sleepingarrangement2api[db_user.sleeping_arrangement], 

1004 parking_details=parkingdetails2api[db_user.parking_details], 

1005 avatar_url=db_user.avatar.full_url if db_user.avatar else None, 

1006 avatar_thumbnail_url=db_user.avatar.thumbnail_url if db_user.avatar else None, 

1007 badges=session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == db_user.id).order_by(UserBadge.id)) 

1008 .scalars() 

1009 .all(), 

1010 **get_strong_verification_fields(session, db_user), 

1011 **response_rate_to_pb(response_rates), 

1012 ) 

1013 

1014 if db_user.max_guests is not None: 

1015 user.max_guests.value = db_user.max_guests 

1016 

1017 if db_user.last_minute is not None: 

1018 user.last_minute.value = db_user.last_minute 

1019 

1020 if db_user.has_pets is not None: 

1021 user.has_pets.value = db_user.has_pets 

1022 

1023 if db_user.accepts_pets is not None: 

1024 user.accepts_pets.value = db_user.accepts_pets 

1025 

1026 if db_user.pet_details is not None: 

1027 user.pet_details.value = db_user.pet_details 

1028 

1029 if db_user.has_kids is not None: 

1030 user.has_kids.value = db_user.has_kids 

1031 

1032 if db_user.accepts_kids is not None: 

1033 user.accepts_kids.value = db_user.accepts_kids 

1034 

1035 if db_user.kid_details is not None: 

1036 user.kid_details.value = db_user.kid_details 

1037 

1038 if db_user.has_housemates is not None: 

1039 user.has_housemates.value = db_user.has_housemates 

1040 

1041 if db_user.housemate_details is not None: 

1042 user.housemate_details.value = db_user.housemate_details 

1043 

1044 if db_user.wheelchair_accessible is not None: 

1045 user.wheelchair_accessible.value = db_user.wheelchair_accessible 

1046 

1047 if db_user.smokes_at_home is not None: 

1048 user.smokes_at_home.value = db_user.smokes_at_home 

1049 

1050 if db_user.drinking_allowed is not None: 

1051 user.drinking_allowed.value = db_user.drinking_allowed 

1052 

1053 if db_user.drinks_at_home is not None: 

1054 user.drinks_at_home.value = db_user.drinks_at_home 

1055 

1056 if db_user.other_host_info is not None: 

1057 user.other_host_info.value = db_user.other_host_info 

1058 

1059 if db_user.sleeping_details is not None: 

1060 user.sleeping_details.value = db_user.sleeping_details 

1061 

1062 if db_user.area is not None: 

1063 user.area.value = db_user.area 

1064 

1065 if db_user.house_rules is not None: 

1066 user.house_rules.value = db_user.house_rules 

1067 

1068 if db_user.parking is not None: 

1069 user.parking.value = db_user.parking 

1070 

1071 if db_user.camping_ok is not None: 

1072 user.camping_ok.value = db_user.camping_ok 

1073 

1074 return user 

1075 

1076 

1077def lite_user_to_pb(lite_user): 

1078 lat, lng = get_coordinates(lite_user.geom) or (0, 0) 

1079 

1080 return api_pb2.LiteUser( 

1081 user_id=lite_user.id, 

1082 username=lite_user.username, 

1083 name=lite_user.name, 

1084 city=lite_user.city, 

1085 age=int(lite_user.age), 

1086 avatar_url=urls.media_url(filename=lite_user.avatar_filename, size="full") 

1087 if lite_user.avatar_filename 

1088 else None, 

1089 avatar_thumbnail_url=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail") 

1090 if lite_user.avatar_filename 

1091 else None, 

1092 lat=lat, 

1093 lng=lng, 

1094 radius=lite_user.radius, 

1095 has_strong_verification=lite_user.has_strong_verification, 

1096 )