Skip to content

Commit

Permalink
Event search by location (#4590)
Browse files Browse the repository at this point in the history
* Implemented event search
* Implemented event search tests
* Add event search filters for online status, subscription, attendance, organization and my communities
  • Loading branch information
spreeni authored Aug 18, 2024
1 parent 205696b commit bf91efb
Show file tree
Hide file tree
Showing 5 changed files with 526 additions and 9 deletions.
1 change: 1 addition & 0 deletions app/backend/src/couchers/servicers/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ def CreateEvent(self, request, context):
link = request.online_information.link
elif request.HasField("offline_information"):
online = False
# As protobuf parses a missing value as 0.0, this is not a permitted event coordinate value
if not (
request.offline_information.address
and request.offline_information.lat
Expand Down
164 changes: 161 additions & 3 deletions app/backend/src/couchers/servicers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,29 @@
See //docs/search.md for overview.
"""

from datetime import timedelta

import grpc
from sqlalchemy.sql import func, or_
from sqlalchemy.sql import and_, func, or_

from couchers import errors
from couchers.crypto import decrypt_page_token, encrypt_page_token
from couchers.db import session_scope
from couchers.models import Cluster, Event, EventOccurrence, Node, Page, PageType, PageVersion, Reference, User
from couchers.models import (
Cluster,
ClusterSubscription,
Event,
EventOccurrence,
EventOccurrenceAttendee,
EventOrganizer,
EventSubscription,
Node,
Page,
PageType,
PageVersion,
Reference,
User,
)
from couchers.servicers.api import (
hostingstatus2sql,
meetupstatus2sql,
Expand All @@ -22,7 +38,14 @@
from couchers.servicers.groups import group_to_pb
from couchers.servicers.pages import page_to_pb
from couchers.sql import couchers_select as select
from couchers.utils import create_coordinate, last_active_coarsen, to_aware_datetime
from couchers.utils import (
create_coordinate,
dt_from_millis,
last_active_coarsen,
millis_from_dt,
now,
to_aware_datetime,
)
from proto import search_pb2, search_pb2_grpc

# searches are a bit expensive, we'd rather send back a bunch of results at once than lots of small pages
Expand Down Expand Up @@ -514,3 +537,138 @@ def UserSearch(self, request, context):
encrypt_page_token(str(users[-1].recommendation_score)) if len(users) > page_size else None
),
)

def EventSearch(self, request, context):
with session_scope() as session:
statement = (
select(EventOccurrence)
.join(Event, Event.id == EventOccurrence.event_id)
.where(~EventOccurrence.is_deleted)
)

if request.HasField("query"):
if request.query_title_only:
statement = statement.where(Event.title.ilike(f"%{request.query.value}%"))
else:
statement = statement.where(
or_(
Event.title.ilike(f"%{request.query.value}%"),
EventOccurrence.content.ilike(f"%{request.query.value}%"),
EventOccurrence.address.ilike(f"%{request.query.value}%"),
)
)

if request.only_online:
statement = statement.where(EventOccurrence.geom == None)
elif request.only_offline:
statement = statement.where(EventOccurrence.geom != None)

if request.subscribed or request.attending or request.organizing or request.my_communities:
where_ = []

if request.subscribed:
statement = statement.outerjoin(
EventSubscription,
and_(EventSubscription.event_id == Event.id, EventSubscription.user_id == context.user_id),
)
where_.append(EventSubscription.user_id != None)
if request.organizing:
statement = statement.outerjoin(
EventOrganizer,
and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id),
)
where_.append(EventOrganizer.user_id != None)
if request.attending:
statement = statement.outerjoin(
EventOccurrenceAttendee,
and_(
EventOccurrenceAttendee.occurrence_id == EventOccurrence.id,
EventOccurrenceAttendee.user_id == context.user_id,
),
)
where_.append(EventOccurrenceAttendee.user_id != None)
if request.my_communities:
my_communities = (
session.execute(
select(Node.id)
.join(Cluster, Cluster.parent_node_id == Node.id)
.join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id)
.where(ClusterSubscription.user_id == context.user_id)
.where(Cluster.is_official_cluster)
.order_by(Node.id)
.limit(100000)
)
.scalars()
.all()
)
where_.append(Event.parent_node_id.in_(my_communities))

statement = statement.where(or_(*where_))

if not request.include_cancelled:
statement = statement.where(~EventOccurrence.is_cancelled)

if request.HasField("search_in_area"):
# EPSG4326 measures distance in decimal degress
# we want to check whether two circles overlap, so check if the distance between their centers is less
# than the sum of their radii, divided by 111111 m ~= 1 degree (at the equator)
search_point = create_coordinate(request.search_in_area.lat, request.search_in_area.lng)
statement = statement.where(
func.ST_DWithin(
# old:
# User.geom, search_point, (User.geom_radius + request.search_in_area.radius) / 111111
# this is an optimization that speeds up the db queries since it doesn't need to look up the user's geom radius
EventOccurrence.geom,
search_point,
(1000 + request.search_in_area.radius) / 111111,
)
)
if request.HasField("search_in_rectangle"):
statement = statement.where(
func.ST_Within(
EventOccurrence.geom,
func.ST_MakeEnvelope(
request.search_in_rectangle.lng_min,
request.search_in_rectangle.lat_min,
request.search_in_rectangle.lng_max,
request.search_in_rectangle.lat_max,
4326,
),
)
)
if request.HasField("search_in_community_id"):
# could do a join here as well, but this is just simpler
node = session.execute(
select(Node).where(Node.id == request.search_in_community_id)
).scalar_one_or_none()
if not node:
context.abort(grpc.StatusCode.NOT_FOUND, errors.COMMUNITY_NOT_FOUND)
statement = statement.where(func.ST_Contains(node.geom, EventOccurrence.geom))

if request.HasField("after"):
statement = statement.where(EventOccurrence.start_time > to_aware_datetime(request.after))
if request.HasField("before"):
statement = statement.where(EventOccurrence.end_time < to_aware_datetime(request.before))

page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
# the page token is a unix timestamp of where we left off
page_token = dt_from_millis(int(request.page_token)) if request.page_token else now()

if not request.past:
statement = statement.where(EventOccurrence.end_time > page_token - timedelta(seconds=1)).order_by(
EventOccurrence.start_time.asc()
)
else:
statement = statement.where(EventOccurrence.end_time < page_token + timedelta(seconds=1)).order_by(
EventOccurrence.start_time.desc()
)

statement = statement.limit(page_size + 1)
occurrences = session.execute(statement).scalars().all()

return search_pb2.EventSearchRes(
events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
next_page_token=(
str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None
),
)
Loading

0 comments on commit bf91efb

Please sign in to comment.