Coverage for app/backend/src/couchers/servicers/public_trips.py: 84%
190 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 17:54 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 17:54 +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 if not context.get_boolean_value("public_trips_enabled", False): 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
95 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
96 if not has_completed_profile(session, user):
97 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip")
99 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
100 if not node:
101 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
103 if not node.official_cluster.small_community_features_enabled:
104 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trips_not_enabled")
106 from_date = parse_date(request.from_date)
107 to_date = parse_date(request.to_date)
109 if not from_date or not to_date:
110 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
112 today = today_in_timezone(node.timezone)
114 if from_date < today:
115 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
117 if from_date > to_date:
118 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
120 if from_date - today > 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_from_after_one_year")
123 if to_date - from_date > timedelta(days=365): 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
126 if not request.description.strip():
127 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
129 if not _is_description_long_enough(request.description):
130 context.abort_with_error_code(
131 grpc.StatusCode.INVALID_ARGUMENT,
132 "public_trip_description_too_short",
133 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
134 )
136 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
139 # Disallow overlapping active trips by the same user in the same community
140 existing = session.execute(
141 select(PublicTrip)
142 .where(PublicTrip.user_id == context.user_id)
143 .where(PublicTrip.node_id == node.id)
144 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
145 .where(PublicTrip.to_date >= from_date)
146 .where(PublicTrip.from_date <= to_date)
147 ).scalar_one_or_none()
148 if existing:
149 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists")
151 public_trip = PublicTrip(
152 user_id=context.user_id,
153 node_id=node.id,
154 from_date=from_date,
155 to_date=to_date,
156 description=request.description,
157 same_gender_only=request.same_gender_only,
158 )
159 session.add(public_trip)
160 session.flush()
162 log_event(
163 context,
164 session,
165 "public_trip.created",
166 {
167 "public_trip_id": public_trip.id,
168 "node_id": node.id,
169 "from_date": str(from_date),
170 "to_date": str(to_date),
171 "nights": (to_date - from_date).days,
172 },
173 )
175 return public_trip_to_pb(public_trip, session, context)
177 def GetPublicTrip(
178 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session
179 ) -> public_trips_pb2.PublicTrip:
180 if not context.get_boolean_value("public_trips_enabled", False): 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
183 trip_node_id = session.execute(
184 select(PublicTrip.node_id).where(PublicTrip.id == request.trip_id)
185 ).scalar_one_or_none()
186 viewer_is_moderator = trip_node_id is not None and can_moderate_node(session, context.user_id, trip_node_id)
188 statement = (
189 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
190 .where(PublicTrip.id == request.trip_id)
191 .options(selectinload(PublicTrip.node, Node.official_cluster))
192 )
193 if not viewer_is_moderator:
194 statement = statement.where(_same_gender_filter(context))
195 public_trip = session.execute(statement).scalar_one_or_none()
197 if not public_trip:
198 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
200 return public_trip_to_pb(public_trip, session, context)
202 def ListPublicTrips(
203 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session
204 ) -> public_trips_pb2.ListPublicTripsRes:
205 if not context.get_boolean_value("public_trips_enabled", False): 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
208 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
209 next_page_id = int(request.page_token) if request.page_token else 0
211 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
212 if not node: 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.NOT_FOUND, "community_not_found")
215 viewer_is_moderator = can_moderate_node(session, context.user_id, node.id)
217 statement = (
218 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
219 .where(PublicTrip.node_id == node.id)
220 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
221 .where(PublicTrip.to_date >= today())
222 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
223 .order_by(PublicTrip.id.desc())
224 .limit(page_size + 1)
225 .options(selectinload(PublicTrip.node, Node.official_cluster))
226 )
227 if not viewer_is_moderator:
228 statement = statement.where(_same_gender_filter(context))
229 public_trips = session.execute(statement).scalars().all()
231 return public_trips_pb2.ListPublicTripsRes(
232 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
233 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
234 )
236 def ListPublicTripsByUser(
237 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session
238 ) -> public_trips_pb2.ListPublicTripsByUserRes:
239 if not context.get_boolean_value("public_trips_enabled", False): 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
242 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
243 cursor_date, cursor_id = _parse_page_token(request.page_token)
244 ascending = request.ascending
245 is_self = request.user_id == context.user_id
247 statement = where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where(
248 PublicTrip.user_id == request.user_id
249 )
251 if not is_self:
252 # On other users' profiles show only active, upcoming trips that the viewer is allowed to see.
253 # Check moderation against each distinct node the user has active trips in.
254 active_node_ids = (
255 session.execute(
256 select(PublicTrip.node_id)
257 .where(PublicTrip.user_id == request.user_id)
258 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
259 .where(PublicTrip.to_date >= today())
260 .distinct()
261 )
262 .scalars()
263 .all()
264 )
265 viewer_is_moderator = any(can_moderate_node(session, context.user_id, nid) for nid in active_node_ids)
267 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where(
268 PublicTrip.to_date >= today()
269 )
270 if not viewer_is_moderator: 270 ↛ 278line 270 didn't jump to line 278 because the condition on line 270 was always true
271 statement = statement.where(_same_gender_filter(context))
272 elif request.statuses_in:
273 statuses = [publictripstatus2sql[s] for s in request.statuses_in if s in publictripstatus2sql]
274 if statuses: 274 ↛ 278line 274 didn't jump to line 278 because the condition on line 274 was always true
275 statement = statement.where(PublicTrip.status.in_(statuses))
277 # Cursor-based pagination using (from_date, id) composite key
278 if cursor_date is not None and cursor_id is not None: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 if ascending:
280 statement = statement.where(
281 or_(
282 PublicTrip.from_date > cursor_date,
283 and_(PublicTrip.from_date == cursor_date, PublicTrip.id > cursor_id),
284 )
285 )
286 else:
287 statement = statement.where(
288 or_(
289 PublicTrip.from_date < cursor_date,
290 and_(PublicTrip.from_date == cursor_date, PublicTrip.id < cursor_id),
291 )
292 )
293 if ascending:
294 statement = statement.order_by(PublicTrip.from_date.asc(), PublicTrip.id.asc())
295 else:
296 statement = statement.order_by(PublicTrip.from_date.desc(), PublicTrip.id.desc())
298 statement = statement.limit(page_size + 1).options(selectinload(PublicTrip.node, Node.official_cluster))
299 public_trips = session.execute(statement).scalars().all()
301 next_page_token = None
302 if len(public_trips) > page_size: 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true
303 last = public_trips[page_size - 1]
304 next_page_token = f"{last.from_date.isoformat()}:{last.id}"
306 return public_trips_pb2.ListPublicTripsByUserRes(
307 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
308 next_page_token=next_page_token,
309 )
311 def UpdatePublicTrip(
312 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session
313 ) -> public_trips_pb2.PublicTrip:
314 if not context.get_boolean_value("public_trips_enabled", False): 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
317 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none()
319 if not public_trip or public_trip.user_id != context.user_id:
320 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
322 editing_content = (
323 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description")
324 )
326 if editing_content:
327 today_local = today_in_timezone(public_trip.node.timezone)
329 if public_trip.to_date < today_local:
330 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
332 new_from_date = public_trip.from_date
333 new_to_date = public_trip.to_date
335 if request.HasField("from_date"):
336 parsed = parse_date(request.from_date)
337 if not parsed: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true
338 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
339 new_from_date = parsed
341 if request.HasField("to_date"):
342 parsed = parse_date(request.to_date)
343 if not parsed: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true
344 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
345 new_to_date = parsed
347 if new_from_date < today_local: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
350 if new_from_date > new_to_date:
351 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
353 if new_from_date - today_local > timedelta(days=365): 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, "date_from_after_one_year")
356 if new_to_date - new_from_date > timedelta(days=365): 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
359 if request.HasField("description"):
360 if not request.description.strip():
361 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
362 if not _is_description_long_enough(request.description):
363 context.abort_with_error_code(
364 grpc.StatusCode.INVALID_ARGUMENT,
365 "public_trip_description_too_short",
366 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
367 )
368 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 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, "public_trip_description_too_long")
370 public_trip.description = request.description
372 public_trip.from_date = new_from_date
373 public_trip.to_date = new_to_date
375 if request.HasField("same_gender_only"):
376 public_trip.same_gender_only = request.same_gender_only
378 if request.HasField("status"):
379 new_status = publictripstatus2sql.get(request.status)
380 if new_status == PublicTripStatus.searching_for_host:
381 # Reopening is only allowed if the trip hasn't started yet, matching creation logic.
382 today_local = today_in_timezone(public_trip.node.timezone)
383 if public_trip.from_date < today_local:
384 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
385 elif new_status != PublicTripStatus.closed: 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true
386 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status")
387 public_trip.status = new_status
389 log_event(
390 context,
391 session,
392 "public_trip.updated",
393 {
394 "public_trip_id": public_trip.id,
395 "from_date": str(public_trip.from_date),
396 "to_date": str(public_trip.to_date),
397 "status": public_trip.status.name,
398 },
399 )
401 return public_trip_to_pb(public_trip, session, context)