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

279 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-20 05:37 +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.strong_verification import has_strong_verification 

17from couchers.materialized_views import LiteUser, UserResponseRate 

18from couchers.models import ( 

19 Cluster, 

20 ClusterSubscription, 

21 Event, 

22 EventOccurrence, 

23 EventOccurrenceAttendee, 

24 EventOrganizer, 

25 EventSubscription, 

26 LanguageAbility, 

27 Node, 

28 Page, 

29 PageType, 

30 PageVersion, 

31 Reference, 

32 StrongVerificationAttempt, 

33 User, 

34) 

35from couchers.proto import search_pb2, search_pb2_grpc 

36from couchers.reranker import reranker 

37from couchers.servicers.api import ( 

38 fluency2sql, 

39 get_num_references, 

40 hostingstatus2api, 

41 hostingstatus2sql, 

42 meetupstatus2api, 

43 meetupstatus2sql, 

44 parkingdetails2sql, 

45 response_rate_to_pb, 

46 sleepingarrangement2sql, 

47 smokinglocation2sql, 

48 user_model_to_pb, 

49) 

50from couchers.servicers.communities import community_to_pb 

51from couchers.servicers.events import event_to_pb 

52from couchers.servicers.groups import group_to_pb 

53from couchers.servicers.pages import page_to_pb 

54from couchers.sql import to_bool, users_visible, where_users_column_visible 

55from couchers.utils import ( 

56 Timestamp_from_datetime, 

57 create_coordinate, 

58 dt_from_millis, 

59 get_coordinates, 

60 last_active_coarsen, 

61 millis_from_dt, 

62 now, 

63 to_aware_datetime, 

64) 

65 

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

67MAX_PAGINATION_LENGTH = 100 

68 

69REGCONFIG = "english" 

70TRI_SIMILARITY_THRESHOLD = 0.6 

71TRI_SIMILARITY_WEIGHT = 5 

72 

73 

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

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

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

77 return "" 

78 out = coalesces[0] 

79 for coalesce in coalesces[1:]: 

80 out += " " + coalesce 

81 return out 

82 

83 

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

85 """ 

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

87 """ 

88 B = B or [] 

89 C = C or [] 

90 D = D or [] 

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

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

93 tsv = tsv.concat( 

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

95 ) 

96 if C: 

97 tsv = tsv.concat( 

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

99 ) 

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

101 tsv = tsv.concat( 

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

103 ) 

104 return tsv 

105 

106 

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

108 """ 

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

110 """ 

111 B = B or [] 

112 C = C or [] 

113 D = D or [] 

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

115 if B: 

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

117 if C: 

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

119 if D: 

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

121 return doc 

122 

123 

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

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

126 

127 

128def _gen_search_elements( 

129 statement: str, 

130 title_only: bool, 

131 next_rank: float | None, 

132 page_size: int, 

133 A: list[Any], 

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

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

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

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

138 """ 

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

140 

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

142 

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

144 

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

146 """ 

147 B = B or [] 

148 C = C or [] 

149 D = D or [] 

150 if not title_only: 

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

152 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

153 

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

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

156 

157 # document to generate snippet from 

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

159 

160 title = _build_doc(A) 

161 

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

163 sim = _similarity(statement, title) 

164 

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

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

167 

168 # the snippet with results highlighted 

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

170 

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

172 """ 

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

174 """ 

175 query = ( 

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

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

178 .order_by(rank.desc()) 

179 .limit(page_size + 1) 

180 ) 

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

182 

183 else: 

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 = sim.label("rank") 

191 

192 # used only for headline 

193 tsq = func.websearch_to_tsquery(REGCONFIG, statement) 

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

195 

196 # the snippet with results highlighted 

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

198 

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

200 """ 

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

202 """ 

203 query = ( 

204 orig_statement.where(sim > TRI_SIMILARITY_THRESHOLD) 

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

206 .order_by(rank.desc()) 

207 .limit(page_size + 1) 

208 ) 

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

210 

211 return rank, snippet, execute_search_statement 

212 

213 

214def _search_users( 

215 session: Session, 

216 search_statement: str, 

217 title_only: bool, 

218 next_rank: float | None, 

219 page_size: int, 

220 context: CouchersContext, 

221 include_users: bool, 

222) -> list[search_pb2.Result]: 

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

224 return [] 

225 rank, snippet, execute_search_statement = _gen_search_elements( 

226 search_statement, 

227 title_only, 

228 next_rank, 

229 page_size, 

230 [User.username, User.name], 

231 [User.city], 

232 [User.about_me], 

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

234 ) 

235 

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

237 

238 return [ 

239 search_pb2.Result( 

240 rank=rank, 

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

242 snippet=snippet, 

243 ) 

244 for page, rank, snippet in users 

245 ] 

246 

247 

248def _search_pages( 

249 session: Session, 

250 search_statement: str, 

251 title_only: bool, 

252 next_rank: float | None, 

253 page_size: int, 

254 context: CouchersContext, 

255 include_places: bool, 

256 include_guides: bool, 

257) -> list[search_pb2.Result]: 

258 rank, snippet, execute_search_statement = _gen_search_elements( 

259 search_statement, 

260 title_only, 

261 next_rank, 

262 page_size, 

263 [PageVersion.title], 

264 [PageVersion.address], 

265 [], 

266 [PageVersion.content], 

267 ) 

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

269 return [] 

270 

271 latest_pages = ( 

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

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

274 .where( 

275 or_( 

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

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

278 ) 

279 ) 

280 .group_by(PageVersion.page_id) 

281 .subquery() 

282 ) 

283 

284 pages = execute_search_statement( 

285 session, 

286 select(Page, rank, snippet) 

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

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

289 ) 

290 

291 return [ 

292 search_pb2.Result( 

293 rank=rank, 

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

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

296 snippet=snippet, 

297 ) 

298 for page, rank, snippet in pages 

299 ] 

300 

301 

302def _search_events( 

303 session: Session, 

304 search_statement: str, 

305 title_only: bool, 

306 next_rank: float | None, 

307 page_size: int, 

308 context: CouchersContext, 

309) -> list[search_pb2.Result]: 

310 rank, snippet, execute_search_statement = _gen_search_elements( 

311 search_statement, 

312 title_only, 

313 next_rank, 

314 page_size, 

315 [Event.title], 

316 [EventOccurrence.address, EventOccurrence.link], 

317 [], 

318 [EventOccurrence.content], 

319 ) 

320 

321 occurrences = execute_search_statement( 

322 session, 

323 select(EventOccurrence, rank, snippet) 

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

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

326 ) 

327 

328 return [ 

329 search_pb2.Result( 

330 rank=rank, 

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

332 snippet=snippet, 

333 ) 

334 for occurrence, rank, snippet in occurrences 

335 ] 

336 

337 

338def _search_clusters( 

339 session: Session, 

340 search_statement: str, 

341 title_only: bool, 

342 next_rank: float | None, 

343 page_size: int, 

344 context: CouchersContext, 

345 include_communities: bool, 

346 include_groups: bool, 

347) -> list[search_pb2.Result]: 

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

349 return [] 

350 

351 rank, snippet, execute_search_statement = _gen_search_elements( 

352 search_statement, 

353 title_only, 

354 next_rank, 

355 page_size, 

356 [Cluster.name], 

357 [PageVersion.address, PageVersion.title], 

358 [Cluster.description], 

359 [PageVersion.content], 

360 ) 

361 

362 latest_pages = ( 

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

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

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

366 .group_by(PageVersion.page_id) 

367 .subquery() 

368 ) 

369 

370 clusters = execute_search_statement( 

371 session, 

372 select(Cluster, rank, snippet) 

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

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

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

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

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

378 ) 

379 

380 return [ 

381 search_pb2.Result( 

382 rank=rank, 

383 community=( 

384 community_to_pb(session, cluster.official_cluster_for_node, context) 

385 if cluster.is_official_cluster 

386 else None 

387 ), 

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

389 snippet=snippet, 

390 ) 

391 for cluster, rank, snippet in clusters 

392 ] 

393 

394 

395def _user_search_inner( 

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

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

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

399 

400 # Base statement with visibility filter 

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

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

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

404 

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

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

407 if len(request.exactly_user_ids) > 0: 

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

409 else: 

410 # Apply all the normal filters 

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

412 if request.query_name_only: 

413 statement = statement.where( 

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

415 ) 

416 else: 

417 statement = statement.where( 

418 or_( 

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

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

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

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

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

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

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

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

427 ) 

428 ) 

429 

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

431 raw_dt = to_aware_datetime(request.last_active) 

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

433 

434 if request.same_gender_only: 

435 if not has_strong_verification(session, user): 

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

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

438 

439 if len(request.hosting_status_filter) > 0: 

440 statement = statement.where( 

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

442 ) 

443 if len(request.meetup_status_filter) > 0: 

444 statement = statement.where( 

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

446 ) 

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

448 statement = statement.where( 

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

450 ) 

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

452 statement = statement.where( 

453 User.sleeping_arrangement.in_( 

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

455 ) 

456 ) 

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

458 statement = statement.where( 

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

460 ) 

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

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

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

464 

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

466 

467 # return results with by language code as only input 

468 # fluency in conversational or fluent 

469 

470 if len(request.language_ability_filter) > 0: 

471 language_options = [] 

472 for ability_filter in request.language_ability_filter: 

473 fluency_sql_value = fluency2sql.get(ability_filter.fluency) 

474 

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

476 continue 

477 language_options.append( 

478 and_( 

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

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

481 ) 

482 ) 

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

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

485 

486 if request.HasField("profile_completed"): 

487 statement = statement.where(User.has_completed_profile == request.profile_completed.value) 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

514 

515 if request.HasField("search_in_area"): 

516 # EPSG4326 measures distance in decimal degress 

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

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

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

520 statement = statement.where( 

521 func.ST_DWithin( 

522 # old: 

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

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

525 # user's geom radius 

526 User.geom, 

527 search_point, 

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

529 ) 

530 ) 

531 if request.HasField("search_in_rectangle"): 

532 statement = statement.where( 

533 func.ST_Within( 

534 User.geom, 

535 func.ST_MakeEnvelope( 

536 request.search_in_rectangle.lng_min, 

537 request.search_in_rectangle.lat_min, 

538 request.search_in_rectangle.lng_max, 

539 request.search_in_rectangle.lat_max, 

540 4326, 

541 ), 

542 ) 

543 ) 

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

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

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

547 if not node: 

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

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

550 

551 if request.only_with_references: 

552 references = ( 

553 where_users_column_visible( 

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

555 context, 

556 Reference.from_user_id, 

557 ) 

558 .distinct() 

559 .subquery() 

560 ) 

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

562 

563 if request.only_with_strong_verification: 

564 statement = statement.join( 

565 StrongVerificationAttempt, 

566 and_( 

567 StrongVerificationAttempt.user_id == User.id, 

568 StrongVerificationAttempt.has_strong_verification(User), 

569 ), 

570 ) 

571 # TODO: 

572 # bool friends_only = 13; 

573 

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

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

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

577 

578 statement = ( 

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

580 .order_by(User.recommendation_score.desc()) 

581 .limit(page_size + 1) 

582 ) 

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

584 users: list[int] = [] 

585 if res: 

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

587 

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

589 return users[:page_size], next_page_token, total_items 

590 

591 

592class Search(search_pb2_grpc.SearchServicer): 

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

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

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

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

597 

598 all_results = ( 

599 _search_users( 

600 session, 

601 request.query, 

602 request.title_only, 

603 next_rank, 

604 page_size, 

605 context, 

606 request.include_users, 

607 ) 

608 + _search_pages( 

609 session, 

610 request.query, 

611 request.title_only, 

612 next_rank, 

613 page_size, 

614 context, 

615 request.include_places, 

616 request.include_guides, 

617 ) 

618 + _search_events( 

619 session, 

620 request.query, 

621 request.title_only, 

622 next_rank, 

623 page_size, 

624 context, 

625 ) 

626 + _search_clusters( 

627 session, 

628 request.query, 

629 request.title_only, 

630 next_rank, 

631 page_size, 

632 context, 

633 request.include_communities, 

634 request.include_groups, 

635 ) 

636 ) 

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

638 return search_pb2.SearchRes( 

639 results=all_results[:page_size], 

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

641 ) 

642 

643 def UserSearch( 

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

645 ) -> search_pb2.UserSearchRes: 

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

647 

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

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

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

651 ).all() 

652 ) 

653 

654 return search_pb2.UserSearchRes( 

655 results=[ 

656 search_pb2.Result( 

657 rank=1, 

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

659 ) 

660 for user_id in user_ids_to_return 

661 ], 

662 next_page_token=next_page_token, 

663 total_items=total_items, 

664 ) 

665 

666 def UserSearchV2( 

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

668 ) -> search_pb2.UserSearchV2Res: 

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

670 

671 LiteUser_by_id = { 

672 lite_user.id: lite_user 

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

674 .scalars() 

675 .all() 

676 } 

677 

678 response_rate_by_id = { 

679 resp_rate.user_id: resp_rate 

680 for resp_rate in session.execute( 

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

682 ) 

683 .scalars() 

684 .all() 

685 } 

686 

687 db_user_data_by_id = { 

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

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

690 select( 

691 User.id, 

692 User.about_me, 

693 User.gender, 

694 User.last_active, 

695 User.hosting_status, 

696 User.meetup_status, 

697 User.joined, 

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

699 ).all() 

700 } 

701 

702 ref_counts_by_user_id = get_num_references(session, user_ids_to_return) 

703 

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

705 lite_user = LiteUser_by_id[user_id] 

706 

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

708 

709 lat, lng = get_coordinates(lite_user.geom) 

710 return search_pb2.SearchUser( 

711 user_id=lite_user.id, 

712 username=lite_user.username, 

713 name=lite_user.name, 

714 city=lite_user.city, 

715 joined=Timestamp_from_datetime(last_active_coarsen(joined)), 

716 has_completed_profile=lite_user.has_completed_profile, 

717 has_completed_my_home=lite_user.has_completed_my_home, 

718 lat=lat, 

719 lng=lng, 

720 profile_snippet=about_me, 

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

722 gender=gender, 

723 age=int(lite_user.age), 

724 last_active=Timestamp_from_datetime(last_active_coarsen(last_active)), 

725 hosting_status=hostingstatus2api[hosting_status], 

726 meetup_status=meetupstatus2api[meetup_status], 

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

728 if lite_user.avatar_filename 

729 else None, 

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

731 if lite_user.avatar_filename 

732 else None, 

733 has_strong_verification=lite_user.has_strong_verification, 

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

735 ) 

736 

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

738 

739 return search_pb2.UserSearchV2Res( 

740 results=results, 

741 next_page_token=next_page_token, 

742 total_items=total_items, 

743 ) 

744 

745 def EventSearch( 

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

747 ) -> search_pb2.EventSearchRes: 

748 statement = ( 

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

750 ) 

751 

752 if request.HasField("query"): 

753 if request.query_title_only: 

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

755 else: 

756 statement = statement.where( 

757 or_( 

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

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

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

761 ) 

762 ) 

763 

764 if request.only_online: 

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

766 elif request.only_offline: 

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

768 

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

770 where_ = [] 

771 

772 if request.subscribed: 

773 statement = statement.outerjoin( 

774 EventSubscription, 

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

776 ) 

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

778 if request.organizing: 

779 statement = statement.outerjoin( 

780 EventOrganizer, 

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

782 ) 

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

784 if request.attending: 

785 statement = statement.outerjoin( 

786 EventOccurrenceAttendee, 

787 and_( 

788 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id, 

789 EventOccurrenceAttendee.user_id == context.user_id, 

790 ), 

791 ) 

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

793 if request.my_communities: 

794 my_communities = ( 

795 session.execute( 

796 select(Node.id) 

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

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

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

800 .where(Cluster.is_official_cluster) 

801 .order_by(Node.id) 

802 .limit(100000) 

803 ) 

804 .scalars() 

805 .all() 

806 ) 

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

808 

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

810 

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

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

813 

814 if request.HasField("search_in_area"): 

815 # EPSG4326 measures distance in decimal degress 

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

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

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

819 statement = statement.where( 

820 func.ST_DWithin( 

821 # old: 

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

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

824 EventOccurrence.geom, 

825 search_point, 

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

827 ) 

828 ) 

829 if request.HasField("search_in_rectangle"): 

830 statement = statement.where( 

831 func.ST_Within( 

832 EventOccurrence.geom, 

833 func.ST_MakeEnvelope( 

834 request.search_in_rectangle.lng_min, 

835 request.search_in_rectangle.lat_min, 

836 request.search_in_rectangle.lng_max, 

837 request.search_in_rectangle.lat_max, 

838 4326, 

839 ), 

840 ) 

841 ) 

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

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

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

845 if not node: 

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

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

848 

849 if request.HasField("after"): 

850 after_time = to_aware_datetime(request.after) 

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

852 if request.HasField("before"): 

853 before_time = to_aware_datetime(request.before) 

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

855 

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

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

858 page_token = ( 

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

860 ) 

861 page_number = request.page_number or 1 

862 # Calculate the offset for pagination 

863 offset = (page_number - 1) * page_size 

864 

865 if not request.past: 

866 cutoff = page_token - timedelta(seconds=1) 

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

868 else: 

869 cutoff = page_token + timedelta(seconds=1) 

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

871 

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

873 # Apply pagination by page number 

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

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

876 

877 return search_pb2.EventSearchRes( 

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

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

880 total_items=total_items, 

881 )