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

410 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-04 01:57 +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 urls 

9from couchers.config import config 

10from couchers.constants import GHOST_USERNAME 

11from couchers.crypto import b64encode, generate_hash_signature, random_hex 

12from couchers.helpers.strong_verification import get_strong_verification_fields 

13from couchers.materialized_views import LiteUser, UserResponseRate 

14from couchers.models import ( 

15 FriendRelationship, 

16 FriendStatus, 

17 GroupChatSubscription, 

18 HostingStatus, 

19 HostRequest, 

20 InitiatedUpload, 

21 LanguageAbility, 

22 LanguageFluency, 

23 MeetupStatus, 

24 Message, 

25 Notification, 

26 NotificationDeliveryType, 

27 ParkingDetails, 

28 RateLimitAction, 

29 Reference, 

30 RegionLived, 

31 RegionVisited, 

32 SleepingArrangement, 

33 SmokingLocation, 

34 User, 

35 UserBadge, 

36) 

37from couchers.notifications.notify import notify 

38from couchers.notifications.settings import get_topic_actions_by_delivery_type 

39from couchers.proto import api_pb2, api_pb2_grpc, media_pb2, notification_data_pb2, requests_pb2 

40from couchers.rate_limits.check import process_rate_limits_and_check_abort 

41from couchers.rate_limits.definitions import RATE_LIMIT_HOURS 

42from couchers.resources import get_badge_dict, language_is_allowed, region_is_allowed 

43from couchers.servicers.blocking import is_not_visible 

44from couchers.sql import couchers_select as select 

45from couchers.sql import is_valid_user_id, is_valid_username 

46from couchers.utils import ( 

47 Duration_from_timedelta, 

48 Timestamp_from_datetime, 

49 create_coordinate, 

50 get_coordinates, 

51 is_valid_name, 

52 now, 

53) 

54 

55 

56class GhostUserSerializationError(Exception): 

57 """ 

58 Raised when attempting to serialize a ghost user (deleted/banned/blocked) 

59 """ 

60 

61 pass 

62 

63 

64MAX_USERS_PER_QUERY = 200 

65MAX_PAGINATION_LENGTH = 50 

66 

67hostingstatus2sql = { 

68 api_pb2.HOSTING_STATUS_UNKNOWN: None, 

69 api_pb2.HOSTING_STATUS_CAN_HOST: HostingStatus.can_host, 

70 api_pb2.HOSTING_STATUS_MAYBE: HostingStatus.maybe, 

71 api_pb2.HOSTING_STATUS_CANT_HOST: HostingStatus.cant_host, 

72} 

73 

74hostingstatus2api = { 

75 None: api_pb2.HOSTING_STATUS_UNKNOWN, 

76 HostingStatus.can_host: api_pb2.HOSTING_STATUS_CAN_HOST, 

77 HostingStatus.maybe: api_pb2.HOSTING_STATUS_MAYBE, 

78 HostingStatus.cant_host: api_pb2.HOSTING_STATUS_CANT_HOST, 

79} 

80 

81meetupstatus2sql = { 

82 api_pb2.MEETUP_STATUS_UNKNOWN: None, 

83 api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP: MeetupStatus.wants_to_meetup, 

84 api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP: MeetupStatus.open_to_meetup, 

85 api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP: MeetupStatus.does_not_want_to_meetup, 

86} 

87 

88meetupstatus2api = { 

89 None: api_pb2.MEETUP_STATUS_UNKNOWN, 

90 MeetupStatus.wants_to_meetup: api_pb2.MEETUP_STATUS_WANTS_TO_MEETUP, 

91 MeetupStatus.open_to_meetup: api_pb2.MEETUP_STATUS_OPEN_TO_MEETUP, 

92 MeetupStatus.does_not_want_to_meetup: api_pb2.MEETUP_STATUS_DOES_NOT_WANT_TO_MEETUP, 

93} 

94 

95smokinglocation2sql = { 

96 api_pb2.SMOKING_LOCATION_UNKNOWN: None, 

97 api_pb2.SMOKING_LOCATION_YES: SmokingLocation.yes, 

98 api_pb2.SMOKING_LOCATION_WINDOW: SmokingLocation.window, 

99 api_pb2.SMOKING_LOCATION_OUTSIDE: SmokingLocation.outside, 

100 api_pb2.SMOKING_LOCATION_NO: SmokingLocation.no, 

101} 

102 

103smokinglocation2api = { 

104 None: api_pb2.SMOKING_LOCATION_UNKNOWN, 

105 SmokingLocation.yes: api_pb2.SMOKING_LOCATION_YES, 

106 SmokingLocation.window: api_pb2.SMOKING_LOCATION_WINDOW, 

107 SmokingLocation.outside: api_pb2.SMOKING_LOCATION_OUTSIDE, 

108 SmokingLocation.no: api_pb2.SMOKING_LOCATION_NO, 

109} 

110 

111sleepingarrangement2sql = { 

112 api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN: None, 

113 api_pb2.SLEEPING_ARRANGEMENT_PRIVATE: SleepingArrangement.private, 

114 api_pb2.SLEEPING_ARRANGEMENT_COMMON: SleepingArrangement.common, 

115 api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM: SleepingArrangement.shared_room, 

116} 

117 

118sleepingarrangement2api = { 

119 None: api_pb2.SLEEPING_ARRANGEMENT_UNKNOWN, 

120 SleepingArrangement.private: api_pb2.SLEEPING_ARRANGEMENT_PRIVATE, 

121 SleepingArrangement.common: api_pb2.SLEEPING_ARRANGEMENT_COMMON, 

122 SleepingArrangement.shared_room: api_pb2.SLEEPING_ARRANGEMENT_SHARED_ROOM, 

123} 

124 

125parkingdetails2sql = { 

126 api_pb2.PARKING_DETAILS_UNKNOWN: None, 

127 api_pb2.PARKING_DETAILS_FREE_ONSITE: ParkingDetails.free_onsite, 

128 api_pb2.PARKING_DETAILS_FREE_OFFSITE: ParkingDetails.free_offsite, 

129 api_pb2.PARKING_DETAILS_PAID_ONSITE: ParkingDetails.paid_onsite, 

130 api_pb2.PARKING_DETAILS_PAID_OFFSITE: ParkingDetails.paid_offsite, 

131} 

132 

133parkingdetails2api = { 

134 None: api_pb2.PARKING_DETAILS_UNKNOWN, 

135 ParkingDetails.free_onsite: api_pb2.PARKING_DETAILS_FREE_ONSITE, 

136 ParkingDetails.free_offsite: api_pb2.PARKING_DETAILS_FREE_OFFSITE, 

137 ParkingDetails.paid_onsite: api_pb2.PARKING_DETAILS_PAID_ONSITE, 

138 ParkingDetails.paid_offsite: api_pb2.PARKING_DETAILS_PAID_OFFSITE, 

139} 

140 

141fluency2sql = { 

142 api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN: None, 

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

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

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

146} 

147 

148fluency2api = { 

149 None: api_pb2.LanguageAbility.Fluency.FLUENCY_UNKNOWN, 

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

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

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

153} 

154 

155 

156class API(api_pb2_grpc.APIServicer): 

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

158 # auth ought to make sure the user exists 

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

160 

161 sent_reqs_last_seen_message_ids = ( 

162 select(HostRequest.conversation_id, HostRequest.surfer_last_seen_message_id) 

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

164 .where_users_column_visible(context, HostRequest.host_user_id) 

165 ).subquery() 

166 

167 unseen_sent_host_request_count = session.execute( 

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

169 .join( 

170 Message, 

171 Message.conversation_id == sent_reqs_last_seen_message_ids.c.conversation_id, 

172 ) 

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

174 .where(Message.id != None) 

175 ).scalar_one() 

176 

177 received_reqs_last_seen_message_ids = ( 

178 select(HostRequest.conversation_id, HostRequest.host_last_seen_message_id) 

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

180 .where_users_column_visible(context, HostRequest.surfer_user_id) 

181 ).subquery() 

182 

183 unseen_received_host_request_count = session.execute( 

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

185 .join( 

186 Message, 

187 Message.conversation_id == received_reqs_last_seen_message_ids.c.conversation_id, 

188 ) 

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

190 .where(Message.id != None) 

191 ).scalar_one() 

192 

193 unseen_message_count = session.execute( 

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

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

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

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

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

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

200 ).scalar_one() 

201 

202 pending_friend_request_count = session.execute( 

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

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

205 .where_users_column_visible(context, FriendRelationship.from_user_id) 

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

207 ).scalar_one() 

208 

209 unseen_notification_count = session.execute( 

210 select(func.count()) 

211 .select_from(Notification) 

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

213 .where(Notification.is_seen == False) 

214 .where( 

215 Notification.topic_action.in_( 

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

217 ) 

218 ) 

219 ).scalar_one() 

220 

221 return api_pb2.PingRes( 

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

223 unseen_message_count=unseen_message_count, 

224 unseen_sent_host_request_count=unseen_sent_host_request_count, 

225 unseen_received_host_request_count=unseen_received_host_request_count, 

226 pending_friend_request_count=pending_friend_request_count, 

227 unseen_notification_count=unseen_notification_count, 

228 ) 

229 

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

231 user = session.execute(select(User).where_username_or_id(request.user)).scalar_one_or_none() 

232 

233 if not user: 

234 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

235 

236 return user_model_to_pb(user, session, context, is_get_user_return_ghosts=True) 

237 

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

239 lite_user = session.execute( 

240 select(LiteUser).where_username_or_id(request.user, table=LiteUser) 

241 ).scalar_one_or_none() 

242 

243 if not lite_user: 

244 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

245 

246 return lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True) 

247 

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

249 if len(request.users) > MAX_USERS_PER_QUERY: 

250 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "requested_too_many_users") 

251 

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

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

254 

255 # decomposed where_username_or_id... 

256 users = ( 

257 session.execute(select(LiteUser).where(or_(LiteUser.username.in_(usernames), LiteUser.id.in_(ids)))) 

258 .scalars() 

259 .all() 

260 ) 

261 

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

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

264 

265 res = api_pb2.GetLiteUsersRes() 

266 

267 for user in request.users: 

268 lite_user = None 

269 if user in users_by_id: 

270 lite_user = users_by_id[user] 

271 elif user in users_by_username: 

272 lite_user = users_by_username[user] 

273 

274 res.responses.append( 

275 api_pb2.LiteUserRes( 

276 query=user, 

277 not_found=lite_user is None, 

278 user=lite_user_to_pb(session, lite_user, context, is_get_user_return_ghosts=True) 

279 if lite_user 

280 else None, 

281 ) 

282 ) 

283 

284 return res 

285 

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

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

288 

289 if request.HasField("name"): 

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

291 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name") 

292 user.name = request.name.value 

293 

294 if request.HasField("city"): 

295 user.city = request.city.value 

296 

297 if request.HasField("hometown"): 

298 if request.hometown.is_null: 

299 user.hometown = None 

300 else: 

301 user.hometown = request.hometown.value 

302 

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

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

305 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate") 

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

307 user.randomized_geom = None 

308 

309 if request.HasField("radius"): 

310 user.geom_radius = request.radius.value 

311 

312 if request.HasField("avatar_key"): 

313 if request.avatar_key.is_null: 

314 user.avatar_key = None 

315 else: 

316 user.avatar_key = request.avatar_key.value 

317 

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

319 # user.gender = request.gender.value 

320 

321 if request.HasField("pronouns"): 

322 if request.pronouns.is_null: 

323 user.pronouns = None 

324 else: 

325 user.pronouns = request.pronouns.value 

326 

327 if request.HasField("occupation"): 

328 if request.occupation.is_null: 

329 user.occupation = None 

330 else: 

331 user.occupation = request.occupation.value 

332 

333 if request.HasField("education"): 

334 if request.education.is_null: 

335 user.education = None 

336 else: 

337 user.education = request.education.value 

338 

339 if request.HasField("about_me"): 

340 if request.about_me.is_null: 

341 user.about_me = None 

342 else: 

343 user.about_me = request.about_me.value 

344 

345 if request.HasField("things_i_like"): 

346 if request.things_i_like.is_null: 

347 user.things_i_like = None 

348 else: 

349 user.things_i_like = request.things_i_like.value 

350 

351 if request.HasField("about_place"): 

352 if request.about_place.is_null: 

353 user.about_place = None 

354 else: 

355 user.about_place = request.about_place.value 

356 

357 if request.hosting_status != api_pb2.HOSTING_STATUS_UNSPECIFIED: 

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

359 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_host") 

360 user.hosting_status = hostingstatus2sql[request.hosting_status] 

361 

362 if request.meetup_status != api_pb2.MEETUP_STATUS_UNSPECIFIED: 

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

364 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "do_not_email_cannot_meet") 

365 user.meetup_status = meetupstatus2sql[request.meetup_status] 

366 

367 if request.HasField("language_abilities"): 

368 # delete all existing abilities 

369 for ability in user.language_abilities: 

370 session.delete(ability) 

371 session.flush() 

372 

373 # add the new ones 

374 for language_ability in request.language_abilities.value: 

375 if not language_is_allowed(language_ability.code): 

376 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_language") 

377 session.add( 

378 LanguageAbility( 

379 user=user, 

380 language_code=language_ability.code, 

381 fluency=fluency2sql[language_ability.fluency], 

382 ) 

383 ) 

384 

385 if request.HasField("regions_visited"): 

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

387 

388 for region in request.regions_visited.value: 

389 if not region_is_allowed(region): 

390 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region") 

391 session.add( 

392 RegionVisited( 

393 user_id=user.id, 

394 region_code=region, 

395 ) 

396 ) 

397 

398 if request.HasField("regions_lived"): 

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

400 

401 for region in request.regions_lived.value: 

402 if not region_is_allowed(region): 

403 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_region") 

404 session.add( 

405 RegionLived( 

406 user_id=user.id, 

407 region_code=region, 

408 ) 

409 ) 

410 

411 if request.HasField("additional_information"): 

412 if request.additional_information.is_null: 

413 user.additional_information = None 

414 else: 

415 user.additional_information = request.additional_information.value 

416 

417 if request.HasField("max_guests"): 

418 if request.max_guests.is_null: 

419 user.max_guests = None 

420 else: 

421 user.max_guests = request.max_guests.value 

422 

423 if request.HasField("last_minute"): 

424 if request.last_minute.is_null: 

425 user.last_minute = None 

426 else: 

427 user.last_minute = request.last_minute.value 

428 

429 if request.HasField("has_pets"): 

430 if request.has_pets.is_null: 

431 user.has_pets = None 

432 else: 

433 user.has_pets = request.has_pets.value 

434 

435 if request.HasField("accepts_pets"): 

436 if request.accepts_pets.is_null: 

437 user.accepts_pets = None 

438 else: 

439 user.accepts_pets = request.accepts_pets.value 

440 

441 if request.HasField("pet_details"): 

442 if request.pet_details.is_null: 

443 user.pet_details = None 

444 else: 

445 user.pet_details = request.pet_details.value 

446 

447 if request.HasField("has_kids"): 

448 if request.has_kids.is_null: 

449 user.has_kids = None 

450 else: 

451 user.has_kids = request.has_kids.value 

452 

453 if request.HasField("accepts_kids"): 

454 if request.accepts_kids.is_null: 

455 user.accepts_kids = None 

456 else: 

457 user.accepts_kids = request.accepts_kids.value 

458 

459 if request.HasField("kid_details"): 

460 if request.kid_details.is_null: 

461 user.kid_details = None 

462 else: 

463 user.kid_details = request.kid_details.value 

464 

465 if request.HasField("has_housemates"): 

466 if request.has_housemates.is_null: 

467 user.has_housemates = None 

468 else: 

469 user.has_housemates = request.has_housemates.value 

470 

471 if request.HasField("housemate_details"): 

472 if request.housemate_details.is_null: 

473 user.housemate_details = None 

474 else: 

475 user.housemate_details = request.housemate_details.value 

476 

477 if request.HasField("wheelchair_accessible"): 

478 if request.wheelchair_accessible.is_null: 

479 user.wheelchair_accessible = None 

480 else: 

481 user.wheelchair_accessible = request.wheelchair_accessible.value 

482 

483 if request.smoking_allowed != api_pb2.SMOKING_LOCATION_UNSPECIFIED: 

484 user.smoking_allowed = smokinglocation2sql[request.smoking_allowed] 

485 

486 if request.HasField("smokes_at_home"): 

487 if request.smokes_at_home.is_null: 

488 user.smokes_at_home = None 

489 else: 

490 user.smokes_at_home = request.smokes_at_home.value 

491 

492 if request.HasField("drinking_allowed"): 

493 if request.drinking_allowed.is_null: 

494 user.drinking_allowed = None 

495 else: 

496 user.drinking_allowed = request.drinking_allowed.value 

497 

498 if request.HasField("drinks_at_home"): 

499 if request.drinks_at_home.is_null: 

500 user.drinks_at_home = None 

501 else: 

502 user.drinks_at_home = request.drinks_at_home.value 

503 

504 if request.HasField("other_host_info"): 

505 if request.other_host_info.is_null: 

506 user.other_host_info = None 

507 else: 

508 user.other_host_info = request.other_host_info.value 

509 

510 if request.sleeping_arrangement != api_pb2.SLEEPING_ARRANGEMENT_UNSPECIFIED: 

511 user.sleeping_arrangement = sleepingarrangement2sql[request.sleeping_arrangement] 

512 

513 if request.HasField("sleeping_details"): 

514 if request.sleeping_details.is_null: 

515 user.sleeping_details = None 

516 else: 

517 user.sleeping_details = request.sleeping_details.value 

518 

519 if request.HasField("area"): 

520 if request.area.is_null: 

521 user.area = None 

522 else: 

523 user.area = request.area.value 

524 

525 if request.HasField("house_rules"): 

526 if request.house_rules.is_null: 

527 user.house_rules = None 

528 else: 

529 user.house_rules = request.house_rules.value 

530 

531 if request.HasField("parking"): 

532 if request.parking.is_null: 

533 user.parking = None 

534 else: 

535 user.parking = request.parking.value 

536 

537 if request.parking_details != api_pb2.PARKING_DETAILS_UNSPECIFIED: 

538 user.parking_details = parkingdetails2sql[request.parking_details] 

539 

540 if request.HasField("camping_ok"): 

541 if request.camping_ok.is_null: 

542 user.camping_ok = None 

543 else: 

544 user.camping_ok = request.camping_ok.value 

545 

546 return empty_pb2.Empty() 

547 

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

549 rels = ( 

550 session.execute( 

551 select(FriendRelationship) 

552 .where_users_column_visible(context, FriendRelationship.from_user_id) 

553 .where_users_column_visible(context, FriendRelationship.to_user_id) 

554 .where( 

555 or_( 

556 FriendRelationship.from_user_id == context.user_id, 

557 FriendRelationship.to_user_id == context.user_id, 

558 ) 

559 ) 

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

561 ) 

562 .scalars() 

563 .all() 

564 ) 

565 return api_pb2.ListFriendsRes( 

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

567 ) 

568 

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

570 rel = session.execute( 

571 select(FriendRelationship) 

572 .where_users_column_visible(context, FriendRelationship.from_user_id) 

573 .where_users_column_visible(context, FriendRelationship.to_user_id) 

574 .where( 

575 or_( 

576 and_( 

577 FriendRelationship.from_user_id == request.user_id, 

578 FriendRelationship.to_user_id == context.user_id, 

579 ), 

580 and_( 

581 FriendRelationship.from_user_id == context.user_id, 

582 FriendRelationship.to_user_id == request.user_id, 

583 ), 

584 ) 

585 ) 

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

587 ).scalar_one_or_none() 

588 

589 if not rel: 

590 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_friends") 

591 

592 session.delete(rel) 

593 

594 return empty_pb2.Empty() 

595 

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

597 if context.user_id == request.user_id: 

598 return api_pb2.ListMutualFriendsRes(mutual_friends=[]) 

599 

600 user = session.execute( 

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

602 ).scalar_one_or_none() 

603 

604 if not user: 

605 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

606 

607 q1 = ( 

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

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

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

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

612 ) 

613 

614 q2 = ( 

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

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

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

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

619 ) 

620 

621 q3 = ( 

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

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

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

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

626 ) 

627 

628 q4 = ( 

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

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

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

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

633 ) 

634 

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

636 

637 mutual_friends = ( 

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

639 ) 

640 

641 return api_pb2.ListMutualFriendsRes( 

642 mutual_friends=[ 

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

644 for mutual_friend in mutual_friends 

645 ] 

646 ) 

647 

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

649 if context.user_id == request.user_id: 

650 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cant_friend_self") 

651 

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

653 to_user = session.execute( 

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

655 ).scalar_one_or_none() 

656 

657 if not to_user: 

658 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

659 

660 if ( 

661 session.execute( 

662 select(FriendRelationship) 

663 .where( 

664 or_( 

665 and_( 

666 FriendRelationship.from_user_id == context.user_id, 

667 FriendRelationship.to_user_id == request.user_id, 

668 ), 

669 and_( 

670 FriendRelationship.from_user_id == request.user_id, 

671 FriendRelationship.to_user_id == context.user_id, 

672 ), 

673 ) 

674 ) 

675 .where( 

676 or_( 

677 FriendRelationship.status == FriendStatus.accepted, 

678 FriendRelationship.status == FriendStatus.pending, 

679 ) 

680 ) 

681 ).scalar_one_or_none() 

682 is not None 

683 ): 

684 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "friends_already_or_pending") 

685 

686 # Check if user has been sending friend requests excessively 

687 if process_rate_limits_and_check_abort( 

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

689 ): 

690 context.abort_with_error_code( 

691 grpc.StatusCode.RESOURCE_EXHAUSTED, 

692 "friend_request_rate_limit", 

693 substitutions={"hours": RATE_LIMIT_HOURS}, 

694 ) 

695 

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

697 

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

699 session.add(friend_relationship) 

700 session.flush() 

701 

702 notify( 

703 session, 

704 user_id=friend_relationship.to_user_id, 

705 topic_action="friend_request:create", 

706 key=friend_relationship.from_user_id, 

707 data=notification_data_pb2.FriendRequestCreate( 

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

709 ), 

710 ) 

711 

712 return empty_pb2.Empty() 

713 

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

715 # both sent and received 

716 sent_requests = ( 

717 session.execute( 

718 select(FriendRelationship) 

719 .where_users_column_visible(context, FriendRelationship.to_user_id) 

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

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

722 ) 

723 .scalars() 

724 .all() 

725 ) 

726 

727 received_requests = ( 

728 session.execute( 

729 select(FriendRelationship) 

730 .where_users_column_visible(context, FriendRelationship.from_user_id) 

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

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

733 ) 

734 .scalars() 

735 .all() 

736 ) 

737 

738 return api_pb2.ListFriendRequestsRes( 

739 sent=[ 

740 api_pb2.FriendRequest( 

741 friend_request_id=friend_request.id, 

742 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING, 

743 user_id=friend_request.to_user.id, 

744 sent=True, 

745 ) 

746 for friend_request in sent_requests 

747 ], 

748 received=[ 

749 api_pb2.FriendRequest( 

750 friend_request_id=friend_request.id, 

751 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING, 

752 user_id=friend_request.from_user.id, 

753 sent=False, 

754 ) 

755 for friend_request in received_requests 

756 ], 

757 ) 

758 

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

760 friend_request = session.execute( 

761 select(FriendRelationship) 

762 .where_users_column_visible(context, FriendRelationship.from_user_id) 

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

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

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

766 ).scalar_one_or_none() 

767 

768 if not friend_request: 

769 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found") 

770 

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

772 friend_request.time_responded = func.now() 

773 

774 session.flush() 

775 

776 if friend_request.status == FriendStatus.accepted: 

777 notify( 

778 session, 

779 user_id=friend_request.from_user_id, 

780 topic_action="friend_request:accept", 

781 key=friend_request.to_user_id, 

782 data=notification_data_pb2.FriendRequestAccept( 

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

784 ), 

785 ) 

786 

787 return empty_pb2.Empty() 

788 

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

790 friend_request = session.execute( 

791 select(FriendRelationship) 

792 .where_users_column_visible(context, FriendRelationship.to_user_id) 

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

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

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

796 ).scalar_one_or_none() 

797 

798 if not friend_request: 

799 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "friend_request_not_found") 

800 

801 friend_request.status = FriendStatus.cancelled 

802 friend_request.time_responded = func.now() 

803 

804 # note no notifications 

805 

806 session.commit() 

807 

808 return empty_pb2.Empty() 

809 

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

811 key = random_hex() 

812 

813 created = now() 

814 expiry = created + timedelta(minutes=20) 

815 

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

817 session.add(upload) 

818 session.commit() 

819 

820 req = media_pb2.UploadRequest( 

821 key=upload.key, 

822 type=media_pb2.UploadRequest.UploadType.IMAGE, 

823 created=Timestamp_from_datetime(upload.created), 

824 expiry=Timestamp_from_datetime(upload.expiry), 

825 max_width=2000, 

826 max_height=1600, 

827 ).SerializeToString() 

828 

829 data = b64encode(req) 

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

831 

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

833 

834 return api_pb2.InitiateMediaUploadRes( 

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

836 expiry=Timestamp_from_datetime(expiry), 

837 ) 

838 

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

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

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

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

843 if not badge: 

844 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "badge_not_found") 

845 

846 badge_user_ids = ( 

847 session.execute( 

848 select(UserBadge.user_id) 

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

850 .where(UserBadge.user_id >= next_user_id) 

851 .order_by(UserBadge.user_id) 

852 .limit(page_size + 1) 

853 ) 

854 .scalars() 

855 .all() 

856 ) 

857 

858 return api_pb2.ListBadgeUsersRes( 

859 user_ids=badge_user_ids[:page_size], 

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

861 ) 

862 

863 

864def response_rate_to_pb(response_rate: UserResponseRate): 

865 if not response_rate: 

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

867 

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

869 if response_rate.requests < 3: 

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

871 

872 if response_rate.response_rate <= 0.33: 

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

874 

875 response_time_p33_coarsened = Duration_from_timedelta( 

876 timedelta(seconds=round(response_rate.response_time_33p.total_seconds() / 60) * 60) 

877 ) 

878 

879 if response_rate.response_rate <= 0.66: 

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

881 

882 response_time_p66_coarsened = Duration_from_timedelta( 

883 timedelta(seconds=round(response_rate.response_time_66p.total_seconds() / 60) * 60) 

884 ) 

885 

886 if response_rate.response_rate <= 0.90: 

887 return { 

888 "most": requests_pb2.ResponseRateMost( 

889 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened 

890 ) 

891 } 

892 else: 

893 return { 

894 "almost_all": requests_pb2.ResponseRateAlmostAll( 

895 response_time_p33=response_time_p33_coarsened, response_time_p66=response_time_p66_coarsened 

896 ) 

897 } 

898 

899 

900def get_num_references(session, user_ids): 

901 return dict( 

902 session.execute( 

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

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

905 .where(Reference.is_deleted == False) 

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

907 .where(User.is_visible) 

908 .group_by(Reference.to_user_id) 

909 ).all() 

910 ) 

911 

912 

913def user_model_to_pb(db_user, session, context, *, is_admin_see_ghosts=False, is_get_user_return_ghosts=False): 

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

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

916 

917 if not is_admin_see_ghosts and is_not_visible(session, context.user_id, db_user.id): 

918 # User is not visible (deleted, banned, or blocked) 

919 if is_get_user_return_ghosts: 

920 # Return an anonymized "ghost" user profile 

921 return api_pb2.User( 

922 user_id=db_user.id, 

923 is_ghost=True, 

924 username=GHOST_USERNAME, 

925 name=context.get_localized_string("ghost_users", "display_name"), 

926 about_me=context.get_localized_string("ghost_users", "about_me"), 

927 ) 

928 raise GhostUserSerializationError( 

929 f"Tried to serialize ghost profile in user_model_to_pb without appropriate flags. " 

930 f"Context user_id: {context.user_id}, db_user id: {db_user.id} (username: {db_user.username})" 

931 ) 

932 

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

934 

935 # returns (lat, lng) 

936 # we put people without coords on null island 

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

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

939 

940 pending_friend_request = None 

941 if db_user.id == context.user_id: 

942 friends_status = api_pb2.User.FriendshipStatus.NA 

943 else: 

944 friend_relationship = session.execute( 

945 select(FriendRelationship) 

946 .where( 

947 or_( 

948 and_( 

949 FriendRelationship.from_user_id == context.user_id, 

950 FriendRelationship.to_user_id == db_user.id, 

951 ), 

952 and_( 

953 FriendRelationship.from_user_id == db_user.id, 

954 FriendRelationship.to_user_id == context.user_id, 

955 ), 

956 ) 

957 ) 

958 .where( 

959 or_( 

960 FriendRelationship.status == FriendStatus.accepted, 

961 FriendRelationship.status == FriendStatus.pending, 

962 ) 

963 ) 

964 ).scalar_one_or_none() 

965 

966 if friend_relationship: 

967 if friend_relationship.status == FriendStatus.accepted: 

968 friends_status = api_pb2.User.FriendshipStatus.FRIENDS 

969 else: 

970 friends_status = api_pb2.User.FriendshipStatus.PENDING 

971 if friend_relationship.from_user_id == context.user_id: 

972 # we sent it 

973 pending_friend_request = api_pb2.FriendRequest( 

974 friend_request_id=friend_relationship.id, 

975 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING, 

976 user_id=friend_relationship.to_user.id, 

977 sent=True, 

978 ) 

979 else: 

980 # we received it 

981 pending_friend_request = api_pb2.FriendRequest( 

982 friend_request_id=friend_relationship.id, 

983 state=api_pb2.FriendRequest.FriendRequestStatus.PENDING, 

984 user_id=friend_relationship.from_user.id, 

985 sent=False, 

986 ) 

987 else: 

988 friends_status = api_pb2.User.FriendshipStatus.NOT_FRIENDS 

989 

990 response_rate = session.execute( 

991 select(UserResponseRate).where(UserResponseRate.user_id == db_user.id) 

992 ).scalar_one_or_none() 

993 

994 verification_score = 0.0 

995 if db_user.phone_verification_verified: 

996 verification_score += 1.0 * db_user.phone_is_verified 

997 

998 user = api_pb2.User( 

999 user_id=db_user.id, 

1000 username=db_user.username, 

1001 name=db_user.name, 

1002 city=db_user.city, 

1003 hometown=db_user.hometown, 

1004 timezone=db_user.timezone, 

1005 lat=lat, 

1006 lng=lng, 

1007 radius=db_user.geom_radius, 

1008 verification=verification_score, 

1009 community_standing=db_user.community_standing, 

1010 num_references=num_references, 

1011 gender=db_user.gender, 

1012 pronouns=db_user.pronouns, 

1013 age=int(db_user.age), 

1014 joined=Timestamp_from_datetime(db_user.display_joined), 

1015 last_active=Timestamp_from_datetime(db_user.display_last_active), 

1016 hosting_status=hostingstatus2api[db_user.hosting_status], 

1017 meetup_status=meetupstatus2api[db_user.meetup_status], 

1018 occupation=db_user.occupation, 

1019 education=db_user.education, 

1020 about_me=db_user.about_me, 

1021 things_i_like=db_user.things_i_like, 

1022 about_place=db_user.about_place, 

1023 language_abilities=[ 

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

1025 for ability in db_user.language_abilities 

1026 ], 

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

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

1029 additional_information=db_user.additional_information, 

1030 friends=friends_status, 

1031 pending_friend_request=pending_friend_request, 

1032 smoking_allowed=smokinglocation2api[db_user.smoking_allowed], 

1033 sleeping_arrangement=sleepingarrangement2api[db_user.sleeping_arrangement], 

1034 parking_details=parkingdetails2api[db_user.parking_details], 

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

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

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

1038 .scalars() 

1039 .all(), 

1040 **get_strong_verification_fields(session, db_user), 

1041 **response_rate_to_pb(response_rate), 

1042 ) 

1043 

1044 if db_user.max_guests is not None: 

1045 user.max_guests.value = db_user.max_guests 

1046 

1047 if db_user.last_minute is not None: 

1048 user.last_minute.value = db_user.last_minute 

1049 

1050 if db_user.has_pets is not None: 

1051 user.has_pets.value = db_user.has_pets 

1052 

1053 if db_user.accepts_pets is not None: 

1054 user.accepts_pets.value = db_user.accepts_pets 

1055 

1056 if db_user.pet_details is not None: 

1057 user.pet_details.value = db_user.pet_details 

1058 

1059 if db_user.has_kids is not None: 

1060 user.has_kids.value = db_user.has_kids 

1061 

1062 if db_user.accepts_kids is not None: 

1063 user.accepts_kids.value = db_user.accepts_kids 

1064 

1065 if db_user.kid_details is not None: 

1066 user.kid_details.value = db_user.kid_details 

1067 

1068 if db_user.has_housemates is not None: 

1069 user.has_housemates.value = db_user.has_housemates 

1070 

1071 if db_user.housemate_details is not None: 

1072 user.housemate_details.value = db_user.housemate_details 

1073 

1074 if db_user.wheelchair_accessible is not None: 

1075 user.wheelchair_accessible.value = db_user.wheelchair_accessible 

1076 

1077 if db_user.smokes_at_home is not None: 

1078 user.smokes_at_home.value = db_user.smokes_at_home 

1079 

1080 if db_user.drinking_allowed is not None: 

1081 user.drinking_allowed.value = db_user.drinking_allowed 

1082 

1083 if db_user.drinks_at_home is not None: 

1084 user.drinks_at_home.value = db_user.drinks_at_home 

1085 

1086 if db_user.other_host_info is not None: 

1087 user.other_host_info.value = db_user.other_host_info 

1088 

1089 if db_user.sleeping_details is not None: 

1090 user.sleeping_details.value = db_user.sleeping_details 

1091 

1092 if db_user.area is not None: 

1093 user.area.value = db_user.area 

1094 

1095 if db_user.house_rules is not None: 

1096 user.house_rules.value = db_user.house_rules 

1097 

1098 if db_user.parking is not None: 

1099 user.parking.value = db_user.parking 

1100 

1101 if db_user.camping_ok is not None: 

1102 user.camping_ok.value = db_user.camping_ok 

1103 

1104 return user 

1105 

1106 

1107def lite_user_to_pb( 

1108 session, lite_user: LiteUser, context, *, is_admin_see_ghosts=False, is_get_user_return_ghosts=False 

1109): 

1110 if not is_admin_see_ghosts and is_not_visible(session, context.user_id, lite_user.id): 

1111 # User is not visible (deleted, banned, or blocked) 

1112 if is_get_user_return_ghosts: 

1113 # Return an anonymized "ghost" user profile 

1114 return api_pb2.LiteUser( 

1115 user_id=lite_user.id, 

1116 is_ghost=True, 

1117 username=GHOST_USERNAME, 

1118 name=context.get_localized_string("ghost_users", "display_name"), 

1119 ) 

1120 raise GhostUserSerializationError( 

1121 f"Tried to serialize ghost profile in lite_user_to_pb without appropriate flags. " 

1122 f"Context user_id: {context.user_id}, lite_user id: {lite_user.id} (username: {lite_user.username})" 

1123 ) 

1124 

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

1126 

1127 return api_pb2.LiteUser( 

1128 user_id=lite_user.id, 

1129 username=lite_user.username, 

1130 name=lite_user.name, 

1131 city=lite_user.city, 

1132 age=int(lite_user.age), 

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

1134 if lite_user.avatar_filename 

1135 else None, 

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

1137 if lite_user.avatar_filename 

1138 else None, 

1139 lat=lat, 

1140 lng=lng, 

1141 radius=lite_user.radius, 

1142 has_strong_verification=lite_user.has_strong_verification, 

1143 )