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

280 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +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.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 event_to_pb 

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_users_column_visible 

56from couchers.utils import ( 

57 Timestamp_from_datetime, 

58 create_coordinate, 

59 dt_from_millis, 

60 get_coordinates, 

61 last_active_coarsen, 

62 millis_from_dt, 

63 now, 

64 to_aware_datetime, 

65) 

66 

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

68MAX_PAGINATION_LENGTH = 100 

69 

70REGCONFIG = "english" 

71TRI_SIMILARITY_THRESHOLD = 0.6 

72TRI_SIMILARITY_WEIGHT = 5 

73 

74 

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

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

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

78 return "" 

79 out = coalesces[0] 

80 for coalesce in coalesces[1:]: 

81 out += " " + coalesce 

82 return out 

83 

84 

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

86 """ 

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

88 """ 

89 B = B or [] 

90 C = C or [] 

91 D = D or [] 

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

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

94 tsv = tsv.concat( 

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

96 ) 

97 if C: 

98 tsv = tsv.concat( 

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

100 ) 

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

102 tsv = tsv.concat( 

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

104 ) 

105 return tsv 

106 

107 

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

109 """ 

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

111 """ 

112 B = B or [] 

113 C = C or [] 

114 D = D or [] 

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

116 if B: 

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

118 if C: 

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

120 if D: 

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

122 return doc 

123 

124 

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

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

127 

128 

129def _gen_search_elements( 

130 statement: str, 

131 title_only: bool, 

132 next_rank: float | None, 

133 page_size: int, 

134 A: list[Any], 

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

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

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

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

139 """ 

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

141 

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

143 

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

145 

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

147 """ 

148 B = B or [] 

149 C = C or [] 

150 D = D or [] 

151 if not title_only: 

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

153 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

154 

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

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

157 

158 # document to generate snippet from 

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

160 

161 title = _build_doc(A) 

162 

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

164 sim = _similarity(statement, title) 

165 

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

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

168 

169 # the snippet with results highlighted 

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

171 

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

173 """ 

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

175 """ 

176 query = ( 

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

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

179 .order_by(rank.desc()) 

180 .limit(page_size + 1) 

181 ) 

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

183 

184 else: 

185 title = _build_doc(A) 

186 

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

188 sim = _similarity(statement, title) 

189 

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

191 rank = sim.label("rank") 

192 

193 # used only for headline 

194 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

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

196 

197 # the snippet with results highlighted 

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

199 

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

201 """ 

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

203 """ 

204 query = ( 

205 orig_statement.where(sim > TRI_SIMILARITY_THRESHOLD) 

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

207 .order_by(rank.desc()) 

208 .limit(page_size + 1) 

209 ) 

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

211 

212 return rank, snippet, execute_search_statement 

213 

214 

215def _search_users( 

216 session: Session, 

217 search_statement: str, 

218 title_only: bool, 

219 next_rank: float | None, 

220 page_size: int, 

221 context: CouchersContext, 

222 include_users: bool, 

223) -> list[search_pb2.Result]: 

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

225 return [] 

226 rank, snippet, execute_search_statement = _gen_search_elements( 

227 search_statement, 

228 title_only, 

229 next_rank, 

230 page_size, 

231 [User.username, User.name], 

232 [User.city], 

233 [User.about_me], 

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

235 ) 

236 

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

238 

239 return [ 

240 search_pb2.Result( 

241 rank=rank, 

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

243 snippet=snippet, 

244 ) 

245 for page, rank, snippet in users 

246 ] 

247 

248 

249def _search_pages( 

250 session: Session, 

251 search_statement: str, 

252 title_only: bool, 

253 next_rank: float | None, 

254 page_size: int, 

255 context: CouchersContext, 

256 include_places: bool, 

257 include_guides: bool, 

258) -> list[search_pb2.Result]: 

259 rank, snippet, execute_search_statement = _gen_search_elements( 

260 search_statement, 

261 title_only, 

262 next_rank, 

263 page_size, 

264 [PageVersion.title], 

265 [PageVersion.address], 

266 [], 

267 [PageVersion.content], 

268 ) 

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

270 return [] 

271 

272 latest_pages = ( 

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

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

275 .where( 

276 or_( 

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

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

279 ) 

280 ) 

281 .group_by(PageVersion.page_id) 

282 .subquery() 

283 ) 

284 

285 pages = execute_search_statement( 

286 session, 

287 select(Page, rank, snippet) 

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

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

290 ) 

291 

292 return [ 

293 search_pb2.Result( 

294 rank=rank, 

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

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

297 snippet=snippet, 

298 ) 

299 for page, rank, snippet in pages 

300 ] 

301 

302 

303def _search_events( 

304 session: Session, 

305 search_statement: str, 

306 title_only: bool, 

307 next_rank: float | None, 

308 page_size: int, 

309 context: CouchersContext, 

310) -> list[search_pb2.Result]: 

311 rank, snippet, execute_search_statement = _gen_search_elements( 

312 search_statement, 

313 title_only, 

314 next_rank, 

315 page_size, 

316 [Event.title], 

317 [EventOccurrence.address, EventOccurrence.link], 

318 [], 

319 [EventOccurrence.content], 

320 ) 

321 

322 occurrences = execute_search_statement( 

323 session, 

324 select(EventOccurrence, rank, snippet) 

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

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

327 ) 

328 

329 return [ 

330 search_pb2.Result( 

331 rank=rank, 

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

333 snippet=snippet, 

334 ) 

335 for occurrence, rank, snippet in occurrences 

336 ] 

337 

338 

339def _search_clusters( 

340 session: Session, 

341 search_statement: str, 

342 title_only: bool, 

343 next_rank: float | None, 

344 page_size: int, 

345 context: CouchersContext, 

346 include_communities: bool, 

347 include_groups: bool, 

348) -> list[search_pb2.Result]: 

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

350 return [] 

351 

352 rank, snippet, execute_search_statement = _gen_search_elements( 

353 search_statement, 

354 title_only, 

355 next_rank, 

356 page_size, 

357 [Cluster.name], 

358 [PageVersion.address, PageVersion.title], 

359 [Cluster.description], 

360 [PageVersion.content], 

361 ) 

362 

363 latest_pages = ( 

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

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

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

367 .group_by(PageVersion.page_id) 

368 .subquery() 

369 ) 

370 

371 clusters = execute_search_statement( 

372 session, 

373 select(Cluster, rank, snippet) 

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

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

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

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

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

379 ) 

380 

381 return [ 

382 search_pb2.Result( 

383 rank=rank, 

384 community=( 

385 community_to_pb(session, cluster.official_cluster_for_node, context) 

386 if cluster.is_official_cluster 

387 else None 

388 ), 

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

390 snippet=snippet, 

391 ) 

392 for cluster, rank, snippet in clusters 

393 ] 

394 

395 

396def _user_search_inner( 

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

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

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

400 

401 # Base statement with visibility filter 

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

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

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

405 

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

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

408 if len(request.exactly_user_ids) > 0: 

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

410 else: 

411 # Apply all the normal filters 

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

413 if request.query_name_only: 

414 statement = statement.where( 

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

416 ) 

417 else: 

418 statement = statement.where( 

419 or_( 

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

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

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

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

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

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

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

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

428 ) 

429 ) 

430 

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

432 raw_dt = to_aware_datetime(request.last_active) 

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

434 

435 if request.same_gender_only: 

436 if not has_strong_verification(session, user): 

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

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

439 

440 if len(request.hosting_status_filter) > 0: 

441 statement = statement.where( 

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

443 ) 

444 if len(request.meetup_status_filter) > 0: 

445 statement = statement.where( 

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

447 ) 

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

449 statement = statement.where( 

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

451 ) 

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

453 statement = statement.where( 

454 User.sleeping_arrangement.in_( 

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

456 ) 

457 ) 

458 if len(request.parking_details_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.parking_details.in_([parkingdetails2sql[det] for det in request.parking_details_filter]) 

461 ) 

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

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

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

465 

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

467 

468 # return results with by language code as only input 

469 # fluency in conversational or fluent 

470 

471 if len(request.language_ability_filter) > 0: 

472 language_options = [] 

473 for ability_filter in request.language_ability_filter: 

474 fluency_sql_value = fluency2sql.get(ability_filter.fluency) 

475 

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

477 continue 

478 language_options.append( 

479 and_( 

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

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

482 ) 

483 ) 

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

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

486 

487 if request.HasField("profile_completed"): 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

515 

516 if request.HasField("search_in_area"): 

517 # EPSG4326 measures distance in decimal degress 

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

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

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

521 statement = statement.where( 

522 func.ST_DWithin( 

523 # old: 

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

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

526 # user's geom radius 

527 User.geom, 

528 search_point, 

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

530 ) 

531 ) 

532 if request.HasField("search_in_rectangle"): 

533 statement = statement.where( 

534 func.ST_Within( 

535 User.geom, 

536 func.ST_MakeEnvelope( 

537 request.search_in_rectangle.lng_min, 

538 request.search_in_rectangle.lat_min, 

539 request.search_in_rectangle.lng_max, 

540 request.search_in_rectangle.lat_max, 

541 4326, 

542 ), 

543 ) 

544 ) 

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

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

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

548 if not node: 

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

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

551 

552 if request.only_with_references: 

553 references = ( 

554 where_users_column_visible( 

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

556 context, 

557 Reference.from_user_id, 

558 ) 

559 .distinct() 

560 .subquery() 

561 ) 

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

563 

564 if request.only_with_strong_verification: 

565 statement = statement.join( 

566 StrongVerificationAttempt, 

567 and_( 

568 StrongVerificationAttempt.user_id == User.id, 

569 StrongVerificationAttempt.has_strong_verification(User), 

570 ), 

571 ) 

572 # TODO: 

573 # bool friends_only = 13; 

574 

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

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

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

578 

579 statement = ( 

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

581 .order_by(User.recommendation_score.desc()) 

582 .limit(page_size + 1) 

583 ) 

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

585 users: list[int] = [] 

586 if res: 

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

588 

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

590 return users[:page_size], next_page_token, total_items 

591 

592 

593class Search(search_pb2_grpc.SearchServicer): 

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

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

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

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

598 

599 all_results = ( 

600 _search_users( 

601 session, 

602 request.query, 

603 request.title_only, 

604 next_rank, 

605 page_size, 

606 context, 

607 request.include_users, 

608 ) 

609 + _search_pages( 

610 session, 

611 request.query, 

612 request.title_only, 

613 next_rank, 

614 page_size, 

615 context, 

616 request.include_places, 

617 request.include_guides, 

618 ) 

619 + _search_events( 

620 session, 

621 request.query, 

622 request.title_only, 

623 next_rank, 

624 page_size, 

625 context, 

626 ) 

627 + _search_clusters( 

628 session, 

629 request.query, 

630 request.title_only, 

631 next_rank, 

632 page_size, 

633 context, 

634 request.include_communities, 

635 request.include_groups, 

636 ) 

637 ) 

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

639 return search_pb2.SearchRes( 

640 results=all_results[:page_size], 

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

642 ) 

643 

644 def UserSearch( 

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

646 ) -> search_pb2.UserSearchRes: 

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

648 

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

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

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

652 ).all() 

653 ) 

654 

655 return search_pb2.UserSearchRes( 

656 results=[ 

657 search_pb2.Result( 

658 rank=1, 

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

660 ) 

661 for user_id in user_ids_to_return 

662 ], 

663 next_page_token=next_page_token, 

664 total_items=total_items, 

665 ) 

666 

667 def UserSearchV2( 

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

669 ) -> search_pb2.UserSearchV2Res: 

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

671 

672 LiteUser_by_id = { 

673 lite_user.id: lite_user 

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

675 .scalars() 

676 .all() 

677 } 

678 

679 response_rate_by_id = { 

680 resp_rate.user_id: resp_rate 

681 for resp_rate in session.execute( 

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

683 ) 

684 .scalars() 

685 .all() 

686 } 

687 

688 db_user_data_by_id = { 

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

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

691 select( 

692 User.id, 

693 User.about_me, 

694 User.gender, 

695 User.last_active, 

696 User.hosting_status, 

697 User.meetup_status, 

698 User.joined, 

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

700 ).all() 

701 } 

702 

703 ref_counts_by_user_id = get_num_references(session, user_ids_to_return) 

704 

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

706 lite_user = LiteUser_by_id[user_id] 

707 

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

709 

710 lat, lng = get_coordinates(lite_user.geom) 

711 return search_pb2.SearchUser( 

712 user_id=lite_user.id, 

713 username=lite_user.username, 

714 name=lite_user.name, 

715 city=lite_user.city, 

716 joined=Timestamp_from_datetime(last_active_coarsen(joined)), 

717 has_completed_profile=lite_user.has_completed_profile, 

718 has_completed_my_home=lite_user.has_completed_my_home, 

719 lat=lat, 

720 lng=lng, 

721 profile_snippet=about_me, 

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

723 gender=gender, 

724 age=int(lite_user.age), 

725 last_active=Timestamp_from_datetime(last_active_coarsen(last_active)), 

726 hosting_status=hostingstatus2api[hosting_status], 

727 meetup_status=meetupstatus2api[meetup_status], 

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

729 if lite_user.avatar_filename 

730 else None, 

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

732 if lite_user.avatar_filename 

733 else None, 

734 has_strong_verification=lite_user.has_strong_verification, 

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

736 ) 

737 

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

739 

740 return search_pb2.UserSearchV2Res( 

741 results=results, 

742 next_page_token=next_page_token, 

743 total_items=total_items, 

744 ) 

745 

746 def EventSearch( 

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

748 ) -> search_pb2.EventSearchRes: 

749 statement = ( 

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

751 ) 

752 

753 if request.HasField("query"): 

754 if request.query_title_only: 

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

756 else: 

757 statement = statement.where( 

758 or_( 

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

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

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

762 ) 

763 ) 

764 

765 if request.only_online: 

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

767 elif request.only_offline: 

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

769 

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

771 where_ = [] 

772 

773 if request.subscribed: 

774 statement = statement.outerjoin( 

775 EventSubscription, 

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

777 ) 

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

779 if request.organizing: 

780 statement = statement.outerjoin( 

781 EventOrganizer, 

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

783 ) 

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

785 if request.attending: 

786 statement = statement.outerjoin( 

787 EventOccurrenceAttendee, 

788 and_( 

789 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

790 EventOccurrenceAttendee.user_id == context.user_id, 

791 ), 

792 ) 

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

794 if request.my_communities: 

795 my_communities = ( 

796 session.execute( 

797 select(Node.id) 

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

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

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

801 .where(Cluster.is_official_cluster) 

802 .order_by(Node.id) 

803 .limit(100000) 

804 ) 

805 .scalars() 

806 .all() 

807 ) 

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

809 

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

811 

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

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

814 

815 if request.HasField("search_in_area"): 

816 # EPSG4326 measures distance in decimal degress 

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

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

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

820 statement = statement.where( 

821 func.ST_DWithin( 

822 # old: 

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

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

825 EventOccurrence.geom, 

826 search_point, 

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

828 ) 

829 ) 

830 if request.HasField("search_in_rectangle"): 

831 statement = statement.where( 

832 func.ST_Within( 

833 EventOccurrence.geom, 

834 func.ST_MakeEnvelope( 

835 request.search_in_rectangle.lng_min, 

836 request.search_in_rectangle.lat_min, 

837 request.search_in_rectangle.lng_max, 

838 request.search_in_rectangle.lat_max, 

839 4326, 

840 ), 

841 ) 

842 ) 

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

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

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

846 if not node: 

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

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

849 

850 if request.HasField("after"): 

851 after_time = to_aware_datetime(request.after) 

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

853 if request.HasField("before"): 

854 before_time = to_aware_datetime(request.before) 

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

856 

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

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

859 page_token = ( 

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

861 ) 

862 page_number = request.page_number or 1 

863 # Calculate the offset for pagination 

864 offset = (page_number - 1) * page_size 

865 

866 if not request.past: 

867 cutoff = page_token - timedelta(seconds=1) 

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

869 else: 

870 cutoff = page_token + timedelta(seconds=1) 

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

872 

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

874 # Apply pagination by page number 

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

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

877 

878 return search_pb2.EventSearchRes( 

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

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

881 total_items=total_items, 

882 )