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