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

283 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1""" 

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

3""" 

4 

5from typing import Any, cast 

6 

7import grpc 

8from sqlalchemy import literal_column, select 

9from sqlalchemy.orm import Session 

10from sqlalchemy.sql import and_, func, or_ 

11 

12from couchers import urls 

13from couchers.context import CouchersContext 

14from couchers.crypto import decrypt_page_token, encrypt_page_token 

15from couchers.event_log import log_event 

16from couchers.helpers.completed_profile import has_completed_profile_expression 

17from couchers.helpers.strong_verification import has_strong_verification 

18from couchers.materialized_views import LiteUser, UserResponseRate 

19from couchers.models import ( 

20 Cluster, 

21 ClusterSubscription, 

22 Event, 

23 EventOccurrence, 

24 EventOccurrenceAttendee, 

25 EventOrganizer, 

26 EventSubscription, 

27 LanguageAbility, 

28 Node, 

29 Page, 

30 PageType, 

31 PageVersion, 

32 Reference, 

33 StrongVerificationAttempt, 

34 User, 

35) 

36from couchers.proto import search_pb2, search_pb2_grpc 

37from couchers.reranker import reranker 

38from couchers.servicers.api import ( 

39 fluency2sql, 

40 get_num_references, 

41 hostingstatus2api, 

42 hostingstatus2sql, 

43 meetupstatus2api, 

44 meetupstatus2sql, 

45 parkingdetails2sql, 

46 response_rate_to_pb, 

47 sleepingarrangement2sql, 

48 smokinglocation2sql, 

49 user_model_to_pb, 

50) 

51from couchers.servicers.communities import community_to_pb 

52from couchers.servicers.events import apply_occurrence_pagination, event_to_pb, occurrences_next_page_token 

53from couchers.servicers.groups import group_to_pb 

54from couchers.servicers.pages import page_to_pb 

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

56from couchers.utils import ( 

57 Timestamp_from_datetime, 

58 create_coordinate, 

59 get_coordinates, 

60 last_active_coarsen, 

61 to_aware_datetime, 

62) 

63 

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

65MAX_PAGINATION_LENGTH = 100 

66 

67REGCONFIG = "english" 

68TRI_SIMILARITY_THRESHOLD = 0.6 

69TRI_SIMILARITY_WEIGHT = 5 

70 

71 

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

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

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

75 return "" 

76 out = coalesces[0] 

77 for coalesce in coalesces[1:]: 

78 out += " " + coalesce 

79 return out 

80 

81 

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

83 """ 

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

85 """ 

86 B = B or [] 

87 C = C or [] 

88 D = D or [] 

89 # Use literal_column for weight letters to avoid psycopg3 type binding issues 

90 # PostgreSQL's setweight expects "char" type (internal single-byte type) 

91 tsv: Any = func.setweight( 

92 func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in A])), 

93 literal_column("'A'"), 

94 ) 

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

96 tsv = tsv.concat( 

97 func.setweight( 

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

99 literal_column("'B'"), 

100 ) 

101 ) 

102 if C: 

103 tsv = tsv.concat( 

104 func.setweight( 

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

106 literal_column("'C'"), 

107 ) 

108 ) 

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

110 tsv = tsv.concat( 

111 func.setweight( 

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

113 literal_column("'D'"), 

114 ) 

115 ) 

116 return tsv 

117 

118 

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

120 """ 

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

122 """ 

123 B = B or [] 

124 C = C or [] 

125 D = D or [] 

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

127 if B: 

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

129 if C: 

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

131 if D: 

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

133 return doc 

134 

135 

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

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

138 

139 

140def _gen_search_elements( 

141 statement: str, 

142 title_only: bool, 

143 next_rank: float | None, 

144 page_size: int, 

145 A: list[Any], 

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

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

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

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

150 """ 

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

152 

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

154 

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

156 

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

158 """ 

159 B = B or [] 

160 C = C or [] 

161 D = D or [] 

162 if not title_only: 

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

164 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

165 

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

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

168 

169 # document to generate snippet from 

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

171 

172 title = _build_doc(A) 

173 

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

175 sim = _similarity(statement, title) 

176 

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

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

179 

180 # the snippet with results highlighted 

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

182 

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

184 """ 

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

186 """ 

187 query = ( 

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

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

190 .order_by(rank.desc()) 

191 .limit(page_size + 1) 

192 ) 

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

194 

195 else: 

196 title = _build_doc(A) 

197 

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

199 sim = _similarity(statement, title) 

200 

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

202 rank = sim.label("rank") 

203 

204 # used only for headline 

205 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

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

207 

208 # the snippet with results highlighted 

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

210 

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

212 """ 

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

214 """ 

215 query = ( 

216 orig_statement.where(sim > TRI_SIMILARITY_THRESHOLD) 

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

218 .order_by(rank.desc()) 

219 .limit(page_size + 1) 

220 ) 

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

222 

223 return rank, snippet, execute_search_statement 

224 

225 

226def _search_users( 

227 session: Session, 

228 search_statement: str, 

229 title_only: bool, 

230 next_rank: float | None, 

231 page_size: int, 

232 context: CouchersContext, 

233 include_users: bool, 

234) -> list[search_pb2.Result]: 

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

236 return [] 

237 rank, snippet, execute_search_statement = _gen_search_elements( 

238 search_statement, 

239 title_only, 

240 next_rank, 

241 page_size, 

242 [User.username, User.name], 

243 [User.city], 

244 [User.about_me], 

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

246 ) 

247 

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

249 

250 return [ 

251 search_pb2.Result( 

252 rank=rank, 

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

254 snippet=snippet, 

255 ) 

256 for page, rank, snippet in users 

257 ] 

258 

259 

260def _search_pages( 

261 session: Session, 

262 search_statement: str, 

263 title_only: bool, 

264 next_rank: float | None, 

265 page_size: int, 

266 context: CouchersContext, 

267 include_places: bool, 

268 include_guides: bool, 

269) -> list[search_pb2.Result]: 

270 rank, snippet, execute_search_statement = _gen_search_elements( 

271 search_statement, 

272 title_only, 

273 next_rank, 

274 page_size, 

275 [PageVersion.title], 

276 [PageVersion.address], 

277 [], 

278 [PageVersion.content], 

279 ) 

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

281 return [] 

282 

283 latest_pages = ( 

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

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

286 .where( 

287 or_( 

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

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

290 ) 

291 ) 

292 .group_by(PageVersion.page_id) 

293 .subquery() 

294 ) 

295 

296 pages = execute_search_statement( 

297 session, 

298 select(Page, rank, snippet) 

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

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

301 ) 

302 

303 return [ 

304 search_pb2.Result( 

305 rank=rank, 

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

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

308 snippet=snippet, 

309 ) 

310 for page, rank, snippet in pages 

311 ] 

312 

313 

314def _search_events( 

315 session: Session, 

316 search_statement: str, 

317 title_only: bool, 

318 next_rank: float | None, 

319 page_size: int, 

320 context: CouchersContext, 

321) -> list[search_pb2.Result]: 

322 rank, snippet, execute_search_statement = _gen_search_elements( 

323 search_statement, 

324 title_only, 

325 next_rank, 

326 page_size, 

327 [Event.title], 

328 [EventOccurrence.address], 

329 [], 

330 [EventOccurrence.content], 

331 ) 

332 

333 occurrences = execute_search_statement( 

334 session, 

335 where_moderated_content_visible( 

336 select(EventOccurrence, rank, snippet) 

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

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

339 context, 

340 EventOccurrence, 

341 is_list_operation=True, 

342 ), 

343 ) 

344 

345 return [ 

346 search_pb2.Result( 

347 rank=rank, 

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

349 snippet=snippet, 

350 ) 

351 for occurrence, rank, snippet in occurrences 

352 ] 

353 

354 

355def _search_clusters( 

356 session: Session, 

357 search_statement: str, 

358 title_only: bool, 

359 next_rank: float | None, 

360 page_size: int, 

361 context: CouchersContext, 

362 include_communities: bool, 

363 include_groups: bool, 

364) -> list[search_pb2.Result]: 

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

366 return [] 

367 

368 rank, snippet, execute_search_statement = _gen_search_elements( 

369 search_statement, 

370 title_only, 

371 next_rank, 

372 page_size, 

373 [Cluster.name], 

374 [PageVersion.address, PageVersion.title], 

375 [Cluster.description], 

376 [PageVersion.content], 

377 ) 

378 

379 latest_pages = ( 

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

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

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

383 .group_by(PageVersion.page_id) 

384 .subquery() 

385 ) 

386 

387 clusters = execute_search_statement( 

388 session, 

389 select(Cluster, rank, snippet) 

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

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

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

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

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

395 ) 

396 

397 return [ 

398 search_pb2.Result( 

399 rank=rank, 

400 community=( 

401 community_to_pb(session, cluster.official_cluster_for_node, context) 

402 if cluster.is_official_cluster 

403 else None 

404 ), 

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

406 snippet=snippet, 

407 ) 

408 for cluster, rank, snippet in clusters 

409 ] 

410 

411 

412def _user_search_inner( 

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

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

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

416 

417 # Base statement with visibility filter 

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

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

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

421 

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

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

424 if len(request.exactly_user_ids) > 0: 

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

426 else: 

427 # Apply all the normal filters 

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

429 if request.query_name_only: 

430 statement = statement.where( 

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

432 ) 

433 else: 

434 statement = statement.where( 

435 or_( 

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

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

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

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

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

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

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

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

444 ) 

445 ) 

446 

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

448 raw_dt = to_aware_datetime(request.last_active) 

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

450 

451 if request.same_gender_only: 

452 if not has_strong_verification(session, user): 

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

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

455 

456 if len(request.hosting_status_filter) > 0: 

457 statement = statement.where( 

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

459 ) 

460 if len(request.meetup_status_filter) > 0: 

461 statement = statement.where( 

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

463 ) 

464 if len(request.smoking_location_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.smoking_allowed.in_([smokinglocation2sql[loc] for loc in request.smoking_location_filter]) 

467 ) 

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

469 statement = statement.where( 

470 User.sleeping_arrangement.in_( 

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

472 ) 

473 ) 

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

475 statement = statement.where( 

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

477 ) 

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

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

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

481 

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

483 

484 # return results with by language code as only input 

485 # fluency in conversational or fluent 

486 

487 if len(request.language_ability_filter) > 0: 

488 language_options = [] 

489 for ability_filter in request.language_ability_filter: 

490 fluency_sql_value = fluency2sql.get(ability_filter.fluency) 

491 

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

493 continue 

494 language_options.append( 

495 and_( 

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

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

498 ) 

499 ) 

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

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

502 

503 if request.HasField("profile_completed"): 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

531 

532 if request.HasField("search_in_area"): 

533 # EPSG4326 measures distance in decimal degress 

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

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

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

537 statement = statement.where( 

538 func.ST_DWithin( 

539 # old: 

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

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

542 # user's geom radius 

543 User.geom, 

544 search_point, 

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

546 ) 

547 ) 

548 if request.HasField("search_in_rectangle"): 

549 statement = statement.where( 

550 func.ST_Within( 

551 User.geom, 

552 func.ST_MakeEnvelope( 

553 request.search_in_rectangle.lng_min, 

554 request.search_in_rectangle.lat_min, 

555 request.search_in_rectangle.lng_max, 

556 request.search_in_rectangle.lat_max, 

557 4326, 

558 ), 

559 ) 

560 ) 

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

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

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

564 if not node: 

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

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

567 

568 if request.only_with_references: 

569 references = ( 

570 where_users_column_visible( 

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

572 context, 

573 Reference.from_user_id, 

574 ) 

575 .distinct() 

576 .subquery() 

577 ) 

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

579 

580 if request.only_with_strong_verification: 

581 statement = statement.join( 

582 StrongVerificationAttempt, 

583 and_( 

584 StrongVerificationAttempt.user_id == User.id, 

585 StrongVerificationAttempt.has_strong_verification(User), 

586 ), 

587 ) 

588 # TODO: 

589 # bool friends_only = 13; 

590 

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

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

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

594 

595 statement = ( 

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

597 .order_by(User.recommendation_score.desc()) 

598 .limit(page_size + 1) 

599 ) 

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

601 users: list[int] = [] 

602 if res: 

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

604 

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

606 return users[:page_size], next_page_token, total_items 

607 

608 

609class Search(search_pb2_grpc.SearchServicer): 

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

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

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

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

614 

615 all_results = ( 

616 _search_users( 

617 session, 

618 request.query, 

619 request.title_only, 

620 next_rank, 

621 page_size, 

622 context, 

623 request.include_users, 

624 ) 

625 + _search_pages( 

626 session, 

627 request.query, 

628 request.title_only, 

629 next_rank, 

630 page_size, 

631 context, 

632 request.include_places, 

633 request.include_guides, 

634 ) 

635 + _search_events( 

636 session, 

637 request.query, 

638 request.title_only, 

639 next_rank, 

640 page_size, 

641 context, 

642 ) 

643 + _search_clusters( 

644 session, 

645 request.query, 

646 request.title_only, 

647 next_rank, 

648 page_size, 

649 context, 

650 request.include_communities, 

651 request.include_groups, 

652 ) 

653 ) 

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

655 return search_pb2.SearchRes( 

656 results=all_results[:page_size], 

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

658 ) 

659 

660 def UserSearch( 

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

662 ) -> search_pb2.UserSearchRes: 

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

664 

665 log_event( 

666 context, 

667 session, 

668 "search.performed", 

669 { 

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

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

672 "has_filters": ( 

673 len(request.hosting_status_filter) > 0 

674 or len(request.meetup_status_filter) > 0 

675 or len(request.smoking_location_filter) > 0 

676 or len(request.sleeping_arrangement_filter) > 0 

677 or len(request.parking_details_filter) > 0 

678 or len(request.language_ability_filter) > 0 

679 or request.only_with_references 

680 or request.only_with_strong_verification 

681 ), 

682 "total_items": total_items, 

683 }, 

684 ) 

685 

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

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

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

689 ).all() 

690 ) 

691 

692 return search_pb2.UserSearchRes( 

693 results=[ 

694 search_pb2.Result( 

695 rank=1, 

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

697 ) 

698 for user_id in user_ids_to_return 

699 ], 

700 next_page_token=next_page_token, 

701 total_items=total_items, 

702 ) 

703 

704 def UserSearchV2( 

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

706 ) -> search_pb2.UserSearchV2Res: 

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

708 

709 LiteUser_by_id = { 

710 lite_user.id: lite_user 

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

712 .scalars() 

713 .all() 

714 } 

715 

716 response_rate_by_id = { 

717 resp_rate.user_id: resp_rate 

718 for resp_rate in session.execute( 

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

720 ) 

721 .scalars() 

722 .all() 

723 } 

724 

725 db_user_data_by_id = { 

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

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

728 select( 

729 User.id, 

730 User.about_me, 

731 User.gender, 

732 User.last_active, 

733 User.hosting_status, 

734 User.meetup_status, 

735 User.joined, 

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

737 ).all() 

738 } 

739 

740 ref_counts_by_user_id = get_num_references(session, context, user_ids_to_return) 

741 

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

743 lite_user = LiteUser_by_id[user_id] 

744 

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

746 

747 lat, lng = get_coordinates(lite_user.geom) 

748 return search_pb2.SearchUser( 

749 user_id=lite_user.id, 

750 username=lite_user.username, 

751 name=lite_user.name, 

752 city=lite_user.city, 

753 joined=Timestamp_from_datetime(last_active_coarsen(joined)), 

754 has_completed_profile=lite_user.has_completed_profile, 

755 has_completed_my_home=lite_user.has_completed_my_home, 

756 lat=lat, 

757 lng=lng, 

758 profile_snippet=about_me, 

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

760 gender=gender, 

761 age=int(lite_user.age), 

762 last_active=Timestamp_from_datetime(last_active_coarsen(last_active)), 

763 hosting_status=hostingstatus2api[hosting_status], 

764 meetup_status=meetupstatus2api[meetup_status], 

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

766 if lite_user.avatar_filename 

767 else None, 

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

769 if lite_user.avatar_filename 

770 else None, 

771 has_strong_verification=lite_user.has_strong_verification, 

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

773 ) 

774 

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

776 

777 return search_pb2.UserSearchV2Res( 

778 results=results, 

779 next_page_token=next_page_token, 

780 total_items=total_items, 

781 ) 

782 

783 def EventSearch( 

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

785 ) -> search_pb2.EventSearchRes: 

786 if request.attending and request.exclude_attending: 

787 context.abort_with_error_code( 

788 grpc.StatusCode.INVALID_ARGUMENT, "cannot_combine_attending_and_exclude_attending" 

789 ) 

790 statement = ( 

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

792 ) 

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

794 

795 if request.HasField("query"): 

796 if request.query_title_only: 

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

798 else: 

799 statement = statement.where( 

800 or_( 

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

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

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

804 ) 

805 ) 

806 

807 if ( 

808 request.subscribed 

809 or request.attending 

810 or request.organizing 

811 or request.my_communities 

812 or request.exclude_attending 

813 ): 

814 where_ = [] 

815 

816 if request.subscribed: 

817 statement = statement.outerjoin( 

818 EventSubscription, 

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

820 ) 

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

822 if request.organizing or request.attending: 

823 if request.organizing: 

824 statement = statement.outerjoin( 

825 EventOrganizer, 

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

827 ) 

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

829 if request.attending: 

830 statement = statement.outerjoin( 

831 EventOccurrenceAttendee, 

832 and_( 

833 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

834 EventOccurrenceAttendee.user_id == context.user_id, 

835 ), 

836 ) 

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

838 elif request.exclude_attending: 

839 statement = statement.outerjoin( 

840 EventOrganizer, 

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

842 ) 

843 statement = statement.outerjoin( 

844 EventOccurrenceAttendee, 

845 and_( 

846 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

847 EventOccurrenceAttendee.user_id == context.user_id, 

848 ), 

849 ) 

850 if request.my_communities: 

851 my_communities = ( 

852 session.execute( 

853 select(Node.id) 

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

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

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

857 .where(Cluster.is_official_cluster) 

858 .order_by(Node.id) 

859 .limit(100000) 

860 ) 

861 .scalars() 

862 .all() 

863 ) 

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

865 

866 if where_: 

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

868 

869 if request.exclude_attending: 

870 statement = statement.where(EventOccurrenceAttendee.user_id == None, EventOrganizer.user_id == None) 

871 

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

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

874 

875 if request.HasField("search_in_area"): 

876 # EPSG4326 measures distance in decimal degress 

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

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

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

880 statement = statement.where( 

881 func.ST_DWithin( 

882 # old: 

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

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

885 EventOccurrence.geom, 

886 search_point, 

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

888 ) 

889 ) 

890 if request.HasField("search_in_rectangle"): 

891 statement = statement.where( 

892 func.ST_Within( 

893 EventOccurrence.geom, 

894 func.ST_MakeEnvelope( 

895 request.search_in_rectangle.lng_min, 

896 request.search_in_rectangle.lat_min, 

897 request.search_in_rectangle.lng_max, 

898 request.search_in_rectangle.lat_max, 

899 4326, 

900 ), 

901 ) 

902 ) 

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

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

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

906 if not node: 

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

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

909 

910 if request.HasField("after"): 

911 after_time = to_aware_datetime(request.after) 

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

913 if request.HasField("before"): 

914 before_time = to_aware_datetime(request.before) 

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

916 

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

918 # the page token is ignored when a page number is given 

919 page_token = request.page_token if not request.page_number else "" 

920 page_number = request.page_number or 1 

921 # Calculate the offset for pagination 

922 offset = (page_number - 1) * page_size 

923 

924 statement = apply_occurrence_pagination(statement, page_token, request.past) 

925 

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

927 # Apply pagination by page number 

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

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

930 

931 return search_pb2.EventSearchRes( 

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

933 next_page_token=occurrences_next_page_token(occurrences, page_size), 

934 total_items=total_items, 

935 )