Coverage for app/backend/src/couchers/servicers/public_trips.py: 85%
197 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 17:13 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 17:13 +0000
1import logging
2from datetime import 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.crypto import decrypt_page_token, encrypt_page_token
11from couchers.db import can_moderate_node
12from couchers.event_log import log_event
13from couchers.helpers.completed_profile import has_completed_profile
14from couchers.models import ModerationObjectType, Node, User
15from couchers.models.host_requests import HostRequest, HostRequestStatus
16from couchers.models.public_trips import PublicTrip, PublicTripStatus
17from couchers.moderation.utils import create_moderation
18from couchers.proto import public_trips_pb2, public_trips_pb2_grpc
19from couchers.servicers.api import user_model_to_pb
20from couchers.sql import to_bool, where_moderated_content_visible, where_users_column_visible
21from couchers.utils import (
22 Timestamp_from_datetime,
23 date_id_from_page_token,
24 date_id_to_page_token,
25 date_to_api,
26 parse_date,
27 today_in_timezone,
28)
30logger = logging.getLogger(__name__)
32MAX_PAGINATION_LENGTH = 25
33PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000
35publictripstatus2api = {
36 PublicTripStatus.searching_for_host: public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
37 PublicTripStatus.closed: public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
38}
40publictripstatus2sql = {
41 public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST: PublicTripStatus.searching_for_host,
42 public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED: PublicTripStatus.closed,
43}
46def _is_description_long_enough(text: str) -> bool:
47 # Match Javascript's string.length (utf16 code units) rather than Python's len()
48 # so the backend check aligns with the frontend character counter.
49 text_length_utf16 = len(text.encode("utf-16-le")) // 2
50 return text_length_utf16 >= PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16
53def _same_gender_filter(context: CouchersContext) -> ColumnElement[bool]:
54 # Show the trip if same_gender_only is off or the viewer's gender matches the poster's gender.
55 # Moderator bypass is handled by callers via can_moderate_node before applying this filter.
56 # Uses scalar subqueries rather than extra joins since where_users_column_visible
57 # already joins User on PublicTrip.user_id.
58 viewer_gender = select(User.gender).where(User.id == context.user_id).scalar_subquery()
59 poster_gender = select(User.gender).where(User.id == PublicTrip.user_id).scalar_subquery()
60 return or_(~PublicTrip.same_gender_only, poster_gender == viewer_gender)
63def public_trip_to_pb(
64 public_trip: PublicTrip, session: Session, context: CouchersContext
65) -> public_trips_pb2.PublicTrip:
66 pb = public_trips_pb2.PublicTrip(
67 trip_id=public_trip.id,
68 user=user_model_to_pb(public_trip.user, session, context),
69 community_id=public_trip.node_id,
70 community_slug=public_trip.node.official_cluster.slug,
71 community_name=public_trip.node.official_cluster.name,
72 from_date=date_to_api(public_trip.from_date),
73 to_date=date_to_api(public_trip.to_date),
74 timezone=public_trip.timezone,
75 description=public_trip.description,
76 status=publictripstatus2api[public_trip.status],
77 created=Timestamp_from_datetime(public_trip.created),
78 same_gender_only=public_trip.same_gender_only,
79 )
80 if public_trip.user_id == context.user_id:
81 offers = (
82 select(func.count())
83 .select_from(HostRequest)
84 .where(HostRequest.public_trip_id == public_trip.id)
85 .where(HostRequest.status != HostRequestStatus.cancelled)
86 )
87 offers = where_users_column_visible(offers, context, HostRequest.initiator_user_id)
88 offers = where_moderated_content_visible(offers, context, HostRequest, is_list_operation=True)
89 pb.offers_count = session.execute(offers).scalar_one()
90 else:
91 # The viewer's own existing offer on this trip (if any), so the client can
92 # show an "already offered" state and link to the thread.
93 pb.viewer_host_request_id = (
94 session.execute(
95 select(HostRequest.conversation_id)
96 .where(HostRequest.public_trip_id == public_trip.id)
97 .where(HostRequest.initiator_user_id == context.user_id)
98 .where(HostRequest.status != HostRequestStatus.cancelled)
99 ).scalar_one_or_none()
100 or 0
101 )
102 return pb
105class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer):
106 def CreatePublicTrip(
107 self, request: public_trips_pb2.CreatePublicTripReq, context: CouchersContext, session: Session
108 ) -> public_trips_pb2.PublicTrip:
109 if not context.get_boolean_value("public_trips_enabled", False): 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
112 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
113 if not has_completed_profile(session, user):
114 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip")
116 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
117 if not node:
118 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
120 if not node.official_cluster.small_community_features_enabled:
121 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trips_not_enabled")
123 from_date = parse_date(request.from_date)
124 to_date = parse_date(request.to_date)
126 if not from_date or not to_date:
127 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
129 today = today_in_timezone(node.timezone)
131 if from_date < today:
132 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
134 if from_date > to_date:
135 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
137 if from_date - today > timedelta(days=365): 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
140 if to_date - from_date > timedelta(days=365): 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
143 if not request.description.strip():
144 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
146 if not _is_description_long_enough(request.description):
147 context.abort_with_error_code(
148 grpc.StatusCode.INVALID_ARGUMENT,
149 "public_trip_description_too_short",
150 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
151 )
153 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
156 # Disallow overlapping active trips by the same user in the same community
157 # They share the same timezone so comparing dates is ok
158 existing = session.execute(
159 select(PublicTrip)
160 .where(PublicTrip.user_id == context.user_id)
161 .where(PublicTrip.node_id == node.id)
162 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
163 .where(PublicTrip.to_date >= from_date)
164 .where(PublicTrip.from_date <= to_date)
165 ).scalar_one_or_none()
166 if existing:
167 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists")
169 public_trip: PublicTrip | None = None
171 def create_object(moderation_state_id: int) -> int:
172 nonlocal public_trip
173 public_trip = PublicTrip(
174 user_id=context.user_id,
175 node_id=node.id,
176 from_date=from_date,
177 to_date=to_date,
178 description=request.description,
179 same_gender_only=request.same_gender_only,
180 moderation_state_id=moderation_state_id,
181 )
182 session.add(public_trip)
183 session.flush()
184 return public_trip.id
186 create_moderation(
187 session=session,
188 object_type=ModerationObjectType.public_trip,
189 object_id=create_object,
190 creator_user_id=context.user_id,
191 )
192 assert public_trip is not None
194 log_event(
195 context,
196 session,
197 "public_trip.created",
198 {
199 "public_trip_id": public_trip.id,
200 "node_id": node.id,
201 "from_date": str(from_date),
202 "to_date": str(to_date),
203 "nights": (to_date - from_date).days,
204 },
205 )
207 return public_trip_to_pb(public_trip, session, context)
209 def GetPublicTrip(
210 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session
211 ) -> public_trips_pb2.PublicTrip:
212 if not context.get_boolean_value("public_trips_enabled", False): 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
215 trip_node_id = session.execute(
216 select(PublicTrip.node_id).where(PublicTrip.id == request.trip_id)
217 ).scalar_one_or_none()
218 viewer_is_moderator = trip_node_id is not None and can_moderate_node(session, context.user_id, trip_node_id)
220 statement = (
221 where_moderated_content_visible(
222 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id),
223 context,
224 PublicTrip,
225 is_list_operation=False,
226 )
227 .where(PublicTrip.id == request.trip_id)
228 .options(selectinload(PublicTrip.node, Node.official_cluster))
229 )
230 if not viewer_is_moderator:
231 statement = statement.where(_same_gender_filter(context))
232 public_trip = session.execute(statement).scalar_one_or_none()
234 if not public_trip:
235 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
237 return public_trip_to_pb(public_trip, session, context)
239 def ListPublicTrips(
240 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session
241 ) -> public_trips_pb2.ListPublicTripsRes:
242 if not context.get_boolean_value("public_trips_enabled", False): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
245 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
246 next_page_id = int(decrypt_page_token(request.page_token)) if request.page_token else 0
248 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
249 if not node: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
252 viewer_is_moderator = can_moderate_node(session, context.user_id, node.id)
254 statement = (
255 where_moderated_content_visible(
256 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id),
257 context,
258 PublicTrip,
259 is_list_operation=True,
260 )
261 .where(PublicTrip.node_id == node.id)
262 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
263 .where(PublicTrip.end_time >= func.now())
264 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
265 .order_by(PublicTrip.id.desc())
266 .limit(page_size + 1)
267 .options(selectinload(PublicTrip.node, Node.official_cluster))
268 )
269 if not viewer_is_moderator:
270 statement = statement.where(_same_gender_filter(context))
271 public_trips = session.execute(statement).scalars().all()
273 return public_trips_pb2.ListPublicTripsRes(
274 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
275 next_page_token=encrypt_page_token(str(public_trips[-1].id)) if len(public_trips) > page_size else None,
276 )
278 def ListPublicTripsByUser(
279 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session
280 ) -> public_trips_pb2.ListPublicTripsByUserRes:
281 if not context.get_boolean_value("public_trips_enabled", False): 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true
282 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
284 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
285 cursor = date_id_from_page_token(request.page_token) if request.page_token else None
286 ascending = request.ascending
287 is_self = request.user_id == context.user_id
289 statement = where_moderated_content_visible(
290 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id),
291 context,
292 PublicTrip,
293 is_list_operation=True,
294 ).where(PublicTrip.user_id == request.user_id)
296 if not is_self:
297 # On other users' profiles show only active, upcoming trips that the viewer is allowed to see.
298 # Check moderation against each distinct node the user has active trips in.
299 active_node_ids = (
300 session.execute(
301 select(PublicTrip.node_id)
302 .where(PublicTrip.user_id == request.user_id)
303 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
304 .where(PublicTrip.end_time >= func.now())
305 .distinct()
306 )
307 .scalars()
308 .all()
309 )
310 viewer_is_moderator = any(can_moderate_node(session, context.user_id, nid) for nid in active_node_ids)
312 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where(
313 PublicTrip.end_time >= func.now()
314 )
315 if not viewer_is_moderator: 315 ↛ 323line 315 didn't jump to line 323 because the condition on line 315 was always true
316 statement = statement.where(_same_gender_filter(context))
317 elif request.statuses_in:
318 statuses = [publictripstatus2sql[s] for s in request.statuses_in if s in publictripstatus2sql]
319 if statuses: 319 ↛ 323line 319 didn't jump to line 323 because the condition on line 319 was always true
320 statement = statement.where(PublicTrip.status.in_(statuses))
322 # Cursor-based pagination using (from_date, id) composite key
323 if cursor is not None: 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true
324 cursor_date, cursor_id = cursor
325 if ascending:
326 statement = statement.where(
327 or_(
328 PublicTrip.from_date > cursor_date,
329 and_(PublicTrip.from_date == cursor_date, PublicTrip.id > cursor_id),
330 )
331 )
332 else:
333 statement = statement.where(
334 or_(
335 PublicTrip.from_date < cursor_date,
336 and_(PublicTrip.from_date == cursor_date, PublicTrip.id < cursor_id),
337 )
338 )
339 if ascending:
340 statement = statement.order_by(PublicTrip.from_date.asc(), PublicTrip.id.asc())
341 else:
342 statement = statement.order_by(PublicTrip.from_date.desc(), PublicTrip.id.desc())
344 statement = statement.limit(page_size + 1).options(selectinload(PublicTrip.node, Node.official_cluster))
345 public_trips = session.execute(statement).scalars().all()
347 next_page_token = None
348 if len(public_trips) > page_size: 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true
349 last = public_trips[page_size - 1]
350 next_page_token = date_id_to_page_token(last.from_date, last.id)
352 return public_trips_pb2.ListPublicTripsByUserRes(
353 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
354 next_page_token=next_page_token,
355 )
357 def UpdatePublicTrip(
358 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session
359 ) -> public_trips_pb2.PublicTrip:
360 if not context.get_boolean_value("public_trips_enabled", False): 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true
361 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
363 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none()
365 if not public_trip or public_trip.user_id != context.user_id:
366 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
368 editing_content = (
369 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description")
370 )
372 if editing_content:
373 today_local = today_in_timezone(public_trip.timezone)
375 if public_trip.to_date < today_local:
376 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
378 new_from_date = public_trip.from_date
379 new_to_date = public_trip.to_date
381 if request.HasField("from_date"):
382 parsed = parse_date(request.from_date)
383 if not parsed: 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true
384 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
385 new_from_date = parsed
387 if request.HasField("to_date"):
388 parsed = parse_date(request.to_date)
389 if not parsed: 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true
390 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
391 new_to_date = parsed
393 if new_from_date < today_local: 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true
394 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
396 if new_from_date > new_to_date:
397 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
399 if new_from_date - today_local > timedelta(days=365): 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true
400 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
402 if new_to_date - new_from_date > timedelta(days=365): 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true
403 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
405 if request.HasField("description"):
406 if not request.description.strip():
407 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
408 if not _is_description_long_enough(request.description):
409 context.abort_with_error_code(
410 grpc.StatusCode.INVALID_ARGUMENT,
411 "public_trip_description_too_short",
412 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
413 )
414 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true
415 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
416 public_trip.description = request.description
418 public_trip.from_date = new_from_date
419 public_trip.to_date = new_to_date
421 if request.HasField("same_gender_only"):
422 public_trip.same_gender_only = request.same_gender_only
424 if request.HasField("status"):
425 new_status = publictripstatus2sql.get(request.status)
426 if new_status == PublicTripStatus.searching_for_host:
427 # Reopening is only allowed if the trip hasn't started yet, matching creation logic.
428 today_local = today_in_timezone(public_trip.timezone)
429 if public_trip.from_date < today_local:
430 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
431 elif new_status != PublicTripStatus.closed: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status")
433 public_trip.status = new_status
435 log_event(
436 context,
437 session,
438 "public_trip.updated",
439 {
440 "public_trip_id": public_trip.id,
441 "from_date": str(public_trip.from_date),
442 "to_date": str(public_trip.to_date),
443 "status": public_trip.status.name,
444 },
445 )
447 return public_trip_to_pb(public_trip, session, context)