Coverage for app/backend/src/couchers/servicers/search.py: 84%
283 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
1"""
2See //docs/search.md for an overview.
3"""
5from typing import Any, cast
7import grpc
8from sqlalchemy import literal_column, select
9from sqlalchemy.orm import Session
10from sqlalchemy.sql import and_, func, or_
12from couchers import urls
13from couchers.context import CouchersContext
14from couchers.crypto import decrypt_page_token, encrypt_page_token
15from couchers.event_log import log_event
16from couchers.helpers.completed_profile import has_completed_profile_expression
17from couchers.helpers.strong_verification import has_strong_verification
18from couchers.materialized_views import LiteUser, UserResponseRate
19from couchers.models import (
20 Cluster,
21 ClusterSubscription,
22 Event,
23 EventOccurrence,
24 EventOccurrenceAttendee,
25 EventOrganizer,
26 EventSubscription,
27 LanguageAbility,
28 Node,
29 Page,
30 PageType,
31 PageVersion,
32 Reference,
33 StrongVerificationAttempt,
34 User,
35)
36from couchers.proto import search_pb2, search_pb2_grpc
37from couchers.reranker import reranker
38from couchers.servicers.api import (
39 fluency2sql,
40 get_num_references,
41 hostingstatus2api,
42 hostingstatus2sql,
43 meetupstatus2api,
44 meetupstatus2sql,
45 parkingdetails2sql,
46 response_rate_to_pb,
47 sleepingarrangement2sql,
48 smokinglocation2sql,
49 user_model_to_pb,
50)
51from couchers.servicers.communities import community_to_pb
52from couchers.servicers.events import apply_occurrence_pagination, event_to_pb, occurrences_next_page_token
53from couchers.servicers.groups import group_to_pb
54from couchers.servicers.pages import page_to_pb
55from couchers.sql import to_bool, users_visible, where_moderated_content_visible, where_users_column_visible
56from couchers.utils import (
57 Timestamp_from_datetime,
58 create_coordinate,
59 get_coordinates,
60 last_active_coarsen,
61 to_aware_datetime,
62)
64# searches are a bit expensive, we'd rather send back a bunch of results at once than lots of small pages
65MAX_PAGINATION_LENGTH = 100
67REGCONFIG = "english"
68TRI_SIMILARITY_THRESHOLD = 0.6
69TRI_SIMILARITY_WEIGHT = 5
72def _join_with_space(coalesces: list[Any]) -> Any:
73 # the objects in coalesces are not strings, so we can't do " ".join(coalesces). They're SQLAlchemy magic.
74 if not coalesces: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 return ""
76 out = coalesces[0]
77 for coalesce in coalesces[1:]:
78 out += " " + coalesce
79 return out
82def _build_tsv(A: list[Any], B: list[Any] | None = None, C: list[Any] | None = None, D: list[Any] | None = None) -> Any:
83 """
84 Given lists for A, B, C, and D, builds a tsvector from them.
85 """
86 B = B or []
87 C = C or []
88 D = D or []
89 # Use literal_column for weight letters to avoid psycopg3 type binding issues
90 # PostgreSQL's setweight expects "char" type (internal single-byte type)
91 tsv: Any = func.setweight(
92 func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in A])),
93 literal_column("'A'"),
94 )
95 if B: 95 ↛ 102line 95 didn't jump to line 102 because the condition on line 95 was always true
96 tsv = tsv.concat(
97 func.setweight(
98 func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in B])),
99 literal_column("'B'"),
100 )
101 )
102 if C:
103 tsv = tsv.concat(
104 func.setweight(
105 func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in C])),
106 literal_column("'C'"),
107 )
108 )
109 if D: 109 ↛ 116line 109 didn't jump to line 116 because the condition on line 109 was always true
110 tsv = tsv.concat(
111 func.setweight(
112 func.to_tsvector(REGCONFIG, _join_with_space([func.coalesce(bit, "") for bit in D])),
113 literal_column("'D'"),
114 )
115 )
116 return tsv
119def _build_doc(A: list[Any], B: list[Any] | None = None, C: list[Any] | None = None, D: list[Any] | None = None) -> Any:
120 """
121 Builds the raw document (without to_tsvector and weighting), used for extracting snippet
122 """
123 B = B or []
124 C = C or []
125 D = D or []
126 doc = _join_with_space([func.coalesce(bit, "") for bit in A])
127 if B:
128 doc += " " + _join_with_space([func.coalesce(bit, "") for bit in B])
129 if C:
130 doc += " " + _join_with_space([func.coalesce(bit, "") for bit in C])
131 if D:
132 doc += " " + _join_with_space([func.coalesce(bit, "") for bit in D])
133 return doc
136def _similarity(statement: Any, text: str) -> Any:
137 return func.word_similarity(func.unaccent(statement), func.unaccent(text))
140def _gen_search_elements(
141 statement: str,
142 title_only: bool,
143 next_rank: float | None,
144 page_size: int,
145 A: list[Any],
146 B: list[Any] | None = None,
147 C: list[Any] | None = None,
148 D: list[Any] | None = None,
149) -> tuple[Any, Any, Any]:
150 """
151 Given an sql statement and four sets of fields, (A, B, C, D), generates a bunch of postgres expressions for full text search.
153 The four sets are in decreasing order of "importance" for ranking.
155 A should be the "title", the others can be anything.
157 If title_only=True, we only perform a trigram search against A only
158 """
159 B = B or []
160 C = C or []
161 D = D or []
162 if not title_only:
163 # a postgres tsquery object that can be used to match against a tsvector
164 tsq = func.websearch_to_tsquery(REGCONFIG, statement)
166 # the tsvector object that we want to search against with our tsquery
167 tsv = _build_tsv(A, B, C, D)
169 # document to generate snippet from
170 doc = _build_doc(A, B, C, D)
172 title = _build_doc(A)
174 # trigram-based text similarity between title and sql statement string
175 sim = _similarity(statement, title)
177 # ranking algo, weigh the similarity a lot, the text-based ranking less
178 rank = (TRI_SIMILARITY_WEIGHT * sim + func.ts_rank_cd(tsv, tsq)).label("rank")
180 # the snippet with results highlighted
181 snippet = func.ts_headline(REGCONFIG, doc, tsq, "StartSel=**,StopSel=**").label("snippet")
183 def execute_search_statement(session: Session, orig_statement: Any) -> list[Any]:
184 """
185 Does the right search filtering, limiting, and ordering for the initial statement
186 """
187 query = (
188 orig_statement.where(or_(tsv.op("@@")(tsq), sim > TRI_SIMILARITY_THRESHOLD))
189 .where(rank <= next_rank if next_rank is not None else True)
190 .order_by(rank.desc())
191 .limit(page_size + 1)
192 )
193 return cast(list[Any], session.execute(query).all())
195 else:
196 title = _build_doc(A)
198 # trigram-based text similarity between title and sql statement string
199 sim = _similarity(statement, title)
201 # ranking algo, weigh the similarity a lot, the text-based ranking less
202 rank = sim.label("rank")
204 # used only for headline
205 tsq = func.websearch_to_tsquery(REGCONFIG, statement)
206 doc = _build_doc(A, B, C, D)
208 # the snippet with results highlighted
209 snippet = func.ts_headline(REGCONFIG, doc, tsq, "StartSel=**,StopSel=**").label("snippet")
211 def execute_search_statement(session: Session, orig_statement: Any) -> list[Any]:
212 """
213 Does the right search filtering, limiting, and ordering for the initial statement
214 """
215 query = (
216 orig_statement.where(sim > TRI_SIMILARITY_THRESHOLD)
217 .where(rank <= next_rank if next_rank is not None else True)
218 .order_by(rank.desc())
219 .limit(page_size + 1)
220 )
221 return cast(list[Any], session.execute(query).all())
223 return rank, snippet, execute_search_statement
226def _search_users(
227 session: Session,
228 search_statement: str,
229 title_only: bool,
230 next_rank: float | None,
231 page_size: int,
232 context: CouchersContext,
233 include_users: bool,
234) -> list[search_pb2.Result]:
235 if not include_users: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
236 return []
237 rank, snippet, execute_search_statement = _gen_search_elements(
238 search_statement,
239 title_only,
240 next_rank,
241 page_size,
242 [User.username, User.name],
243 [User.city],
244 [User.about_me],
245 [User.things_i_like, User.about_place, User.additional_information],
246 )
248 users = execute_search_statement(session, select(User, rank, snippet).where(users_visible(context)))
250 return [
251 search_pb2.Result(
252 rank=rank,
253 user=user_model_to_pb(page, session, context),
254 snippet=snippet,
255 )
256 for page, rank, snippet in users
257 ]
260def _search_pages(
261 session: Session,
262 search_statement: str,
263 title_only: bool,
264 next_rank: float | None,
265 page_size: int,
266 context: CouchersContext,
267 include_places: bool,
268 include_guides: bool,
269) -> list[search_pb2.Result]:
270 rank, snippet, execute_search_statement = _gen_search_elements(
271 search_statement,
272 title_only,
273 next_rank,
274 page_size,
275 [PageVersion.title],
276 [PageVersion.address],
277 [],
278 [PageVersion.content],
279 )
280 if not include_places and not include_guides: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 return []
283 latest_pages = (
284 select(func.max(PageVersion.id).label("id"))
285 .join(Page, Page.id == PageVersion.page_id)
286 .where(
287 or_(
288 (Page.type == PageType.place) if include_places else to_bool(False),
289 (Page.type == PageType.guide) if include_guides else to_bool(False),
290 )
291 )
292 .group_by(PageVersion.page_id)
293 .subquery()
294 )
296 pages = execute_search_statement(
297 session,
298 select(Page, rank, snippet)
299 .join(PageVersion, PageVersion.page_id == Page.id)
300 .join(latest_pages, latest_pages.c.id == PageVersion.id),
301 )
303 return [
304 search_pb2.Result(
305 rank=rank,
306 place=page_to_pb(session, page, context) if page.type == PageType.place else None,
307 guide=page_to_pb(session, page, context) if page.type == PageType.guide else None,
308 snippet=snippet,
309 )
310 for page, rank, snippet in pages
311 ]
314def _search_events(
315 session: Session,
316 search_statement: str,
317 title_only: bool,
318 next_rank: float | None,
319 page_size: int,
320 context: CouchersContext,
321) -> list[search_pb2.Result]:
322 rank, snippet, execute_search_statement = _gen_search_elements(
323 search_statement,
324 title_only,
325 next_rank,
326 page_size,
327 [Event.title],
328 [EventOccurrence.address],
329 [],
330 [EventOccurrence.content],
331 )
333 occurrences = execute_search_statement(
334 session,
335 where_moderated_content_visible(
336 select(EventOccurrence, rank, snippet)
337 .join(Event, Event.id == EventOccurrence.event_id)
338 .where(EventOccurrence.end_time >= func.now()),
339 context,
340 EventOccurrence,
341 is_list_operation=True,
342 ),
343 )
345 return [
346 search_pb2.Result(
347 rank=rank,
348 event=event_to_pb(session, occurrence, context),
349 snippet=snippet,
350 )
351 for occurrence, rank, snippet in occurrences
352 ]
355def _search_clusters(
356 session: Session,
357 search_statement: str,
358 title_only: bool,
359 next_rank: float | None,
360 page_size: int,
361 context: CouchersContext,
362 include_communities: bool,
363 include_groups: bool,
364) -> list[search_pb2.Result]:
365 if not include_communities and not include_groups: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true
366 return []
368 rank, snippet, execute_search_statement = _gen_search_elements(
369 search_statement,
370 title_only,
371 next_rank,
372 page_size,
373 [Cluster.name],
374 [PageVersion.address, PageVersion.title],
375 [Cluster.description],
376 [PageVersion.content],
377 )
379 latest_pages = (
380 select(func.max(PageVersion.id).label("id"))
381 .join(Page, Page.id == PageVersion.page_id)
382 .where(Page.type == PageType.main_page)
383 .group_by(PageVersion.page_id)
384 .subquery()
385 )
387 clusters = execute_search_statement(
388 session,
389 select(Cluster, rank, snippet)
390 .join(Page, Page.owner_cluster_id == Cluster.id)
391 .join(PageVersion, PageVersion.page_id == Page.id)
392 .join(latest_pages, latest_pages.c.id == PageVersion.id)
393 .where(Cluster.is_official_cluster if include_communities and not include_groups else to_bool(True))
394 .where(~Cluster.is_official_cluster if not include_communities and include_groups else to_bool(True)),
395 )
397 return [
398 search_pb2.Result(
399 rank=rank,
400 community=(
401 community_to_pb(session, cluster.official_cluster_for_node, context)
402 if cluster.is_official_cluster
403 else None
404 ),
405 group=group_to_pb(session, cluster, context) if not cluster.is_official_cluster else None,
406 snippet=snippet,
407 )
408 for cluster, rank, snippet in clusters
409 ]
412def _user_search_inner(
413 request: search_pb2.UserSearchReq, context: CouchersContext, session: Session
414) -> tuple[list[int], str | None, int]:
415 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
417 # Base statement with visibility filter
418 statement = select(User.id, User.recommendation_score).where(users_visible(context))
419 # make sure that only users who are in LiteUser show up
420 statement = statement.join(LiteUser, LiteUser.id == User.id)
422 # If exactly_user_ids is present, only filter by those IDs and ignore all other filters
423 # This is a bit of a hacky feature to help with the frontend map implementation
424 if len(request.exactly_user_ids) > 0:
425 statement = statement.where(User.id.in_(request.exactly_user_ids))
426 else:
427 # Apply all the normal filters
428 if request.HasField("query"): 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true
429 if request.query_name_only:
430 statement = statement.where(
431 or_(User.name.ilike(f"%{request.query.value}%"), User.username.ilike(f"%{request.query.value}%"))
432 )
433 else:
434 statement = statement.where(
435 or_(
436 User.name.ilike(f"%{request.query.value}%"),
437 User.username.ilike(f"%{request.query.value}%"),
438 User.city.ilike(f"%{request.query.value}%"),
439 User.hometown.ilike(f"%{request.query.value}%"),
440 User.about_me.ilike(f"%{request.query.value}%"),
441 User.things_i_like.ilike(f"%{request.query.value}%"),
442 User.about_place.ilike(f"%{request.query.value}%"),
443 User.additional_information.ilike(f"%{request.query.value}%"),
444 )
445 )
447 if request.HasField("last_active"): 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true
448 raw_dt = to_aware_datetime(request.last_active)
449 statement = statement.where(User.last_active >= last_active_coarsen(raw_dt))
451 if request.same_gender_only:
452 if not has_strong_verification(session, user):
453 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "need_strong_verification")
454 statement = statement.where(User.gender == user.gender)
456 if len(request.hosting_status_filter) > 0:
457 statement = statement.where(
458 User.hosting_status.in_([hostingstatus2sql[status] for status in request.hosting_status_filter])
459 )
460 if len(request.meetup_status_filter) > 0:
461 statement = statement.where(
462 User.meetup_status.in_([meetupstatus2sql[status] for status in request.meetup_status_filter])
463 )
464 if len(request.smoking_location_filter) > 0: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true
465 statement = statement.where(
466 User.smoking_allowed.in_([smokinglocation2sql[loc] for loc in request.smoking_location_filter])
467 )
468 if len(request.sleeping_arrangement_filter) > 0: 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true
469 statement = statement.where(
470 User.sleeping_arrangement.in_(
471 [sleepingarrangement2sql[arr] for arr in request.sleeping_arrangement_filter]
472 )
473 )
474 if len(request.parking_details_filter) > 0: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 statement = statement.where(
476 User.parking_details.in_([parkingdetails2sql[det] for det in request.parking_details_filter])
477 )
478 # limits/default could be handled on the front end as well
479 min_age = request.age_min.value if request.HasField("age_min") else 18
480 max_age = request.age_max.value if request.HasField("age_max") else 200
482 statement = statement.where((User.age >= min_age) & (User.age <= max_age))
484 # return results with by language code as only input
485 # fluency in conversational or fluent
487 if len(request.language_ability_filter) > 0:
488 language_options = []
489 for ability_filter in request.language_ability_filter:
490 fluency_sql_value = fluency2sql.get(ability_filter.fluency)
492 if fluency_sql_value is None: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true
493 continue
494 language_options.append(
495 and_(
496 (LanguageAbility.language_code == ability_filter.code),
497 (LanguageAbility.fluency >= (fluency_sql_value)),
498 )
499 )
500 statement = statement.join(LanguageAbility, LanguageAbility.user_id == User.id)
501 statement = statement.where(or_(*language_options))
503 if request.HasField("profile_completed"):
504 statement = statement.where(has_completed_profile_expression() == request.profile_completed.value)
505 if request.HasField("guests"): 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true
506 statement = statement.where(User.max_guests >= request.guests.value)
507 if request.HasField("last_minute"): 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true
508 statement = statement.where(User.last_minute == request.last_minute.value)
509 if request.HasField("has_pets"): 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 statement = statement.where(User.has_pets == request.has_pets.value)
511 if request.HasField("accepts_pets"): 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true
512 statement = statement.where(User.accepts_pets == request.accepts_pets.value)
513 if request.HasField("has_kids"): 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true
514 statement = statement.where(User.has_kids == request.has_kids.value)
515 if request.HasField("accepts_kids"): 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true
516 statement = statement.where(User.accepts_kids == request.accepts_kids.value)
517 if request.HasField("has_housemates"): 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true
518 statement = statement.where(User.has_housemates == request.has_housemates.value)
519 if request.HasField("wheelchair_accessible"): 519 ↛ 520line 519 didn't jump to line 520 because the condition on line 519 was never true
520 statement = statement.where(User.wheelchair_accessible == request.wheelchair_accessible.value)
521 if request.HasField("smokes_at_home"): 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true
522 statement = statement.where(User.smokes_at_home == request.smokes_at_home.value)
523 if request.HasField("drinking_allowed"): 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true
524 statement = statement.where(User.drinking_allowed == request.drinking_allowed.value)
525 if request.HasField("drinks_at_home"): 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true
526 statement = statement.where(User.drinks_at_home == request.drinks_at_home.value)
527 if request.HasField("parking"): 527 ↛ 528line 527 didn't jump to line 528 because the condition on line 527 was never true
528 statement = statement.where(User.parking == request.parking.value)
529 if request.HasField("camping_ok"): 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true
530 statement = statement.where(User.camping_ok == request.camping_ok.value)
532 if request.HasField("search_in_area"):
533 # EPSG4326 measures distance in decimal degress
534 # we want to check whether two circles overlap, so check if the distance between their centers is less
535 # than the sum of their radii, divided by 111111 m ~= 1 degree (at the equator)
536 search_point = create_coordinate(request.search_in_area.lat, request.search_in_area.lng)
537 statement = statement.where(
538 func.ST_DWithin(
539 # old:
540 # User.geom, search_point, (User.geom_radius + request.search_in_area.radius) / 111111
541 # this is an optimization that speeds up the db queries since it doesn't need to look up the
542 # user's geom radius
543 User.geom,
544 search_point,
545 (1000 + request.search_in_area.radius) / 111111,
546 )
547 )
548 if request.HasField("search_in_rectangle"):
549 statement = statement.where(
550 func.ST_Within(
551 User.geom,
552 func.ST_MakeEnvelope(
553 request.search_in_rectangle.lng_min,
554 request.search_in_rectangle.lat_min,
555 request.search_in_rectangle.lng_max,
556 request.search_in_rectangle.lat_max,
557 4326,
558 ),
559 )
560 )
561 if request.HasField("search_in_community_id"): 561 ↛ 563line 561 didn't jump to line 563 because the condition on line 561 was never true
562 # could do a join here as well, but this is just simpler
563 node = session.execute(select(Node).where(Node.id == request.search_in_community_id)).scalar_one_or_none()
564 if not node:
565 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
566 statement = statement.where(func.ST_Contains(node.geom, User.geom))
568 if request.only_with_references:
569 references = (
570 where_users_column_visible(
571 select(Reference.to_user_id.label("user_id")),
572 context,
573 Reference.from_user_id,
574 )
575 .distinct()
576 .subquery()
577 )
578 statement = statement.join(references, references.c.user_id == User.id)
580 if request.only_with_strong_verification:
581 statement = statement.join(
582 StrongVerificationAttempt,
583 and_(
584 StrongVerificationAttempt.user_id == User.id,
585 StrongVerificationAttempt.has_strong_verification(User),
586 ),
587 )
588 # TODO:
589 # bool friends_only = 13;
591 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
592 next_recommendation_score = float(decrypt_page_token(request.page_token)) if request.page_token else 1e10
593 total_items = cast(int, session.execute(select(func.count()).select_from(statement.subquery())).scalar())
595 statement = (
596 statement.where(User.recommendation_score <= next_recommendation_score)
597 .order_by(User.recommendation_score.desc())
598 .limit(page_size + 1)
599 )
600 res = session.execute(statement).all()
601 users: list[int] = []
602 if res:
603 users, rec_scores = zip(*res) # type: ignore[assignment]
605 next_page_token = encrypt_page_token(str(rec_scores[-1])) if len(users) > page_size else None
606 return users[:page_size], next_page_token, total_items
609class Search(search_pb2_grpc.SearchServicer):
610 def Search(self, request: search_pb2.SearchReq, context: CouchersContext, session: Session) -> search_pb2.SearchRes:
611 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
612 # this is not an ideal page token, some results have equal rank (unlikely)
613 next_rank = float(request.page_token) if request.page_token else None
615 all_results = (
616 _search_users(
617 session,
618 request.query,
619 request.title_only,
620 next_rank,
621 page_size,
622 context,
623 request.include_users,
624 )
625 + _search_pages(
626 session,
627 request.query,
628 request.title_only,
629 next_rank,
630 page_size,
631 context,
632 request.include_places,
633 request.include_guides,
634 )
635 + _search_events(
636 session,
637 request.query,
638 request.title_only,
639 next_rank,
640 page_size,
641 context,
642 )
643 + _search_clusters(
644 session,
645 request.query,
646 request.title_only,
647 next_rank,
648 page_size,
649 context,
650 request.include_communities,
651 request.include_groups,
652 )
653 )
654 all_results.sort(key=lambda result: result.rank, reverse=True)
655 return search_pb2.SearchRes(
656 results=all_results[:page_size],
657 next_page_token=str(all_results[page_size].rank) if len(all_results) > page_size else None,
658 )
660 def UserSearch(
661 self, request: search_pb2.UserSearchReq, context: CouchersContext, session: Session
662 ) -> search_pb2.UserSearchRes:
663 user_ids_to_return, next_page_token, total_items = _user_search_inner(request, context, session)
665 log_event(
666 context,
667 session,
668 "search.performed",
669 {
670 "search_in": request.WhichOneof("search_in"),
671 "has_query": request.HasField("query"),
672 "has_filters": (
673 len(request.hosting_status_filter) > 0
674 or len(request.meetup_status_filter) > 0
675 or len(request.smoking_location_filter) > 0
676 or len(request.sleeping_arrangement_filter) > 0
677 or len(request.parking_details_filter) > 0
678 or len(request.language_ability_filter) > 0
679 or request.only_with_references
680 or request.only_with_strong_verification
681 ),
682 "total_items": total_items,
683 },
684 )
686 user_ids_to_users: dict[int, User] = dict(
687 session.execute( # type: ignore[arg-type]
688 select(User.id, User).where(User.id.in_(user_ids_to_return))
689 ).all()
690 )
692 return search_pb2.UserSearchRes(
693 results=[
694 search_pb2.Result(
695 rank=1,
696 user=user_model_to_pb(user_ids_to_users[user_id], session, context),
697 )
698 for user_id in user_ids_to_return
699 ],
700 next_page_token=next_page_token,
701 total_items=total_items,
702 )
704 def UserSearchV2(
705 self, request: search_pb2.UserSearchReq, context: CouchersContext, session: Session
706 ) -> search_pb2.UserSearchV2Res:
707 user_ids_to_return, next_page_token, total_items = _user_search_inner(request, context, session)
709 LiteUser_by_id = {
710 lite_user.id: lite_user
711 for lite_user in session.execute(select(LiteUser).where(LiteUser.id.in_(user_ids_to_return)))
712 .scalars()
713 .all()
714 }
716 response_rate_by_id = {
717 resp_rate.user_id: resp_rate
718 for resp_rate in session.execute(
719 select(UserResponseRate).where(UserResponseRate.user_id.in_(user_ids_to_return))
720 )
721 .scalars()
722 .all()
723 }
725 db_user_data_by_id = {
726 user_id: (about_me, gender, last_active, hosting_status, meetup_status, joined)
727 for user_id, about_me, gender, last_active, hosting_status, meetup_status, joined in session.execute(
728 select(
729 User.id,
730 User.about_me,
731 User.gender,
732 User.last_active,
733 User.hosting_status,
734 User.meetup_status,
735 User.joined,
736 ).where(User.id.in_(user_ids_to_return))
737 ).all()
738 }
740 ref_counts_by_user_id = get_num_references(session, context, user_ids_to_return)
742 def _user_to_search_user(user_id: int) -> search_pb2.SearchUser:
743 lite_user = LiteUser_by_id[user_id]
745 about_me, gender, last_active, hosting_status, meetup_status, joined = db_user_data_by_id[user_id]
747 lat, lng = get_coordinates(lite_user.geom)
748 return search_pb2.SearchUser(
749 user_id=lite_user.id,
750 username=lite_user.username,
751 name=lite_user.name,
752 city=lite_user.city,
753 joined=Timestamp_from_datetime(last_active_coarsen(joined)),
754 has_completed_profile=lite_user.has_completed_profile,
755 has_completed_my_home=lite_user.has_completed_my_home,
756 lat=lat,
757 lng=lng,
758 profile_snippet=about_me,
759 num_references=ref_counts_by_user_id.get(lite_user.id, 0),
760 gender=gender,
761 age=int(lite_user.age),
762 last_active=Timestamp_from_datetime(last_active_coarsen(last_active)),
763 hosting_status=hostingstatus2api[hosting_status],
764 meetup_status=meetupstatus2api[meetup_status],
765 avatar_url=urls.media_url(filename=lite_user.avatar_filename, size="full")
766 if lite_user.avatar_filename
767 else None,
768 avatar_thumbnail_url=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail")
769 if lite_user.avatar_filename
770 else None,
771 has_strong_verification=lite_user.has_strong_verification,
772 **response_rate_to_pb(response_rate_by_id.get(user_id)),
773 )
775 results = reranker([_user_to_search_user(user_id) for user_id in user_ids_to_return])
777 return search_pb2.UserSearchV2Res(
778 results=results,
779 next_page_token=next_page_token,
780 total_items=total_items,
781 )
783 def EventSearch(
784 self, request: search_pb2.EventSearchReq, context: CouchersContext, session: Session
785 ) -> search_pb2.EventSearchRes:
786 if request.attending and request.exclude_attending:
787 context.abort_with_error_code(
788 grpc.StatusCode.INVALID_ARGUMENT, "cannot_combine_attending_and_exclude_attending"
789 )
790 statement = (
791 select(EventOccurrence).join(Event, Event.id == EventOccurrence.event_id).where(~EventOccurrence.is_deleted)
792 )
793 statement = where_moderated_content_visible(statement, context, EventOccurrence, is_list_operation=True)
795 if request.HasField("query"):
796 if request.query_title_only:
797 statement = statement.where(Event.title.ilike(f"%{request.query.value}%"))
798 else:
799 statement = statement.where(
800 or_(
801 Event.title.ilike(f"%{request.query.value}%"),
802 EventOccurrence.content.ilike(f"%{request.query.value}%"),
803 EventOccurrence.address.ilike(f"%{request.query.value}%"),
804 )
805 )
807 if (
808 request.subscribed
809 or request.attending
810 or request.organizing
811 or request.my_communities
812 or request.exclude_attending
813 ):
814 where_ = []
816 if request.subscribed:
817 statement = statement.outerjoin(
818 EventSubscription,
819 and_(EventSubscription.event_id == Event.id, EventSubscription.user_id == context.user_id),
820 )
821 where_.append(EventSubscription.user_id != None)
822 if request.organizing or request.attending:
823 if request.organizing:
824 statement = statement.outerjoin(
825 EventOrganizer,
826 and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id),
827 )
828 where_.append(EventOrganizer.user_id != None)
829 if request.attending:
830 statement = statement.outerjoin(
831 EventOccurrenceAttendee,
832 and_(
833 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id,
834 EventOccurrenceAttendee.user_id == context.user_id,
835 ),
836 )
837 where_.append(EventOccurrenceAttendee.user_id != None)
838 elif request.exclude_attending:
839 statement = statement.outerjoin(
840 EventOrganizer,
841 and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id),
842 )
843 statement = statement.outerjoin(
844 EventOccurrenceAttendee,
845 and_(
846 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id,
847 EventOccurrenceAttendee.user_id == context.user_id,
848 ),
849 )
850 if request.my_communities:
851 my_communities = (
852 session.execute(
853 select(Node.id)
854 .join(Cluster, Cluster.parent_node_id == Node.id)
855 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id)
856 .where(ClusterSubscription.user_id == context.user_id)
857 .where(Cluster.is_official_cluster)
858 .order_by(Node.id)
859 .limit(100000)
860 )
861 .scalars()
862 .all()
863 )
864 where_.append(Event.parent_node_id.in_(my_communities))
866 if where_:
867 statement = statement.where(or_(*where_))
869 if request.exclude_attending:
870 statement = statement.where(EventOccurrenceAttendee.user_id == None, EventOrganizer.user_id == None)
872 if not request.include_cancelled: 872 ↛ 875line 872 didn't jump to line 875 because the condition on line 872 was always true
873 statement = statement.where(~EventOccurrence.is_cancelled)
875 if request.HasField("search_in_area"):
876 # EPSG4326 measures distance in decimal degress
877 # we want to check whether two circles overlap, so check if the distance between their centers is less
878 # than the sum of their radii, divided by 111111 m ~= 1 degree (at the equator)
879 search_point = create_coordinate(request.search_in_area.lat, request.search_in_area.lng)
880 statement = statement.where(
881 func.ST_DWithin(
882 # old:
883 # User.geom, search_point, (User.geom_radius + request.search_in_area.radius) / 111111
884 # this is an optimization that speeds up the db queries since it doesn't need to look up the user's geom radius
885 EventOccurrence.geom,
886 search_point,
887 (1000 + request.search_in_area.radius) / 111111,
888 )
889 )
890 if request.HasField("search_in_rectangle"):
891 statement = statement.where(
892 func.ST_Within(
893 EventOccurrence.geom,
894 func.ST_MakeEnvelope(
895 request.search_in_rectangle.lng_min,
896 request.search_in_rectangle.lat_min,
897 request.search_in_rectangle.lng_max,
898 request.search_in_rectangle.lat_max,
899 4326,
900 ),
901 )
902 )
903 if request.HasField("search_in_community_id"): 903 ↛ 905line 903 didn't jump to line 905 because the condition on line 903 was never true
904 # could do a join here as well, but this is just simpler
905 node = session.execute(select(Node).where(Node.id == request.search_in_community_id)).scalar_one_or_none()
906 if not node:
907 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
908 statement = statement.where(func.ST_Contains(node.geom, EventOccurrence.geom))
910 if request.HasField("after"):
911 after_time = to_aware_datetime(request.after)
912 statement = statement.where(EventOccurrence.start_time > after_time)
913 if request.HasField("before"):
914 before_time = to_aware_datetime(request.before)
915 statement = statement.where(EventOccurrence.end_time < before_time)
917 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
918 # the page token is ignored when a page number is given
919 page_token = request.page_token if not request.page_number else ""
920 page_number = request.page_number or 1
921 # Calculate the offset for pagination
922 offset = (page_number - 1) * page_size
924 statement = apply_occurrence_pagination(statement, page_token, request.past)
926 total_items = session.execute(select(func.count()).select_from(statement.subquery())).scalar()
927 # Apply pagination by page number
928 statement = statement.offset(offset).limit(page_size) if request.page_number else statement.limit(page_size + 1)
929 occurrences = session.execute(statement).scalars().all()
931 return search_pb2.EventSearchRes(
932 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
933 next_page_token=occurrences_next_page_token(occurrences, page_size),
934 total_items=total_items,
935 )