Coverage for app/backend/src/couchers/servicers/public_trips.py: 87%
180 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 00:30 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 00:30 +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 return pb
88class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer):
89 def CreatePublicTrip(
90 self, request: public_trips_pb2.CreatePublicTripReq, context: CouchersContext, session: Session
91 ) -> public_trips_pb2.PublicTrip:
92 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
93 if not has_completed_profile(session, user):
94 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip")
96 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
97 if not node:
98 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
100 if not node.official_cluster.small_community_features_enabled:
101 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trips_not_enabled")
103 from_date = parse_date(request.from_date)
104 to_date = parse_date(request.to_date)
106 if not from_date or not to_date:
107 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
109 today = today_in_timezone(node.timezone)
111 if from_date < today:
112 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
114 if from_date > to_date:
115 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
117 if from_date - today > timedelta(days=365): 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true
118 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
120 if to_date - from_date > timedelta(days=365): 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true
121 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
123 if not request.description.strip():
124 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
126 if not _is_description_long_enough(request.description):
127 context.abort_with_error_code(
128 grpc.StatusCode.INVALID_ARGUMENT,
129 "public_trip_description_too_short",
130 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
131 )
133 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true
134 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
136 # Disallow overlapping active trips by the same user in the same community
137 existing = session.execute(
138 select(PublicTrip)
139 .where(PublicTrip.user_id == context.user_id)
140 .where(PublicTrip.node_id == node.id)
141 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
142 .where(PublicTrip.to_date >= from_date)
143 .where(PublicTrip.from_date <= to_date)
144 ).scalar_one_or_none()
145 if existing:
146 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists")
148 public_trip = PublicTrip(
149 user_id=context.user_id,
150 node_id=node.id,
151 from_date=from_date,
152 to_date=to_date,
153 description=request.description,
154 same_gender_only=request.same_gender_only,
155 )
156 session.add(public_trip)
157 session.flush()
159 log_event(
160 context,
161 session,
162 "public_trip.created",
163 {
164 "public_trip_id": public_trip.id,
165 "node_id": node.id,
166 "from_date": str(from_date),
167 "to_date": str(to_date),
168 "nights": (to_date - from_date).days,
169 },
170 )
172 return public_trip_to_pb(public_trip, session, context)
174 def GetPublicTrip(
175 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session
176 ) -> public_trips_pb2.PublicTrip:
177 trip_node_id = session.execute(
178 select(PublicTrip.node_id).where(PublicTrip.id == request.trip_id)
179 ).scalar_one_or_none()
180 viewer_is_moderator = trip_node_id is not None and can_moderate_node(session, context.user_id, trip_node_id)
182 statement = (
183 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
184 .where(PublicTrip.id == request.trip_id)
185 .options(selectinload(PublicTrip.node, Node.official_cluster))
186 )
187 if not viewer_is_moderator:
188 statement = statement.where(_same_gender_filter(context))
189 public_trip = session.execute(statement).scalar_one_or_none()
191 if not public_trip:
192 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
194 return public_trip_to_pb(public_trip, session, context)
196 def ListPublicTrips(
197 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session
198 ) -> public_trips_pb2.ListPublicTripsRes:
199 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
200 next_page_id = int(request.page_token) if request.page_token else 0
202 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
203 if not node: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
206 viewer_is_moderator = can_moderate_node(session, context.user_id, node.id)
208 statement = (
209 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
210 .where(PublicTrip.node_id == node.id)
211 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
212 .where(PublicTrip.to_date >= today())
213 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
214 .order_by(PublicTrip.id.desc())
215 .limit(page_size + 1)
216 .options(selectinload(PublicTrip.node, Node.official_cluster))
217 )
218 if not viewer_is_moderator:
219 statement = statement.where(_same_gender_filter(context))
220 public_trips = session.execute(statement).scalars().all()
222 return public_trips_pb2.ListPublicTripsRes(
223 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
224 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
225 )
227 def ListPublicTripsByUser(
228 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session
229 ) -> public_trips_pb2.ListPublicTripsByUserRes:
230 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
231 cursor_date, cursor_id = _parse_page_token(request.page_token)
232 ascending = request.ascending
233 is_self = request.user_id == context.user_id
235 statement = where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where(
236 PublicTrip.user_id == request.user_id
237 )
239 if not is_self:
240 # On other users' profiles show only active, upcoming trips that the viewer is allowed to see.
241 # Check moderation against each distinct node the user has active trips in.
242 active_node_ids = (
243 session.execute(
244 select(PublicTrip.node_id)
245 .where(PublicTrip.user_id == request.user_id)
246 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
247 .where(PublicTrip.to_date >= today())
248 .distinct()
249 )
250 .scalars()
251 .all()
252 )
253 viewer_is_moderator = any(can_moderate_node(session, context.user_id, nid) for nid in active_node_ids)
255 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where(
256 PublicTrip.to_date >= today()
257 )
258 if not viewer_is_moderator: 258 ↛ 266line 258 didn't jump to line 266 because the condition on line 258 was always true
259 statement = statement.where(_same_gender_filter(context))
260 elif request.statuses_in:
261 statuses = [publictripstatus2sql[s] for s in request.statuses_in if s in publictripstatus2sql]
262 if statuses: 262 ↛ 266line 262 didn't jump to line 266 because the condition on line 262 was always true
263 statement = statement.where(PublicTrip.status.in_(statuses))
265 # Cursor-based pagination using (from_date, id) composite key
266 if cursor_date is not None and cursor_id is not None: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 if ascending:
268 statement = statement.where(
269 or_(
270 PublicTrip.from_date > cursor_date,
271 and_(PublicTrip.from_date == cursor_date, PublicTrip.id > cursor_id),
272 )
273 )
274 else:
275 statement = statement.where(
276 or_(
277 PublicTrip.from_date < cursor_date,
278 and_(PublicTrip.from_date == cursor_date, PublicTrip.id < cursor_id),
279 )
280 )
281 if ascending:
282 statement = statement.order_by(PublicTrip.from_date.asc(), PublicTrip.id.asc())
283 else:
284 statement = statement.order_by(PublicTrip.from_date.desc(), PublicTrip.id.desc())
286 statement = statement.limit(page_size + 1).options(selectinload(PublicTrip.node, Node.official_cluster))
287 public_trips = session.execute(statement).scalars().all()
289 next_page_token = None
290 if len(public_trips) > page_size: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true
291 last = public_trips[page_size - 1]
292 next_page_token = f"{last.from_date.isoformat()}:{last.id}"
294 return public_trips_pb2.ListPublicTripsByUserRes(
295 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
296 next_page_token=next_page_token,
297 )
299 def UpdatePublicTrip(
300 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session
301 ) -> public_trips_pb2.PublicTrip:
302 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none()
304 if not public_trip or public_trip.user_id != context.user_id:
305 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
307 editing_content = (
308 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description")
309 )
311 if editing_content:
312 today_local = today_in_timezone(public_trip.node.timezone)
314 if public_trip.to_date < today_local:
315 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
317 new_from_date = public_trip.from_date
318 new_to_date = public_trip.to_date
320 if request.HasField("from_date"):
321 parsed = parse_date(request.from_date)
322 if not parsed: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true
323 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
324 new_from_date = parsed
326 if request.HasField("to_date"):
327 parsed = parse_date(request.to_date)
328 if not parsed: 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true
329 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
330 new_to_date = parsed
332 if new_from_date < today_local: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
335 if new_from_date > new_to_date:
336 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
338 if new_from_date - today_local > timedelta(days=365): 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true
339 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
341 if new_to_date - new_from_date > timedelta(days=365): 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
344 if request.HasField("description"):
345 if not request.description.strip():
346 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
347 if not _is_description_long_enough(request.description):
348 context.abort_with_error_code(
349 grpc.StatusCode.INVALID_ARGUMENT,
350 "public_trip_description_too_short",
351 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
352 )
353 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true
354 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
355 public_trip.description = request.description
357 public_trip.from_date = new_from_date
358 public_trip.to_date = new_to_date
360 if request.HasField("same_gender_only"):
361 public_trip.same_gender_only = request.same_gender_only
363 if request.HasField("status"):
364 new_status = publictripstatus2sql.get(request.status)
365 if new_status == PublicTripStatus.searching_for_host:
366 # Reopening is only allowed if the trip hasn't started yet, matching creation logic.
367 today_local = today_in_timezone(public_trip.node.timezone)
368 if public_trip.from_date < today_local:
369 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
370 elif new_status != PublicTripStatus.closed: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true
371 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status")
372 public_trip.status = new_status
374 log_event(
375 context,
376 session,
377 "public_trip.updated",
378 {
379 "public_trip_id": public_trip.id,
380 "from_date": str(public_trip.from_date),
381 "to_date": str(public_trip.to_date),
382 "status": public_trip.status.name,
383 },
384 )
386 return public_trip_to_pb(public_trip, session, context)