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
« 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"""
5from datetime import timedelta
6from typing import Any, cast
8import grpc
9from sqlalchemy import select
10from sqlalchemy.orm import Session
11from sqlalchemy.sql import and_, func, or_
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)
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
69REGCONFIG = "english"
70TRI_SIMILARITY_THRESHOLD = 0.6
71TRI_SIMILARITY_WEIGHT = 5
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
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
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
124def _similarity(statement: Any, text: str) -> Any:
125 return func.word_similarity(func.unaccent(statement), func.unaccent(text))
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.
141 The four sets are in decreasing order of "importance" for ranking.
143 A should be the "title", the others can be anything.
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)
154 # the tsvector object that we want to search against with our tsquery
155 tsv = _build_tsv(A, B, C, D)
157 # document to generate snippet from
158 doc = _build_doc(A, B, C, D)
160 title = _build_doc(A)
162 # trigram-based text similarity between title and sql statement string
163 sim = _similarity(statement, title)
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")
168 # the snippet with results highlighted
169 snippet = func.ts_headline(REGCONFIG, doc, tsq, "StartSel=**,StopSel=**").label("snippet")
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())
183 else:
184 title = _build_doc(A)
186 # trigram-based text similarity between title and sql statement string
187 sim = _similarity(statement, title)
189 # ranking algo, weigh the similarity a lot, the text-based ranking less
190 rank = sim.label("rank")
192 # used only for headline
193 tsq = func.websearch_to_tsquery(REGCONFIG, statement)
194 doc = _build_doc(A, B, C, D)
196 # the snippet with results highlighted
197 snippet = func.ts_headline(REGCONFIG, doc, tsq, "StartSel=**,StopSel=**").label("snippet")
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())
211 return rank, snippet, execute_search_statement
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 )
236 users = execute_search_statement(session, select(User, rank, snippet).where(users_visible(context)))
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 ]
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 []
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 )
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 )
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 ]
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 )
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 )
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 ]
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 []
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 )
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 )
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 )
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 ]
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()
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)
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 )
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))
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)
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
465 statement = statement.where((User.age >= min_age) & (User.age <= max_age))
467 # return results with by language code as only input
468 # fluency in conversational or fluent
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)
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))
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)
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))
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)
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;
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())
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]
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
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
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 )
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)
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 )
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 )
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)
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 }
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 }
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 }
702 ref_counts_by_user_id = get_num_references(session, user_ids_to_return)
704 def _user_to_search_user(user_id: int) -> search_pb2.SearchUser:
705 lite_user = LiteUser_by_id[user_id]
707 about_me, gender, last_active, hosting_status, meetup_status, joined = db_user_data_by_id[user_id]
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 )
737 results = reranker([_user_to_search_user(user_id) for user_id in user_ids_to_return])
739 return search_pb2.UserSearchV2Res(
740 results=results,
741 next_page_token=next_page_token,
742 total_items=total_items,
743 )
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 )
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 )
764 if request.only_online:
765 statement = statement.where(EventOccurrence.geom == None)
766 elif request.only_offline:
767 statement = statement.where(EventOccurrence.geom != None)
769 if request.subscribed or request.attending or request.organizing or request.my_communities:
770 where_ = []
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))
809 statement = statement.where(or_(*where_))
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)
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))
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)
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
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())
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()
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 )