Coverage for app / backend / src / couchers / servicers / search.py: 83%

283 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +0000

1""" 

2See //docs/search.md for an overview. 

3""" 

4 

5from datetime import timedelta 

6from typing import Any, cast 

7 

8import grpc 

9from sqlalchemy import select 

10from sqlalchemy.orm import Session 

11from sqlalchemy.sql import and_, func, or_ 

12 

13from couchers import urls 

14from couchers.context import CouchersContext 

15from couchers.crypto import decrypt_page_token, encrypt_page_token 

16from couchers.event_log import log_event 

17from couchers.helpers.completed_profile import has_completed_profile_expression 

18from couchers.helpers.strong_verification import has_strong_verification 

19from couchers.materialized_views import LiteUser, UserResponseRate 

20from couchers.models import ( 

21 Cluster, 

22 ClusterSubscription, 

23 Event, 

24 EventOccurrence, 

25 EventOccurrenceAttendee, 

26 EventOrganizer, 

27 EventSubscription, 

28 LanguageAbility, 

29 Node, 

30 Page, 

31 PageType, 

32 PageVersion, 

33 Reference, 

34 StrongVerificationAttempt, 

35 User, 

36) 

37from couchers.proto import search_pb2, search_pb2_grpc 

38from couchers.reranker import reranker 

39from couchers.servicers.api import ( 

40 fluency2sql, 

41 get_num_references, 

42 hostingstatus2api, 

43 hostingstatus2sql, 

44 meetupstatus2api, 

45 meetupstatus2sql, 

46 parkingdetails2sql, 

47 response_rate_to_pb, 

48 sleepingarrangement2sql, 

49 smokinglocation2sql, 

50 user_model_to_pb, 

51) 

52from couchers.servicers.communities import community_to_pb 

53from couchers.servicers.events import event_to_pb 

54from couchers.servicers.groups import group_to_pb 

55from couchers.servicers.pages import page_to_pb 

56from couchers.sql import to_bool, users_visible, where_moderated_content_visible, where_users_column_visible 

57from couchers.utils import ( 

58 Timestamp_from_datetime, 

59 create_coordinate, 

60 dt_from_millis, 

61 get_coordinates, 

62 last_active_coarsen, 

63 millis_from_dt, 

64 now, 

65 to_aware_datetime, 

66) 

67 

68# searches are a bit expensive, we'd rather send back a bunch of results at once than lots of small pages 

69MAX_PAGINATION_LENGTH = 100 

70 

71REGCONFIG = "english" 

72TRI_SIMILARITY_THRESHOLD = 0.6 

73TRI_SIMILARITY_WEIGHT = 5 

74 

75 

76def _join_with_space(coalesces: list[Any]) -> Any: 

77 # the objects in coalesces are not strings, so we can't do " ".join(coalesces). They're SQLAlchemy magic. 

78 if not coalesces: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true

79 return "" 

80 out = coalesces[0] 

81 for coalesce in coalesces[1:]: 

82 out += " " + coalesce 

83 return out 

84 

85 

86def _build_tsv(A: list[Any], B: list[Any] | None = None, C: list[Any] | None = None, D: list[Any] | None = None) -> Any: 

87 """ 

88 Given lists for A, B, C, and D, builds a tsvector from them. 

89 """ 

90 B = B or [] 

91 C = C or [] 

92 D = D or [] 

93 tsv: Any = func.setweight(func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in A])), "A") 

94 if B: 94 ↛ 98line 94 didn't jump to line 98 because the condition on line 94 was always true

95 tsv = tsv.concat( 

96 func.setweight(func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in B])), "B") 

97 ) 

98 if C: 

99 tsv = tsv.concat( 

100 func.setweight(func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in C])), "C") 

101 ) 

102 if D: 102 ↛ 106line 102 didn't jump to line 106 because the condition on line 102 was always true

103 tsv = tsv.concat( 

104 func.setweight(func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in D])), "D") 

105 ) 

106 return tsv 

107 

108 

109def _build_doc(A: list[Any], B: list[Any] | None = None, C: list[Any] | None = None, D: list[Any] | None = None) -> Any: 

110 """ 

111 Builds the raw document (without to_tsvector and weighting), used for extracting snippet 

112 """ 

113 B = B or [] 

114 C = C or [] 

115 D = D or [] 

116 doc = _join_with_space([func.coalesce(bit, "") for bit in A]) 

117 if B: 

118 doc += " " + _join_with_space([func.coalesce(bit, "") for bit in B]) 

119 if C: 

120 doc += " " + _join_with_space([func.coalesce(bit, "") for bit in C]) 

121 if D: 

122 doc += " " + _join_with_space([func.coalesce(bit, "") for bit in D]) 

123 return doc 

124 

125 

126def _similarity(statement: Any, text: str) -> Any: 

127 return func.word_similarity(func.unaccent(statement), func.unaccent(text)) 

128 

129 

130def _gen_search_elements( 

131 statement: str, 

132 title_only: bool, 

133 next_rank: float | None, 

134 page_size: int, 

135 A: list[Any], 

136 B: list[Any] | None = None, 

137 C: list[Any] | None = None, 

138 D: list[Any] | None = None, 

139) -> tuple[Any, Any, Any]: 

140 """ 

141 Given an sql statement and four sets of fields, (A, B, C, D), generates a bunch of postgres expressions for full text search. 

142 

143 The four sets are in decreasing order of "importance" for ranking. 

144 

145 A should be the "title", the others can be anything. 

146 

147 If title_only=True, we only perform a trigram search against A only 

148 """ 

149 B = B or [] 

150 C = C or [] 

151 D = D or [] 

152 if not title_only: 

153 # a postgres tsquery object that can be used to match against a tsvector 

154 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

155 

156 # the tsvector object that we want to search against with our tsquery 

157 tsv = _build_tsv(A, B, C, D) 

158 

159 # document to generate snippet from 

160 doc = _build_doc(A, B, C, D) 

161 

162 title = _build_doc(A) 

163 

164 # trigram-based text similarity between title and sql statement string 

165 sim = _similarity(statement, title) 

166 

167 # ranking algo, weigh the similarity a lot, the text-based ranking less 

168 rank = (TRI_SIMILARITY_WEIGHT * sim + func.ts_rank_cd(tsv, tsq)).label("rank") 

169 

170 # the snippet with results highlighted 

171 snippet = func.ts_headline(REGCONFIG, doc, tsq, "StartSel=**,StopSel=**").label("snippet") 

172 

173 def execute_search_statement(session: Session, orig_statement: Any) -> list[Any]: 

174 """ 

175 Does the right search filtering, limiting, and ordering for the initial statement 

176 """ 

177 query = ( 

178 orig_statement.where(or_(tsv.op("@@")(tsq), sim > TRI_SIMILARITY_THRESHOLD)) 

179 .where(rank <= next_rank if next_rank is not None else True) 

180 .order_by(rank.desc()) 

181 .limit(page_size + 1) 

182 ) 

183 return cast(list[Any], session.execute(query).all()) 

184 

185 else: 

186 title = _build_doc(A) 

187 

188 # trigram-based text similarity between title and sql statement string 

189 sim = _similarity(statement, title) 

190 

191 # ranking algo, weigh the similarity a lot, the text-based ranking less 

192 rank = sim.label("rank") 

193 

194 # used only for headline 

195 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

196 doc = _build_doc(A, B, C, D) 

197 

198 # the snippet with results highlighted 

199 snippet = func.ts_headline(REGCONFIG, doc, tsq, "StartSel=**,StopSel=**").label("snippet") 

200 

201 def execute_search_statement(session: Session, orig_statement: Any) -> list[Any]: 

202 """ 

203 Does the right search filtering, limiting, and ordering for the initial statement 

204 """ 

205 query = ( 

206 orig_statement.where(sim > TRI_SIMILARITY_THRESHOLD) 

207 .where(rank <= next_rank if next_rank is not None else True) 

208 .order_by(rank.desc()) 

209 .limit(page_size + 1) 

210 ) 

211 return cast(list[Any], session.execute(query).all()) 

212 

213 return rank, snippet, execute_search_statement 

214 

215 

216def _search_users( 

217 session: Session, 

218 search_statement: str, 

219 title_only: bool, 

220 next_rank: float | None, 

221 page_size: int, 

222 context: CouchersContext, 

223 include_users: bool, 

224) -> list[search_pb2.Result]: 

225 if not include_users: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true

226 return [] 

227 rank, snippet, execute_search_statement = _gen_search_elements( 

228 search_statement, 

229 title_only, 

230 next_rank, 

231 page_size, 

232 [User.username, User.name], 

233 [User.city], 

234 [User.about_me], 

235 [User.things_i_like, User.about_place, User.additional_information], 

236 ) 

237 

238 users = execute_search_statement(session, select(User, rank, snippet).where(users_visible(context))) 

239 

240 return [ 

241 search_pb2.Result( 

242 rank=rank, 

243 user=user_model_to_pb(page, session, context), 

244 snippet=snippet, 

245 ) 

246 for page, rank, snippet in users 

247 ] 

248 

249 

250def _search_pages( 

251 session: Session, 

252 search_statement: str, 

253 title_only: bool, 

254 next_rank: float | None, 

255 page_size: int, 

256 context: CouchersContext, 

257 include_places: bool, 

258 include_guides: bool, 

259) -> list[search_pb2.Result]: 

260 rank, snippet, execute_search_statement = _gen_search_elements( 

261 search_statement, 

262 title_only, 

263 next_rank, 

264 page_size, 

265 [PageVersion.title], 

266 [PageVersion.address], 

267 [], 

268 [PageVersion.content], 

269 ) 

270 if not include_places and not include_guides: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 return [] 

272 

273 latest_pages = ( 

274 select(func.max(PageVersion.id).label("id")) 

275 .join(Page, Page.id == PageVersion.page_id) 

276 .where( 

277 or_( 

278 (Page.type == PageType.place) if include_places else to_bool(False), 

279 (Page.type == PageType.guide) if include_guides else to_bool(False), 

280 ) 

281 ) 

282 .group_by(PageVersion.page_id) 

283 .subquery() 

284 ) 

285 

286 pages = execute_search_statement( 

287 session, 

288 select(Page, rank, snippet) 

289 .join(PageVersion, PageVersion.page_id == Page.id) 

290 .join(latest_pages, latest_pages.c.id == PageVersion.id), 

291 ) 

292 

293 return [ 

294 search_pb2.Result( 

295 rank=rank, 

296 place=page_to_pb(session, page, context) if page.type == PageType.place else None, 

297 guide=page_to_pb(session, page, context) if page.type == PageType.guide else None, 

298 snippet=snippet, 

299 ) 

300 for page, rank, snippet in pages 

301 ] 

302 

303 

304def _search_events( 

305 session: Session, 

306 search_statement: str, 

307 title_only: bool, 

308 next_rank: float | None, 

309 page_size: int, 

310 context: CouchersContext, 

311) -> list[search_pb2.Result]: 

312 rank, snippet, execute_search_statement = _gen_search_elements( 

313 search_statement, 

314 title_only, 

315 next_rank, 

316 page_size, 

317 [Event.title], 

318 [EventOccurrence.address, EventOccurrence.link], 

319 [], 

320 [EventOccurrence.content], 

321 ) 

322 

323 occurrences = execute_search_statement( 

324 session, 

325 where_moderated_content_visible( 

326 select(EventOccurrence, rank, snippet) 

327 .join(Event, Event.id == EventOccurrence.event_id) 

328 .where(EventOccurrence.end_time >= func.now()), 

329 context, 

330 EventOccurrence, 

331 is_list_operation=True, 

332 ), 

333 ) 

334 

335 return [ 

336 search_pb2.Result( 

337 rank=rank, 

338 event=event_to_pb(session, occurrence, context), 

339 snippet=snippet, 

340 ) 

341 for occurrence, rank, snippet in occurrences 

342 ] 

343 

344 

345def _search_clusters( 

346 session: Session, 

347 search_statement: str, 

348 title_only: bool, 

349 next_rank: float | None, 

350 page_size: int, 

351 context: CouchersContext, 

352 include_communities: bool, 

353 include_groups: bool, 

354) -> list[search_pb2.Result]: 

355 if not include_communities and not include_groups: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 return [] 

357 

358 rank, snippet, execute_search_statement = _gen_search_elements( 

359 search_statement, 

360 title_only, 

361 next_rank, 

362 page_size, 

363 [Cluster.name], 

364 [PageVersion.address, PageVersion.title], 

365 [Cluster.description], 

366 [PageVersion.content], 

367 ) 

368 

369 latest_pages = ( 

370 select(func.max(PageVersion.id).label("id")) 

371 .join(Page, Page.id == PageVersion.page_id) 

372 .where(Page.type == PageType.main_page) 

373 .group_by(PageVersion.page_id) 

374 .subquery() 

375 ) 

376 

377 clusters = execute_search_statement( 

378 session, 

379 select(Cluster, rank, snippet) 

380 .join(Page, Page.owner_cluster_id == Cluster.id) 

381 .join(PageVersion, PageVersion.page_id == Page.id) 

382 .join(latest_pages, latest_pages.c.id == PageVersion.id) 

383 .where(Cluster.is_official_cluster if include_communities and not include_groups else to_bool(True)) 

384 .where(~Cluster.is_official_cluster if not include_communities and include_groups else to_bool(True)), 

385 ) 

386 

387 return [ 

388 search_pb2.Result( 

389 rank=rank, 

390 community=( 

391 community_to_pb(session, cluster.official_cluster_for_node, context) 

392 if cluster.is_official_cluster 

393 else None 

394 ), 

395 group=group_to_pb(session, cluster, context) if not cluster.is_official_cluster else None, 

396 snippet=snippet, 

397 ) 

398 for cluster, rank, snippet in clusters 

399 ] 

400 

401 

402def _user_search_inner( 

403 request: search_pb2.UserSearchReq, context: CouchersContext, session: Session 

404) -> tuple[list[int], str | None, int]: 

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

406 

407 # Base statement with visibility filter 

408 statement = select(User.id, User.recommendation_score).where(users_visible(context)) 

409 # make sure that only users who are in LiteUser show up 

410 statement = statement.join(LiteUser, LiteUser.id == User.id) 

411 

412 # If exactly_user_ids is present, only filter by those IDs and ignore all other filters 

413 # This is a bit of a hacky feature to help with the frontend map implementation 

414 if len(request.exactly_user_ids) > 0: 

415 statement = statement.where(User.id.in_(request.exactly_user_ids)) 

416 else: 

417 # Apply all the normal filters 

418 if request.HasField("query"): 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true

419 if request.query_name_only: 

420 statement = statement.where( 

421 or_(User.name.ilike(f"%{request.query.value}%"), User.username.ilike(f"%{request.query.value}%")) 

422 ) 

423 else: 

424 statement = statement.where( 

425 or_( 

426 User.name.ilike(f"%{request.query.value}%"), 

427 User.username.ilike(f"%{request.query.value}%"), 

428 User.city.ilike(f"%{request.query.value}%"), 

429 User.hometown.ilike(f"%{request.query.value}%"), 

430 User.about_me.ilike(f"%{request.query.value}%"), 

431 User.things_i_like.ilike(f"%{request.query.value}%"), 

432 User.about_place.ilike(f"%{request.query.value}%"), 

433 User.additional_information.ilike(f"%{request.query.value}%"), 

434 ) 

435 ) 

436 

437 if request.HasField("last_active"): 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true

438 raw_dt = to_aware_datetime(request.last_active) 

439 statement = statement.where(User.last_active >= last_active_coarsen(raw_dt)) 

440 

441 if request.same_gender_only: 

442 if not has_strong_verification(session, user): 

443 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "need_strong_verification") 

444 statement = statement.where(User.gender == user.gender) 

445 

446 if len(request.hosting_status_filter) > 0: 

447 statement = statement.where( 

448 User.hosting_status.in_([hostingstatus2sql[status] for status in request.hosting_status_filter]) 

449 ) 

450 if len(request.meetup_status_filter) > 0: 

451 statement = statement.where( 

452 User.meetup_status.in_([meetupstatus2sql[status] for status in request.meetup_status_filter]) 

453 ) 

454 if len(request.smoking_location_filter) > 0: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true

455 statement = statement.where( 

456 User.smoking_allowed.in_([smokinglocation2sql[loc] for loc in request.smoking_location_filter]) 

457 ) 

458 if len(request.sleeping_arrangement_filter) > 0: 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true

459 statement = statement.where( 

460 User.sleeping_arrangement.in_( 

461 [sleepingarrangement2sql[arr] for arr in request.sleeping_arrangement_filter] 

462 ) 

463 ) 

464 if len(request.parking_details_filter) > 0: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true

465 statement = statement.where( 

466 User.parking_details.in_([parkingdetails2sql[det] for det in request.parking_details_filter]) 

467 ) 

468 # limits/default could be handled on the front end as well 

469 min_age = request.age_min.value if request.HasField("age_min") else 18 

470 max_age = request.age_max.value if request.HasField("age_max") else 200 

471 

472 statement = statement.where((User.age >= min_age) & (User.age <= max_age)) 

473 

474 # return results with by language code as only input 

475 # fluency in conversational or fluent 

476 

477 if len(request.language_ability_filter) > 0: 

478 language_options = [] 

479 for ability_filter in request.language_ability_filter: 

480 fluency_sql_value = fluency2sql.get(ability_filter.fluency) 

481 

482 if fluency_sql_value is None: 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true

483 continue 

484 language_options.append( 

485 and_( 

486 (LanguageAbility.language_code == ability_filter.code), 

487 (LanguageAbility.fluency >= (fluency_sql_value)), 

488 ) 

489 ) 

490 statement = statement.join(LanguageAbility, LanguageAbility.user_id == User.id) 

491 statement = statement.where(or_(*language_options)) 

492 

493 if request.HasField("profile_completed"): 

494 statement = statement.where(has_completed_profile_expression() == request.profile_completed.value) 

495 if request.HasField("guests"): 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true

496 statement = statement.where(User.max_guests >= request.guests.value) 

497 if request.HasField("last_minute"): 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 statement = statement.where(User.last_minute == request.last_minute.value) 

499 if request.HasField("has_pets"): 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true

500 statement = statement.where(User.has_pets == request.has_pets.value) 

501 if request.HasField("accepts_pets"): 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true

502 statement = statement.where(User.accepts_pets == request.accepts_pets.value) 

503 if request.HasField("has_kids"): 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true

504 statement = statement.where(User.has_kids == request.has_kids.value) 

505 if request.HasField("accepts_kids"): 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true

506 statement = statement.where(User.accepts_kids == request.accepts_kids.value) 

507 if request.HasField("has_housemates"): 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true

508 statement = statement.where(User.has_housemates == request.has_housemates.value) 

509 if request.HasField("wheelchair_accessible"): 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true

510 statement = statement.where(User.wheelchair_accessible == request.wheelchair_accessible.value) 

511 if request.HasField("smokes_at_home"): 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true

512 statement = statement.where(User.smokes_at_home == request.smokes_at_home.value) 

513 if request.HasField("drinking_allowed"): 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true

514 statement = statement.where(User.drinking_allowed == request.drinking_allowed.value) 

515 if request.HasField("drinks_at_home"): 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true

516 statement = statement.where(User.drinks_at_home == request.drinks_at_home.value) 

517 if request.HasField("parking"): 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true

518 statement = statement.where(User.parking == request.parking.value) 

519 if request.HasField("camping_ok"): 519 ↛ 520line 519 didn't jump to line 520 because the condition on line 519 was never true

520 statement = statement.where(User.camping_ok == request.camping_ok.value) 

521 

522 if request.HasField("search_in_area"): 

523 # EPSG4326 measures distance in decimal degress 

524 # we want to check whether two circles overlap, so check if the distance between their centers is less 

525 # than the sum of their radii, divided by 111111 m ~= 1 degree (at the equator) 

526 search_point = create_coordinate(request.search_in_area.lat, request.search_in_area.lng) 

527 statement = statement.where( 

528 func.ST_DWithin( 

529 # old: 

530 # User.geom, search_point, (User.geom_radius + request.search_in_area.radius) / 111111 

531 # this is an optimization that speeds up the db queries since it doesn't need to look up the 

532 # user's geom radius 

533 User.geom, 

534 search_point, 

535 (1000 + request.search_in_area.radius) / 111111, 

536 ) 

537 ) 

538 if request.HasField("search_in_rectangle"): 

539 statement = statement.where( 

540 func.ST_Within( 

541 User.geom, 

542 func.ST_MakeEnvelope( 

543 request.search_in_rectangle.lng_min, 

544 request.search_in_rectangle.lat_min, 

545 request.search_in_rectangle.lng_max, 

546 request.search_in_rectangle.lat_max, 

547 4326, 

548 ), 

549 ) 

550 ) 

551 if request.HasField("search_in_community_id"): 551 ↛ 553line 551 didn't jump to line 553 because the condition on line 551 was never true

552 # could do a join here as well, but this is just simpler 

553 node = session.execute(select(Node).where(Node.id == request.search_in_community_id)).scalar_one_or_none() 

554 if not node: 

555 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

556 statement = statement.where(func.ST_Contains(node.geom, User.geom)) 

557 

558 if request.only_with_references: 

559 references = ( 

560 where_users_column_visible( 

561 select(Reference.to_user_id.label("user_id")), 

562 context, 

563 Reference.from_user_id, 

564 ) 

565 .distinct() 

566 .subquery() 

567 ) 

568 statement = statement.join(references, references.c.user_id == User.id) 

569 

570 if request.only_with_strong_verification: 

571 statement = statement.join( 

572 StrongVerificationAttempt, 

573 and_( 

574 StrongVerificationAttempt.user_id == User.id, 

575 StrongVerificationAttempt.has_strong_verification(User), 

576 ), 

577 ) 

578 # TODO: 

579 # bool friends_only = 13; 

580 

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

582 next_recommendation_score = float(decrypt_page_token(request.page_token)) if request.page_token else 1e10 

583 total_items = cast(int, session.execute(select(func.count()).select_from(statement.subquery())).scalar()) 

584 

585 statement = ( 

586 statement.where(User.recommendation_score <= next_recommendation_score) 

587 .order_by(User.recommendation_score.desc()) 

588 .limit(page_size + 1) 

589 ) 

590 res = session.execute(statement).all() 

591 users: list[int] = [] 

592 if res: 

593 users, rec_scores = zip(*res) # type: ignore[assignment] 

594 

595 next_page_token = encrypt_page_token(str(rec_scores[-1])) if len(users) > page_size else None 

596 return users[:page_size], next_page_token, total_items 

597 

598 

599class Search(search_pb2_grpc.SearchServicer): 

600 def Search(self, request: search_pb2.SearchReq, context: CouchersContext, session: Session) -> search_pb2.SearchRes: 

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

602 # this is not an ideal page token, some results have equal rank (unlikely) 

603 next_rank = float(request.page_token) if request.page_token else None 

604 

605 all_results = ( 

606 _search_users( 

607 session, 

608 request.query, 

609 request.title_only, 

610 next_rank, 

611 page_size, 

612 context, 

613 request.include_users, 

614 ) 

615 + _search_pages( 

616 session, 

617 request.query, 

618 request.title_only, 

619 next_rank, 

620 page_size, 

621 context, 

622 request.include_places, 

623 request.include_guides, 

624 ) 

625 + _search_events( 

626 session, 

627 request.query, 

628 request.title_only, 

629 next_rank, 

630 page_size, 

631 context, 

632 ) 

633 + _search_clusters( 

634 session, 

635 request.query, 

636 request.title_only, 

637 next_rank, 

638 page_size, 

639 context, 

640 request.include_communities, 

641 request.include_groups, 

642 ) 

643 ) 

644 all_results.sort(key=lambda result: result.rank, reverse=True) 

645 return search_pb2.SearchRes( 

646 results=all_results[:page_size], 

647 next_page_token=str(all_results[page_size].rank) if len(all_results) > page_size else None, 

648 ) 

649 

650 def UserSearch( 

651 self, request: search_pb2.UserSearchReq, context: CouchersContext, session: Session 

652 ) -> search_pb2.UserSearchRes: 

653 user_ids_to_return, next_page_token, total_items = _user_search_inner(request, context, session) 

654 

655 log_event( 

656 context, 

657 session, 

658 "search.performed", 

659 { 

660 "search_in": request.WhichOneof("search_in"), 

661 "has_query": request.HasField("query"), 

662 "has_filters": ( 

663 len(request.hosting_status_filter) > 0 

664 or len(request.meetup_status_filter) > 0 

665 or len(request.smoking_location_filter) > 0 

666 or len(request.sleeping_arrangement_filter) > 0 

667 or len(request.parking_details_filter) > 0 

668 or len(request.language_ability_filter) > 0 

669 or request.only_with_references 

670 or request.only_with_strong_verification 

671 ), 

672 "total_items": total_items, 

673 }, 

674 ) 

675 

676 user_ids_to_users: dict[int, User] = dict( 

677 session.execute( # type: ignore[arg-type] 

678 select(User.id, User).where(User.id.in_(user_ids_to_return)) 

679 ).all() 

680 ) 

681 

682 return search_pb2.UserSearchRes( 

683 results=[ 

684 search_pb2.Result( 

685 rank=1, 

686 user=user_model_to_pb(user_ids_to_users[user_id], session, context), 

687 ) 

688 for user_id in user_ids_to_return 

689 ], 

690 next_page_token=next_page_token, 

691 total_items=total_items, 

692 ) 

693 

694 def UserSearchV2( 

695 self, request: search_pb2.UserSearchReq, context: CouchersContext, session: Session 

696 ) -> search_pb2.UserSearchV2Res: 

697 user_ids_to_return, next_page_token, total_items = _user_search_inner(request, context, session) 

698 

699 LiteUser_by_id = { 

700 lite_user.id: lite_user 

701 for lite_user in session.execute(select(LiteUser).where(LiteUser.id.in_(user_ids_to_return))) 

702 .scalars() 

703 .all() 

704 } 

705 

706 response_rate_by_id = { 

707 resp_rate.user_id: resp_rate 

708 for resp_rate in session.execute( 

709 select(UserResponseRate).where(UserResponseRate.user_id.in_(user_ids_to_return)) 

710 ) 

711 .scalars() 

712 .all() 

713 } 

714 

715 db_user_data_by_id = { 

716 user_id: (about_me, gender, last_active, hosting_status, meetup_status, joined) 

717 for user_id, about_me, gender, last_active, hosting_status, meetup_status, joined in session.execute( 

718 select( 

719 User.id, 

720 User.about_me, 

721 User.gender, 

722 User.last_active, 

723 User.hosting_status, 

724 User.meetup_status, 

725 User.joined, 

726 ).where(User.id.in_(user_ids_to_return)) 

727 ).all() 

728 } 

729 

730 ref_counts_by_user_id = get_num_references(session, user_ids_to_return) 

731 

732 def _user_to_search_user(user_id: int) -> search_pb2.SearchUser: 

733 lite_user = LiteUser_by_id[user_id] 

734 

735 about_me, gender, last_active, hosting_status, meetup_status, joined = db_user_data_by_id[user_id] 

736 

737 lat, lng = get_coordinates(lite_user.geom) 

738 return search_pb2.SearchUser( 

739 user_id=lite_user.id, 

740 username=lite_user.username, 

741 name=lite_user.name, 

742 city=lite_user.city, 

743 joined=Timestamp_from_datetime(last_active_coarsen(joined)), 

744 has_completed_profile=lite_user.has_completed_profile, 

745 has_completed_my_home=lite_user.has_completed_my_home, 

746 lat=lat, 

747 lng=lng, 

748 profile_snippet=about_me, 

749 num_references=ref_counts_by_user_id.get(lite_user.id, 0), 

750 gender=gender, 

751 age=int(lite_user.age), 

752 last_active=Timestamp_from_datetime(last_active_coarsen(last_active)), 

753 hosting_status=hostingstatus2api[hosting_status], 

754 meetup_status=meetupstatus2api[meetup_status], 

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

756 if lite_user.avatar_filename 

757 else None, 

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

759 if lite_user.avatar_filename 

760 else None, 

761 has_strong_verification=lite_user.has_strong_verification, 

762 **response_rate_to_pb(response_rate_by_id.get(user_id)), 

763 ) 

764 

765 results = reranker([_user_to_search_user(user_id) for user_id in user_ids_to_return]) 

766 

767 return search_pb2.UserSearchV2Res( 

768 results=results, 

769 next_page_token=next_page_token, 

770 total_items=total_items, 

771 ) 

772 

773 def EventSearch( 

774 self, request: search_pb2.EventSearchReq, context: CouchersContext, session: Session 

775 ) -> search_pb2.EventSearchRes: 

776 statement = ( 

777 select(EventOccurrence).join(Event, Event.id == EventOccurrence.event_id).where(~EventOccurrence.is_deleted) 

778 ) 

779 statement = where_moderated_content_visible(statement, context, EventOccurrence, is_list_operation=True) 

780 

781 if request.HasField("query"): 

782 if request.query_title_only: 

783 statement = statement.where(Event.title.ilike(f"%{request.query.value}%")) 

784 else: 

785 statement = statement.where( 

786 or_( 

787 Event.title.ilike(f"%{request.query.value}%"), 

788 EventOccurrence.content.ilike(f"%{request.query.value}%"), 

789 EventOccurrence.address.ilike(f"%{request.query.value}%"), 

790 ) 

791 ) 

792 

793 if request.only_online: 

794 statement = statement.where(EventOccurrence.geom == None) 

795 elif request.only_offline: 

796 statement = statement.where(EventOccurrence.geom != None) 

797 

798 if request.subscribed or request.attending or request.organizing or request.my_communities: 

799 where_ = [] 

800 

801 if request.subscribed: 

802 statement = statement.outerjoin( 

803 EventSubscription, 

804 and_(EventSubscription.event_id == Event.id, EventSubscription.user_id == context.user_id), 

805 ) 

806 where_.append(EventSubscription.user_id != None) 

807 if request.organizing: 

808 statement = statement.outerjoin( 

809 EventOrganizer, 

810 and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id), 

811 ) 

812 where_.append(EventOrganizer.user_id != None) 

813 if request.attending: 

814 statement = statement.outerjoin( 

815 EventOccurrenceAttendee, 

816 and_( 

817 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

818 EventOccurrenceAttendee.user_id == context.user_id, 

819 ), 

820 ) 

821 where_.append(EventOccurrenceAttendee.user_id != None) 

822 if request.my_communities: 

823 my_communities = ( 

824 session.execute( 

825 select(Node.id) 

826 .join(Cluster, Cluster.parent_node_id == Node.id) 

827 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id) 

828 .where(ClusterSubscription.user_id == context.user_id) 

829 .where(Cluster.is_official_cluster) 

830 .order_by(Node.id) 

831 .limit(100000) 

832 ) 

833 .scalars() 

834 .all() 

835 ) 

836 where_.append(Event.parent_node_id.in_(my_communities)) 

837 

838 statement = statement.where(or_(*where_)) 

839 

840 if not request.include_cancelled: 840 ↛ 843line 840 didn't jump to line 843 because the condition on line 840 was always true

841 statement = statement.where(~EventOccurrence.is_cancelled) 

842 

843 if request.HasField("search_in_area"): 

844 # EPSG4326 measures distance in decimal degress 

845 # we want to check whether two circles overlap, so check if the distance between their centers is less 

846 # than the sum of their radii, divided by 111111 m ~= 1 degree (at the equator) 

847 search_point = create_coordinate(request.search_in_area.lat, request.search_in_area.lng) 

848 statement = statement.where( 

849 func.ST_DWithin( 

850 # old: 

851 # User.geom, search_point, (User.geom_radius + request.search_in_area.radius) / 111111 

852 # this is an optimization that speeds up the db queries since it doesn't need to look up the user's geom radius 

853 EventOccurrence.geom, 

854 search_point, 

855 (1000 + request.search_in_area.radius) / 111111, 

856 ) 

857 ) 

858 if request.HasField("search_in_rectangle"): 

859 statement = statement.where( 

860 func.ST_Within( 

861 EventOccurrence.geom, 

862 func.ST_MakeEnvelope( 

863 request.search_in_rectangle.lng_min, 

864 request.search_in_rectangle.lat_min, 

865 request.search_in_rectangle.lng_max, 

866 request.search_in_rectangle.lat_max, 

867 4326, 

868 ), 

869 ) 

870 ) 

871 if request.HasField("search_in_community_id"): 871 ↛ 873line 871 didn't jump to line 873 because the condition on line 871 was never true

872 # could do a join here as well, but this is just simpler 

873 node = session.execute(select(Node).where(Node.id == request.search_in_community_id)).scalar_one_or_none() 

874 if not node: 

875 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

876 statement = statement.where(func.ST_Contains(node.geom, EventOccurrence.geom)) 

877 

878 if request.HasField("after"): 

879 after_time = to_aware_datetime(request.after) 

880 statement = statement.where(EventOccurrence.start_time > after_time) 

881 if request.HasField("before"): 

882 before_time = to_aware_datetime(request.before) 

883 statement = statement.where(EventOccurrence.end_time < before_time) 

884 

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

886 # the page token is a unix timestamp of where we left off 

887 page_token = ( 

888 dt_from_millis(int(request.page_token)) if request.page_token and not request.page_number else now() 

889 ) 

890 page_number = request.page_number or 1 

891 # Calculate the offset for pagination 

892 offset = (page_number - 1) * page_size 

893 

894 if not request.past: 

895 cutoff = page_token - timedelta(seconds=1) 

896 statement = statement.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc()) 

897 else: 

898 cutoff = page_token + timedelta(seconds=1) 

899 statement = statement.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc()) 

900 

901 total_items = session.execute(select(func.count()).select_from(statement.subquery())).scalar() 

902 # Apply pagination by page number 

903 statement = statement.offset(offset).limit(page_size) if request.page_number else statement.limit(page_size + 1) 

904 occurrences = session.execute(statement).scalars().all() 

905 

906 return search_pb2.EventSearchRes( 

907 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]], 

908 next_page_token=(str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None), 

909 total_items=total_items, 

910 )