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