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