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

302 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1""" 

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

3""" 

4 

5from math import pi 

6from typing import Any, cast 

7 

8import grpc 

9from sqlalchemy import ColumnElement, Float, SQLColumnExpression, literal_column, select, tuple_, type_coerce 

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 ( 

16 USER_SEARCH_NOISE_KEY_NAME, 

17 decrypt_page_token, 

18 encrypt_page_token, 

19 generate_hash_signature, 

20 get_secret, 

21) 

22from couchers.event_log import log_event 

23from couchers.helpers.completed_profile import has_completed_profile_expression 

24from couchers.helpers.strong_verification import has_strong_verification 

25from couchers.materialized_views import LiteUser, UserResponseRate 

26from couchers.models import ( 

27 Cluster, 

28 ClusterSubscription, 

29 Event, 

30 EventOccurrence, 

31 EventOccurrenceAttendee, 

32 EventOrganizer, 

33 EventSubscription, 

34 LanguageAbility, 

35 Node, 

36 Page, 

37 PageType, 

38 PageVersion, 

39 Reference, 

40 StrongVerificationAttempt, 

41 User, 

42 UserRankingOffset, 

43) 

44from couchers.proto import search_pb2, search_pb2_grpc 

45from couchers.reranker import reranker 

46from couchers.servicers.api import ( 

47 fluency2sql, 

48 get_num_references, 

49 hostingstatus2api, 

50 hostingstatus2sql, 

51 meetupstatus2api, 

52 meetupstatus2sql, 

53 parkingdetails2sql, 

54 response_rate_to_pb, 

55 sleepingarrangement2sql, 

56 smokinglocation2sql, 

57 user_model_to_pb, 

58) 

59from couchers.servicers.communities import community_to_pb 

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

61from couchers.servicers.groups import group_to_pb 

62from couchers.servicers.pages import page_to_pb 

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

64from couchers.utils import ( 

65 Timestamp_from_datetime, 

66 create_coordinate, 

67 get_coordinates, 

68 last_active_coarsen, 

69 to_aware_datetime, 

70 today, 

71) 

72 

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

74MAX_PAGINATION_LENGTH = 100 

75 

76REGCONFIG = "english" 

77TRI_SIMILARITY_THRESHOLD = 0.6 

78TRI_SIMILARITY_WEIGHT = 5 

79 

80DEFAULT_USER_SEARCH_NOISE_SCALE = 2.0 

81USER_SEARCH_NOISE_PERIOD_DAYS = 7 

82 

83 

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

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

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

87 return "" 

88 out = coalesces[0] 

89 for coalesce in coalesces[1:]: 

90 out += " " + coalesce 

91 return out 

92 

93 

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

95 """ 

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

97 """ 

98 B = B or [] 

99 C = C or [] 

100 D = D or [] 

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

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

103 tsv: Any = func.setweight( 

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

105 literal_column("'A'"), 

106 ) 

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

108 tsv = tsv.concat( 

109 func.setweight( 

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

111 literal_column("'B'"), 

112 ) 

113 ) 

114 if C: 

115 tsv = tsv.concat( 

116 func.setweight( 

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

118 literal_column("'C'"), 

119 ) 

120 ) 

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

122 tsv = tsv.concat( 

123 func.setweight( 

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

125 literal_column("'D'"), 

126 ) 

127 ) 

128 return tsv 

129 

130 

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

132 """ 

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

134 """ 

135 B = B or [] 

136 C = C or [] 

137 D = D or [] 

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

139 if B: 

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

141 if C: 

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

143 if D: 

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

145 return doc 

146 

147 

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

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

150 

151 

152def _gen_search_elements( 

153 statement: str, 

154 title_only: bool, 

155 next_rank: float | None, 

156 page_size: int, 

157 A: list[Any], 

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

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

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

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

162 """ 

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

164 

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

166 

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

168 

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

170 """ 

171 B = B or [] 

172 C = C or [] 

173 D = D or [] 

174 if not title_only: 

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

176 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

177 

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

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

180 

181 # document to generate snippet from 

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

183 

184 title = _build_doc(A) 

185 

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

187 sim = _similarity(statement, title) 

188 

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

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

191 

192 # the snippet with results highlighted 

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

194 

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

196 """ 

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

198 """ 

199 query = ( 

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

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

202 .order_by(rank.desc()) 

203 .limit(page_size + 1) 

204 ) 

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

206 

207 else: 

208 title = _build_doc(A) 

209 

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

211 sim = _similarity(statement, title) 

212 

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

214 rank = sim.label("rank") 

215 

216 # used only for headline 

217 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

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

219 

220 # the snippet with results highlighted 

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

222 

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

224 """ 

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

226 """ 

227 query = ( 

228 orig_statement.where(sim > TRI_SIMILARITY_THRESHOLD) 

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

230 .order_by(rank.desc()) 

231 .limit(page_size + 1) 

232 ) 

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

234 

235 return rank, snippet, execute_search_statement 

236 

237 

238def _search_users( 

239 session: Session, 

240 search_statement: str, 

241 title_only: bool, 

242 next_rank: float | None, 

243 page_size: int, 

244 context: CouchersContext, 

245 include_users: bool, 

246) -> list[search_pb2.Result]: 

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

248 return [] 

249 rank, snippet, execute_search_statement = _gen_search_elements( 

250 search_statement, 

251 title_only, 

252 next_rank, 

253 page_size, 

254 [User.username, User.name], 

255 [User.city], 

256 [User.about_me], 

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

258 ) 

259 

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

261 

262 return [ 

263 search_pb2.Result( 

264 rank=rank, 

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

266 snippet=snippet, 

267 ) 

268 for page, rank, snippet in users 

269 ] 

270 

271 

272def _search_pages( 

273 session: Session, 

274 search_statement: str, 

275 title_only: bool, 

276 next_rank: float | None, 

277 page_size: int, 

278 context: CouchersContext, 

279 include_places: bool, 

280 include_guides: bool, 

281) -> list[search_pb2.Result]: 

282 rank, snippet, execute_search_statement = _gen_search_elements( 

283 search_statement, 

284 title_only, 

285 next_rank, 

286 page_size, 

287 [PageVersion.title], 

288 [PageVersion.address], 

289 [], 

290 [PageVersion.content], 

291 ) 

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

293 return [] 

294 

295 latest_pages = ( 

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

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

298 .where( 

299 or_( 

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

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

302 ) 

303 ) 

304 .group_by(PageVersion.page_id) 

305 .subquery() 

306 ) 

307 

308 pages = execute_search_statement( 

309 session, 

310 select(Page, rank, snippet) 

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

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

313 ) 

314 

315 return [ 

316 search_pb2.Result( 

317 rank=rank, 

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

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

320 snippet=snippet, 

321 ) 

322 for page, rank, snippet in pages 

323 ] 

324 

325 

326def _search_events( 

327 session: Session, 

328 search_statement: str, 

329 title_only: bool, 

330 next_rank: float | None, 

331 page_size: int, 

332 context: CouchersContext, 

333) -> list[search_pb2.Result]: 

334 rank, snippet, execute_search_statement = _gen_search_elements( 

335 search_statement, 

336 title_only, 

337 next_rank, 

338 page_size, 

339 [Event.title], 

340 [EventOccurrence.address], 

341 [], 

342 [EventOccurrence.content], 

343 ) 

344 

345 occurrences = execute_search_statement( 

346 session, 

347 where_moderated_content_visible( 

348 select(EventOccurrence, rank, snippet) 

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

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

351 context, 

352 EventOccurrence, 

353 is_list_operation=True, 

354 ), 

355 ) 

356 

357 return [ 

358 search_pb2.Result( 

359 rank=rank, 

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

361 snippet=snippet, 

362 ) 

363 for occurrence, rank, snippet in occurrences 

364 ] 

365 

366 

367def _search_clusters( 

368 session: Session, 

369 search_statement: str, 

370 title_only: bool, 

371 next_rank: float | None, 

372 page_size: int, 

373 context: CouchersContext, 

374 include_communities: bool, 

375 include_groups: bool, 

376) -> list[search_pb2.Result]: 

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

378 return [] 

379 

380 rank, snippet, execute_search_statement = _gen_search_elements( 

381 search_statement, 

382 title_only, 

383 next_rank, 

384 page_size, 

385 [Cluster.name], 

386 [PageVersion.address, PageVersion.title], 

387 [Cluster.description], 

388 [PageVersion.content], 

389 ) 

390 

391 latest_pages = ( 

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

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

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

395 .group_by(PageVersion.page_id) 

396 .subquery() 

397 ) 

398 

399 clusters = execute_search_statement( 

400 session, 

401 select(Cluster, rank, snippet) 

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

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

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

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

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

407 ) 

408 

409 return [ 

410 search_pb2.Result( 

411 rank=rank, 

412 community=( 

413 community_to_pb(session, cluster.official_cluster_for_node, context) 

414 if cluster.is_official_cluster 

415 else None 

416 ), 

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

418 snippet=snippet, 

419 ) 

420 for cluster, rank, snippet in clusters 

421 ] 

422 

423 

424def stable_normal_rv(column: SQLColumnExpression[int], key: bytes, seed: bytes) -> ColumnElement[float]: 

425 """ 

426 Standard normal random variate for each value of a bigint column, deterministic given the key and seed 

427 """ 

428 digest = generate_hash_signature(message=seed, key=key, digest_size=16) 

429 # hashint8extended gives a uniform signed 64-bit int; keep the top 53 bits so it's exact as a double, and map into 

430 # (0, 1] so it's safe to take ln() of 

431 seeds = (int.from_bytes(digest[i : i + 8], "big", signed=True) for i in (0, 8)) 

432 u1, u2 = ((func.hashint8extended(column, seed).op(">>")(11) + 2**52 + 1).cast(Float) / 2.0**53 for seed in seeds) 

433 # Box-Muller transform. Postgres' random_normal() can't be seeded per row, so we roll our own 

434 return type_coerce(func.sqrt(-2 * func.ln(u1)) * func.cos(2 * pi * u2), Float) 

435 

436 

437def _user_search_noise(column: SQLColumnExpression[int], viewer_id: int) -> ColumnElement[float]: 

438 """ 

439 Standard normal noise for each value of the column, different for each viewer and stable for a given viewer over 

440 USER_SEARCH_NOISE_PERIOD_DAYS, so different viewers see a different ordering 

441 """ 

442 # offset by viewer id so that viewers don't all reshuffle on the same day 

443 period = (today().toordinal() + viewer_id) // USER_SEARCH_NOISE_PERIOD_DAYS 

444 return stable_normal_rv(column, get_secret(USER_SEARCH_NOISE_KEY_NAME), f"{viewer_id}|{period}".encode()) 

445 

446 

447def _user_search_inner( 

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

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

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

451 

452 # Base statement with visibility filter 

453 statement = select(User.id).where(users_visible(context)) 

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

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

456 

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

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

459 if len(request.exactly_user_ids) > 0: 

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

461 else: 

462 # Apply all the normal filters 

463 if request.HasField("query"): 

464 if request.query_name_only: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true

465 statement = statement.where( 

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

467 ) 

468 else: 

469 statement = statement.where( 

470 or_( 

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

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

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

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

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

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

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

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

479 ) 

480 ) 

481 

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

483 raw_dt = to_aware_datetime(request.last_active) 

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

485 

486 if request.same_gender_only: 

487 if not has_strong_verification(session, user): 

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

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

490 

491 if len(request.hosting_status_filter) > 0: 

492 statement = statement.where( 

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

494 ) 

495 if len(request.meetup_status_filter) > 0: 

496 statement = statement.where( 

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

498 ) 

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

500 statement = statement.where( 

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

502 ) 

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

504 statement = statement.where( 

505 User.sleeping_arrangement.in_( 

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

507 ) 

508 ) 

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

510 statement = statement.where( 

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

512 ) 

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

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

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

516 

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

518 

519 # return results with by language code as only input 

520 # fluency in conversational or fluent 

521 

522 if len(request.language_ability_filter) > 0: 

523 language_options = [] 

524 for ability_filter in request.language_ability_filter: 

525 fluency_sql_value = fluency2sql.get(ability_filter.fluency) 

526 

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

528 continue 

529 language_options.append( 

530 and_( 

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

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

533 ) 

534 ) 

535 statement = statement.where( 

536 select(LanguageAbility).where(LanguageAbility.user_id == User.id).where(or_(*language_options)).exists() 

537 ) 

538 

539 if request.HasField("profile_completed"): 

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

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

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

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

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

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

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

547 if request.HasField("accepts_pets"): 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

567 

568 if request.HasField("search_in_area"): 

569 # EPSG4326 measures distance in decimal degress 

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

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

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

573 statement = statement.where( 

574 func.ST_DWithin( 

575 # old: 

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

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

578 # user's geom radius 

579 User.geom, 

580 search_point, 

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

582 ) 

583 ) 

584 if request.HasField("search_in_rectangle"): 

585 statement = statement.where( 

586 func.ST_Within( 

587 User.geom, 

588 func.ST_MakeEnvelope( 

589 request.search_in_rectangle.lng_min, 

590 request.search_in_rectangle.lat_min, 

591 request.search_in_rectangle.lng_max, 

592 request.search_in_rectangle.lat_max, 

593 4326, 

594 ), 

595 ) 

596 ) 

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

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

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

600 if not node: 

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

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

603 

604 if request.only_with_references: 

605 references = ( 

606 where_users_column_visible( 

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

608 context, 

609 Reference.from_user_id, 

610 ) 

611 .distinct() 

612 .subquery() 

613 ) 

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

615 

616 if request.only_with_strong_verification: 

617 statement = statement.where( 

618 select(StrongVerificationAttempt) 

619 .where(StrongVerificationAttempt.user_id == User.id) 

620 .where(StrongVerificationAttempt.has_strong_verification(User)) 

621 .exists() 

622 ) 

623 # TODO: 

624 # bool friends_only = 13; 

625 

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

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

628 

629 noise_scale = context.get_float_value("user_search_noise_scale", DEFAULT_USER_SEARCH_NOISE_SCALE) 

630 ranking_score = User.recommendation_score + noise_scale * _user_search_noise(User.id, context.user_id) 

631 ranking_offsets_variant = context.get_string_value("search_ranking_offsets_variant", "") 

632 if ranking_offsets_variant: 

633 statement = statement.outerjoin( 

634 UserRankingOffset, 

635 and_(UserRankingOffset.user_id == User.id, UserRankingOffset.variant == ranking_offsets_variant), 

636 ) 

637 ranking_score = ranking_score + func.coalesce(UserRankingOffset.offset, 0.0) 

638 statement = statement.add_columns(ranking_score) 

639 if request.page_token: 

640 last_score, last_id = decrypt_page_token(request.page_token).split(",") 

641 statement = statement.where(tuple_(ranking_score, User.id) < (float(last_score), int(last_id))) 

642 

643 statement = statement.order_by(ranking_score.desc(), User.id.desc()).limit(page_size + 1) 

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

645 

646 next_page_token = None 

647 if len(res) > page_size: 

648 last_user_id, last_rec_score = res[page_size - 1] 

649 next_page_token = encrypt_page_token(f"{last_rec_score},{last_user_id}") 

650 return [user_id for user_id, _ in res[:page_size]], next_page_token, total_items, ranking_offsets_variant 

651 

652 

653def _log_search_performed( 

654 request: search_pb2.UserSearchReq, 

655 context: CouchersContext, 

656 session: Session, 

657 total_items: int, 

658 ranking_offsets_variant: str, 

659) -> None: 

660 non_filter_fields = { 

661 "query", 

662 "query_name_only", 

663 "search_in_area", 

664 "search_in_community_id", 

665 "search_in_rectangle", 

666 "exactly_user_ids", 

667 "page_size", 

668 "page_token", 

669 } 

670 filters = sorted(field.name for field, _ in request.ListFields() if field.name not in non_filter_fields) 

671 log_event( 

672 context, 

673 session, 

674 "search.performed", 

675 { 

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

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

678 "has_filters": len(filters) > 0, 

679 "filters": filters, 

680 "is_selection": len(request.exactly_user_ids) > 0, 

681 "has_page_token": bool(request.page_token), 

682 "total_items": total_items, 

683 "ranking_offsets_variant": ranking_offsets_variant, 

684 }, 

685 ) 

686 

687 

688class Search(search_pb2_grpc.SearchServicer): 

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

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

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

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

693 

694 all_results = ( 

695 _search_users( 

696 session, 

697 request.query, 

698 request.title_only, 

699 next_rank, 

700 page_size, 

701 context, 

702 request.include_users, 

703 ) 

704 + _search_pages( 

705 session, 

706 request.query, 

707 request.title_only, 

708 next_rank, 

709 page_size, 

710 context, 

711 request.include_places, 

712 request.include_guides, 

713 ) 

714 + _search_events( 

715 session, 

716 request.query, 

717 request.title_only, 

718 next_rank, 

719 page_size, 

720 context, 

721 ) 

722 + _search_clusters( 

723 session, 

724 request.query, 

725 request.title_only, 

726 next_rank, 

727 page_size, 

728 context, 

729 request.include_communities, 

730 request.include_groups, 

731 ) 

732 ) 

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

734 return search_pb2.SearchRes( 

735 results=all_results[:page_size], 

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

737 ) 

738 

739 def UserSearchV2( 

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

741 ) -> search_pb2.UserSearchV2Res: 

742 user_ids_to_return, next_page_token, total_items, ranking_offsets_variant = _user_search_inner( 

743 request, context, session 

744 ) 

745 

746 _log_search_performed(request, context, session, total_items, ranking_offsets_variant) 

747 

748 LiteUser_by_id = { 

749 lite_user.id: lite_user 

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

751 .scalars() 

752 .all() 

753 } 

754 

755 response_rate_by_id = { 

756 resp_rate.user_id: resp_rate 

757 for resp_rate in session.execute( 

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

759 ) 

760 .scalars() 

761 .all() 

762 } 

763 

764 db_user_data_by_id = { 

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

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

767 select( 

768 User.id, 

769 User.about_me, 

770 User.gender, 

771 User.last_active, 

772 User.hosting_status, 

773 User.meetup_status, 

774 User.joined, 

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

776 ).all() 

777 } 

778 

779 ref_counts_by_user_id = get_num_references(session, context, user_ids_to_return) 

780 

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

782 lite_user = LiteUser_by_id[user_id] 

783 

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

785 

786 lat, lng = get_coordinates(lite_user.geom) 

787 return search_pb2.SearchUser( 

788 user_id=lite_user.id, 

789 username=lite_user.username, 

790 name=lite_user.name, 

791 city=lite_user.city, 

792 joined=Timestamp_from_datetime(last_active_coarsen(joined)), 

793 has_completed_profile=lite_user.has_completed_profile, 

794 has_completed_my_home=lite_user.has_completed_my_home, 

795 lat=lat, 

796 lng=lng, 

797 profile_snippet=about_me, 

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

799 gender=gender, 

800 age=int(lite_user.age), 

801 last_active=Timestamp_from_datetime(last_active_coarsen(last_active)), 

802 hosting_status=hostingstatus2api[hosting_status], 

803 meetup_status=meetupstatus2api[meetup_status], 

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

805 if lite_user.avatar_filename 

806 else None, 

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

808 if lite_user.avatar_filename 

809 else None, 

810 has_strong_verification=lite_user.has_strong_verification, 

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

812 ) 

813 

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

815 

816 return search_pb2.UserSearchV2Res( 

817 results=results, 

818 next_page_token=next_page_token, 

819 total_items=total_items, 

820 ) 

821 

822 def EventSearch( 

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

824 ) -> search_pb2.EventSearchRes: 

825 if request.attending and request.exclude_attending: 

826 context.abort_with_error_code( 

827 grpc.StatusCode.INVALID_ARGUMENT, "cannot_combine_attending_and_exclude_attending" 

828 ) 

829 statement = ( 

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

831 ) 

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

833 

834 if request.HasField("query"): 

835 if request.query_title_only: 

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

837 else: 

838 statement = statement.where( 

839 or_( 

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

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

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

843 ) 

844 ) 

845 

846 if ( 

847 request.subscribed 

848 or request.attending 

849 or request.organizing 

850 or request.my_communities 

851 or request.exclude_attending 

852 ): 

853 where_ = [] 

854 

855 if request.subscribed: 

856 statement = statement.outerjoin( 

857 EventSubscription, 

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

859 ) 

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

861 if request.organizing or request.attending: 

862 if request.organizing: 

863 statement = statement.outerjoin( 

864 EventOrganizer, 

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

866 ) 

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

868 if request.attending: 

869 statement = statement.outerjoin( 

870 EventOccurrenceAttendee, 

871 and_( 

872 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

873 EventOccurrenceAttendee.user_id == context.user_id, 

874 ), 

875 ) 

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

877 elif request.exclude_attending: 

878 statement = statement.outerjoin( 

879 EventOrganizer, 

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

881 ) 

882 statement = statement.outerjoin( 

883 EventOccurrenceAttendee, 

884 and_( 

885 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

886 EventOccurrenceAttendee.user_id == context.user_id, 

887 ), 

888 ) 

889 if request.my_communities: 

890 my_communities = ( 

891 session.execute( 

892 select(Node.id) 

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

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

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

896 .where(Cluster.is_official_cluster) 

897 .order_by(Node.id) 

898 .limit(100000) 

899 ) 

900 .scalars() 

901 .all() 

902 ) 

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

904 

905 if where_: 

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

907 

908 if request.exclude_attending: 

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

910 

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

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

913 

914 if request.HasField("search_in_area"): 

915 # EPSG4326 measures distance in decimal degress 

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

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

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

919 statement = statement.where( 

920 func.ST_DWithin( 

921 # old: 

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

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

924 EventOccurrence.geom, 

925 search_point, 

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

927 ) 

928 ) 

929 if request.HasField("search_in_rectangle"): 

930 statement = statement.where( 

931 func.ST_Within( 

932 EventOccurrence.geom, 

933 func.ST_MakeEnvelope( 

934 request.search_in_rectangle.lng_min, 

935 request.search_in_rectangle.lat_min, 

936 request.search_in_rectangle.lng_max, 

937 request.search_in_rectangle.lat_max, 

938 4326, 

939 ), 

940 ) 

941 ) 

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

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

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

945 if not node: 

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

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

948 

949 if request.HasField("after"): 

950 after_time = to_aware_datetime(request.after) 

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

952 if request.HasField("before"): 

953 before_time = to_aware_datetime(request.before) 

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

955 

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

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

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

959 page_number = request.page_number or 1 

960 # Calculate the offset for pagination 

961 offset = (page_number - 1) * page_size 

962 

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

964 

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

966 # Apply pagination by page number 

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

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

969 

970 return search_pb2.EventSearchRes( 

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

972 next_page_token=occurrences_next_page_token(occurrences, page_size), 

973 total_items=total_items, 

974 )