Coverage for app/backend/src/couchers/servicers/public_trips.py: 84%
191 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 15:12 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 15:12 +0000
1import logging
2from datetime import date, timedelta
4import grpc
5from sqlalchemy import ColumnElement, and_, func, or_, select
6from sqlalchemy.orm import Session, selectinload
8from couchers.constants import PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16
9from couchers.context import CouchersContext
10from couchers.db import can_moderate_node
11from couchers.event_log import log_event
12from couchers.helpers.completed_profile import has_completed_profile
13from couchers.models import Node, User
14from couchers.models.host_requests import HostRequest, HostRequestStatus
15from couchers.models.public_trips import PublicTrip, PublicTripStatus
16from couchers.proto import public_trips_pb2, public_trips_pb2_grpc
17from couchers.servicers.api import user_model_to_pb
18from couchers.sql import to_bool, where_users_column_visible
19from couchers.utils import Timestamp_from_datetime, date_to_api, parse_date, today, today_in_timezone
21logger = logging.getLogger(__name__)
23MAX_PAGINATION_LENGTH = 25
24PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000
26publictripstatus2api = {
27 PublicTripStatus.searching_for_host: public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
28 PublicTripStatus.closed: public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
29}
31publictripstatus2sql = {
32 public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST: PublicTripStatus.searching_for_host,
33 public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED: PublicTripStatus.closed,
34}
37def _is_description_long_enough(text: str) -> bool:
38 # Match Javascript's string.length (utf16 code units) rather than Python's len()
39 # so the backend check aligns with the frontend character counter.
40 text_length_utf16 = len(text.encode("utf-16-le")) // 2
41 return text_length_utf16 >= PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16
44def _parse_page_token(page_token: str) -> tuple[date | None, int | None]:
45 """Parse a page token into (from_date, trip_id). Returns (None, None) for first page."""
46 if not page_token: 46 ↛ 48line 46 didn't jump to line 48 because the condition on line 46 was always true
47 return None, None
48 date_str, id_str = page_token.rsplit(":", 1)
49 return date.fromisoformat(date_str), int(id_str)
52def _same_gender_filter(context: CouchersContext) -> ColumnElement[bool]:
53 # Show the trip if same_gender_only is off or the viewer's gender matches the poster's gender.
54 # Moderator bypass is handled by callers via can_moderate_node before applying this filter.
55 # Uses scalar subqueries rather than extra joins since where_users_column_visible
56 # already joins User on PublicTrip.user_id.
57 viewer_gender = select(User.gender).where(User.id == context.user_id).scalar_subquery()
58 poster_gender = select(User.gender).where(User.id == PublicTrip.user_id).scalar_subquery()
59 return or_(~PublicTrip.same_gender_only, poster_gender == viewer_gender)
62def public_trip_to_pb(
63 public_trip: PublicTrip, session: Session, context: CouchersContext
64) -> public_trips_pb2.PublicTrip:
65 pb = public_trips_pb2.PublicTrip(
66 trip_id=public_trip.id,
67 user=user_model_to_pb(public_trip.user, session, context),
68 community_id=public_trip.node_id,
69 community_slug=public_trip.node.official_cluster.slug,
70 community_name=public_trip.node.official_cluster.name,
71 from_date=date_to_api(public_trip.from_date),
72 to_date=date_to_api(public_trip.to_date),
73 description=public_trip.description,
74 status=publictripstatus2api[public_trip.status],
75 created=Timestamp_from_datetime(public_trip.created),
76 same_gender_only=public_trip.same_gender_only,
77 )
78 if public_trip.user_id == context.user_id:
79 pb.offers_count = session.execute(
80 select(func.count())
81 .select_from(HostRequest)
82 .where(HostRequest.public_trip_id == public_trip.id)
83 .where(HostRequest.status != HostRequestStatus.cancelled)
84 ).scalar_one()
85 else:
86 # The viewer's own existing offer on this trip (if any), so the client can
87 # show an "already offered" state and link to the thread.
88 pb.viewer_host_request_id = (
89 session.execute(
90 select(HostRequest.conversation_id)
91 .where(HostRequest.public_trip_id == public_trip.id)
92 .where(HostRequest.initiator_user_id == context.user_id)
93 .where(HostRequest.status != HostRequestStatus.cancelled)
94 ).scalar_one_or_none()
95 or 0
96 )
97 return pb
100class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer):
101 def CreatePublicTrip(
102 self, request: public_trips_pb2.CreatePublicTripReq, context: CouchersContext, session: Session
103 ) -> public_trips_pb2.PublicTrip:
104 if not context.get_boolean_value("public_trips_enabled", False): 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
107 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
108 if not has_completed_profile(session, user):
109 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip")
111 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
112 if not node:
113 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
115 if not node.official_cluster.small_community_features_enabled:
116 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trips_not_enabled")
118 from_date = parse_date(request.from_date)
119 to_date = parse_date(request.to_date)
121 if not from_date or not to_date:
122 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
124 today = today_in_timezone(node.timezone)
126 if from_date < today:
127 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
129 if from_date > to_date:
130 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
132 if from_date - today > timedelta(days=365): 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
135 if to_date - from_date > timedelta(days=365): 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
138 if not request.description.strip():
139 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
141 if not _is_description_long_enough(request.description):
142 context.abort_with_error_code(
143 grpc.StatusCode.INVALID_ARGUMENT,
144 "public_trip_description_too_short",
145 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
146 )
148 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
151 # Disallow overlapping active trips by the same user in the same community
152 existing = session.execute(
153 select(PublicTrip)
154 .where(PublicTrip.user_id == context.user_id)
155 .where(PublicTrip.node_id == node.id)
156 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
157 .where(PublicTrip.to_date >= from_date)
158 .where(PublicTrip.from_date <= to_date)
159 ).scalar_one_or_none()
160 if existing:
161 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists")
163 public_trip = PublicTrip(
164 user_id=context.user_id,
165 node_id=node.id,
166 from_date=from_date,
167 to_date=to_date,
168 description=request.description,
169 same_gender_only=request.same_gender_only,
170 )
171 session.add(public_trip)
172 session.flush()
174 log_event(
175 context,
176 session,
177 "public_trip.created",
178 {
179 "public_trip_id": public_trip.id,
180 "node_id": node.id,
181 "from_date": str(from_date),
182 "to_date": str(to_date),
183 "nights": (to_date - from_date).days,
184 },
185 )
187 return public_trip_to_pb(public_trip, session, context)
189 def GetPublicTrip(
190 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session
191 ) -> public_trips_pb2.PublicTrip:
192 if not context.get_boolean_value("public_trips_enabled", False): 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
195 trip_node_id = session.execute(
196 select(PublicTrip.node_id).where(PublicTrip.id == request.trip_id)
197 ).scalar_one_or_none()
198 viewer_is_moderator = trip_node_id is not None and can_moderate_node(session, context.user_id, trip_node_id)
200 statement = (
201 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
202 .where(PublicTrip.id == request.trip_id)
203 .options(selectinload(PublicTrip.node, Node.official_cluster))
204 )
205 if not viewer_is_moderator:
206 statement = statement.where(_same_gender_filter(context))
207 public_trip = session.execute(statement).scalar_one_or_none()
209 if not public_trip:
210 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
212 return public_trip_to_pb(public_trip, session, context)
214 def ListPublicTrips(
215 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session
216 ) -> public_trips_pb2.ListPublicTripsRes:
217 if not context.get_boolean_value("public_trips_enabled", False): 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
220 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
221 next_page_id = int(request.page_token) if request.page_token else 0
223 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
224 if not node: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
227 viewer_is_moderator = can_moderate_node(session, context.user_id, node.id)
229 statement = (
230 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
231 .where(PublicTrip.node_id == node.id)
232 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
233 .where(PublicTrip.to_date >= today())
234 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
235 .order_by(PublicTrip.id.desc())
236 .limit(page_size + 1)
237 .options(selectinload(PublicTrip.node, Node.official_cluster))
238 )
239 if not viewer_is_moderator:
240 statement = statement.where(_same_gender_filter(context))
241 public_trips = session.execute(statement).scalars().all()
243 return public_trips_pb2.ListPublicTripsRes(
244 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
245 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
246 )
248 def ListPublicTripsByUser(
249 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session
250 ) -> public_trips_pb2.ListPublicTripsByUserRes:
251 if not context.get_boolean_value("public_trips_enabled", False): 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
254 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
255 cursor_date, cursor_id = _parse_page_token(request.page_token)
256 ascending = request.ascending
257 is_self = request.user_id == context.user_id
259 statement = where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where(
260 PublicTrip.user_id == request.user_id
261 )
263 if not is_self:
264 # On other users' profiles show only active, upcoming trips that the viewer is allowed to see.
265 # Check moderation against each distinct node the user has active trips in.
266 active_node_ids = (
267 session.execute(
268 select(PublicTrip.node_id)
269 .where(PublicTrip.user_id == request.user_id)
270 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
271 .where(PublicTrip.to_date >= today())
272 .distinct()
273 )
274 .scalars()
275 .all()
276 )
277 viewer_is_moderator = any(can_moderate_node(session, context.user_id, nid) for nid in active_node_ids)
279 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where(
280 PublicTrip.to_date >= today()
281 )
282 if not viewer_is_moderator: 282 ↛ 290line 282 didn't jump to line 290 because the condition on line 282 was always true
283 statement = statement.where(_same_gender_filter(context))
284 elif request.statuses_in:
285 statuses = [publictripstatus2sql[s] for s in request.statuses_in if s in publictripstatus2sql]
286 if statuses: 286 ↛ 290line 286 didn't jump to line 290 because the condition on line 286 was always true
287 statement = statement.where(PublicTrip.status.in_(statuses))
289 # Cursor-based pagination using (from_date, id) composite key
290 if cursor_date is not None and cursor_id is not None: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true
291 if ascending:
292 statement = statement.where(
293 or_(
294 PublicTrip.from_date > cursor_date,
295 and_(PublicTrip.from_date == cursor_date, PublicTrip.id > cursor_id),
296 )
297 )
298 else:
299 statement = statement.where(
300 or_(
301 PublicTrip.from_date < cursor_date,
302 and_(PublicTrip.from_date == cursor_date, PublicTrip.id < cursor_id),
303 )
304 )
305 if ascending:
306 statement = statement.order_by(PublicTrip.from_date.asc(), PublicTrip.id.asc())
307 else:
308 statement = statement.order_by(PublicTrip.from_date.desc(), PublicTrip.id.desc())
310 statement = statement.limit(page_size + 1).options(selectinload(PublicTrip.node, Node.official_cluster))
311 public_trips = session.execute(statement).scalars().all()
313 next_page_token = None
314 if len(public_trips) > page_size: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 last = public_trips[page_size - 1]
316 next_page_token = f"{last.from_date.isoformat()}:{last.id}"
318 return public_trips_pb2.ListPublicTripsByUserRes(
319 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
320 next_page_token=next_page_token,
321 )
323 def UpdatePublicTrip(
324 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session
325 ) -> public_trips_pb2.PublicTrip:
326 if not context.get_boolean_value("public_trips_enabled", False): 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
329 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none()
331 if not public_trip or public_trip.user_id != context.user_id:
332 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
334 editing_content = (
335 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description")
336 )
338 if editing_content:
339 today_local = today_in_timezone(public_trip.node.timezone)
341 if public_trip.to_date < today_local:
342 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
344 new_from_date = public_trip.from_date
345 new_to_date = public_trip.to_date
347 if request.HasField("from_date"):
348 parsed = parse_date(request.from_date)
349 if not parsed: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true
350 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
351 new_from_date = parsed
353 if request.HasField("to_date"):
354 parsed = parse_date(request.to_date)
355 if not parsed: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true
356 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
357 new_to_date = parsed
359 if new_from_date < today_local: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
362 if new_from_date > new_to_date:
363 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
365 if new_from_date - today_local > timedelta(days=365): 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true
366 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
368 if new_to_date - new_from_date > timedelta(days=365): 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true
369 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
371 if request.HasField("description"):
372 if not request.description.strip():
373 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
374 if not _is_description_long_enough(request.description):
375 context.abort_with_error_code(
376 grpc.StatusCode.INVALID_ARGUMENT,
377 "public_trip_description_too_short",
378 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
379 )
380 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true
381 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
382 public_trip.description = request.description
384 public_trip.from_date = new_from_date
385 public_trip.to_date = new_to_date
387 if request.HasField("same_gender_only"):
388 public_trip.same_gender_only = request.same_gender_only
390 if request.HasField("status"):
391 new_status = publictripstatus2sql.get(request.status)
392 if new_status == PublicTripStatus.searching_for_host:
393 # Reopening is only allowed if the trip hasn't started yet, matching creation logic.
394 today_local = today_in_timezone(public_trip.node.timezone)
395 if public_trip.from_date < today_local:
396 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
397 elif new_status != PublicTripStatus.closed: 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true
398 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status")
399 public_trip.status = new_status
401 log_event(
402 context,
403 session,
404 "public_trip.updated",
405 {
406 "public_trip_id": public_trip.id,
407 "from_date": str(public_trip.from_date),
408 "to_date": str(public_trip.to_date),
409 "status": public_trip.status.name,
410 },
411 )
413 return public_trip_to_pb(public_trip, session, context)