Coverage for app/backend/src/couchers/servicers/public_trips.py: 85%
200 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-03 13:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-03 13:25 +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 ModerationObjectType, Node, User
14from couchers.models.host_requests import HostRequest, HostRequestStatus
15from couchers.models.public_trips import PublicTrip, PublicTripStatus
16from couchers.moderation.utils import create_moderation
17from couchers.proto import public_trips_pb2, public_trips_pb2_grpc
18from couchers.servicers.api import user_model_to_pb
19from couchers.sql import to_bool, where_moderated_content_visible, where_users_column_visible
20from couchers.utils import Timestamp_from_datetime, date_to_api, parse_date, today, today_in_timezone
22logger = logging.getLogger(__name__)
24MAX_PAGINATION_LENGTH = 25
25PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000
27publictripstatus2api = {
28 PublicTripStatus.searching_for_host: public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
29 PublicTripStatus.closed: public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
30}
32publictripstatus2sql = {
33 public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST: PublicTripStatus.searching_for_host,
34 public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED: PublicTripStatus.closed,
35}
38def _is_description_long_enough(text: str) -> bool:
39 # Match Javascript's string.length (utf16 code units) rather than Python's len()
40 # so the backend check aligns with the frontend character counter.
41 text_length_utf16 = len(text.encode("utf-16-le")) // 2
42 return text_length_utf16 >= PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16
45def _parse_page_token(page_token: str) -> tuple[date | None, int | None]:
46 """Parse a page token into (from_date, trip_id). Returns (None, None) for first page."""
47 if not page_token: 47 ↛ 49line 47 didn't jump to line 49 because the condition on line 47 was always true
48 return None, None
49 date_str, id_str = page_token.rsplit(":", 1)
50 return date.fromisoformat(date_str), int(id_str)
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 description=public_trip.description,
75 status=publictripstatus2api[public_trip.status],
76 created=Timestamp_from_datetime(public_trip.created),
77 same_gender_only=public_trip.same_gender_only,
78 )
79 if public_trip.user_id == context.user_id:
80 offers = (
81 select(func.count())
82 .select_from(HostRequest)
83 .where(HostRequest.public_trip_id == public_trip.id)
84 .where(HostRequest.status != HostRequestStatus.cancelled)
85 )
86 offers = where_users_column_visible(offers, context, HostRequest.initiator_user_id)
87 offers = where_moderated_content_visible(offers, context, HostRequest, is_list_operation=True)
88 pb.offers_count = session.execute(offers).scalar_one()
89 else:
90 # The viewer's own existing offer on this trip (if any), so the client can
91 # show an "already offered" state and link to the thread.
92 pb.viewer_host_request_id = (
93 session.execute(
94 select(HostRequest.conversation_id)
95 .where(HostRequest.public_trip_id == public_trip.id)
96 .where(HostRequest.initiator_user_id == context.user_id)
97 .where(HostRequest.status != HostRequestStatus.cancelled)
98 ).scalar_one_or_none()
99 or 0
100 )
101 return pb
104class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer):
105 def CreatePublicTrip(
106 self, request: public_trips_pb2.CreatePublicTripReq, context: CouchersContext, session: Session
107 ) -> public_trips_pb2.PublicTrip:
108 if not context.get_boolean_value("public_trips_enabled", False): 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
111 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
112 if not has_completed_profile(session, user):
113 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip")
115 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
116 if not node:
117 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
119 if not node.official_cluster.small_community_features_enabled:
120 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trips_not_enabled")
122 from_date = parse_date(request.from_date)
123 to_date = parse_date(request.to_date)
125 if not from_date or not to_date:
126 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
128 today = today_in_timezone(node.timezone)
130 if from_date < today:
131 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
133 if from_date > to_date:
134 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
136 if from_date - today > timedelta(days=365): 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, "date_from_after_one_year")
139 if to_date - from_date > timedelta(days=365): 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
142 if not request.description.strip():
143 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
145 if not _is_description_long_enough(request.description):
146 context.abort_with_error_code(
147 grpc.StatusCode.INVALID_ARGUMENT,
148 "public_trip_description_too_short",
149 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
150 )
152 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
155 # Disallow overlapping active trips by the same user in the same community
156 existing = session.execute(
157 select(PublicTrip)
158 .where(PublicTrip.user_id == context.user_id)
159 .where(PublicTrip.node_id == node.id)
160 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
161 .where(PublicTrip.to_date >= from_date)
162 .where(PublicTrip.from_date <= to_date)
163 ).scalar_one_or_none()
164 if existing:
165 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists")
167 public_trip: PublicTrip | None = None
169 def create_object(moderation_state_id: int) -> int:
170 nonlocal public_trip
171 public_trip = PublicTrip(
172 user_id=context.user_id,
173 node_id=node.id,
174 from_date=from_date,
175 to_date=to_date,
176 description=request.description,
177 same_gender_only=request.same_gender_only,
178 moderation_state_id=moderation_state_id,
179 )
180 session.add(public_trip)
181 session.flush()
182 return public_trip.id
184 create_moderation(
185 session=session,
186 object_type=ModerationObjectType.public_trip,
187 object_id=create_object,
188 creator_user_id=context.user_id,
189 )
190 assert public_trip is not None
192 log_event(
193 context,
194 session,
195 "public_trip.created",
196 {
197 "public_trip_id": public_trip.id,
198 "node_id": node.id,
199 "from_date": str(from_date),
200 "to_date": str(to_date),
201 "nights": (to_date - from_date).days,
202 },
203 )
205 return public_trip_to_pb(public_trip, session, context)
207 def GetPublicTrip(
208 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session
209 ) -> public_trips_pb2.PublicTrip:
210 if not context.get_boolean_value("public_trips_enabled", False): 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
213 trip_node_id = session.execute(
214 select(PublicTrip.node_id).where(PublicTrip.id == request.trip_id)
215 ).scalar_one_or_none()
216 viewer_is_moderator = trip_node_id is not None and can_moderate_node(session, context.user_id, trip_node_id)
218 statement = (
219 where_moderated_content_visible(
220 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id),
221 context,
222 PublicTrip,
223 is_list_operation=False,
224 )
225 .where(PublicTrip.id == request.trip_id)
226 .options(selectinload(PublicTrip.node, Node.official_cluster))
227 )
228 if not viewer_is_moderator:
229 statement = statement.where(_same_gender_filter(context))
230 public_trip = session.execute(statement).scalar_one_or_none()
232 if not public_trip:
233 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
235 return public_trip_to_pb(public_trip, session, context)
237 def ListPublicTrips(
238 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session
239 ) -> public_trips_pb2.ListPublicTripsRes:
240 if not context.get_boolean_value("public_trips_enabled", False): 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
243 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
244 next_page_id = int(request.page_token) if request.page_token else 0
246 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
247 if not node: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
250 viewer_is_moderator = can_moderate_node(session, context.user_id, node.id)
252 statement = (
253 where_moderated_content_visible(
254 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id),
255 context,
256 PublicTrip,
257 is_list_operation=True,
258 )
259 .where(PublicTrip.node_id == node.id)
260 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
261 .where(PublicTrip.to_date >= today())
262 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
263 .order_by(PublicTrip.id.desc())
264 .limit(page_size + 1)
265 .options(selectinload(PublicTrip.node, Node.official_cluster))
266 )
267 if not viewer_is_moderator:
268 statement = statement.where(_same_gender_filter(context))
269 public_trips = session.execute(statement).scalars().all()
271 return public_trips_pb2.ListPublicTripsRes(
272 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
273 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
274 )
276 def ListPublicTripsByUser(
277 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session
278 ) -> public_trips_pb2.ListPublicTripsByUserRes:
279 if not context.get_boolean_value("public_trips_enabled", False): 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true
280 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
282 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
283 cursor_date, cursor_id = _parse_page_token(request.page_token)
284 ascending = request.ascending
285 is_self = request.user_id == context.user_id
287 statement = where_moderated_content_visible(
288 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id),
289 context,
290 PublicTrip,
291 is_list_operation=True,
292 ).where(PublicTrip.user_id == request.user_id)
294 if not is_self:
295 # On other users' profiles show only active, upcoming trips that the viewer is allowed to see.
296 # Check moderation against each distinct node the user has active trips in.
297 active_node_ids = (
298 session.execute(
299 select(PublicTrip.node_id)
300 .where(PublicTrip.user_id == request.user_id)
301 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
302 .where(PublicTrip.to_date >= today())
303 .distinct()
304 )
305 .scalars()
306 .all()
307 )
308 viewer_is_moderator = any(can_moderate_node(session, context.user_id, nid) for nid in active_node_ids)
310 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where(
311 PublicTrip.to_date >= today()
312 )
313 if not viewer_is_moderator: 313 ↛ 321line 313 didn't jump to line 321 because the condition on line 313 was always true
314 statement = statement.where(_same_gender_filter(context))
315 elif request.statuses_in:
316 statuses = [publictripstatus2sql[s] for s in request.statuses_in if s in publictripstatus2sql]
317 if statuses: 317 ↛ 321line 317 didn't jump to line 321 because the condition on line 317 was always true
318 statement = statement.where(PublicTrip.status.in_(statuses))
320 # Cursor-based pagination using (from_date, id) composite key
321 if cursor_date is not None and cursor_id is not None: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 if ascending:
323 statement = statement.where(
324 or_(
325 PublicTrip.from_date > cursor_date,
326 and_(PublicTrip.from_date == cursor_date, PublicTrip.id > cursor_id),
327 )
328 )
329 else:
330 statement = statement.where(
331 or_(
332 PublicTrip.from_date < cursor_date,
333 and_(PublicTrip.from_date == cursor_date, PublicTrip.id < cursor_id),
334 )
335 )
336 if ascending:
337 statement = statement.order_by(PublicTrip.from_date.asc(), PublicTrip.id.asc())
338 else:
339 statement = statement.order_by(PublicTrip.from_date.desc(), PublicTrip.id.desc())
341 statement = statement.limit(page_size + 1).options(selectinload(PublicTrip.node, Node.official_cluster))
342 public_trips = session.execute(statement).scalars().all()
344 next_page_token = None
345 if len(public_trips) > page_size: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true
346 last = public_trips[page_size - 1]
347 next_page_token = f"{last.from_date.isoformat()}:{last.id}"
349 return public_trips_pb2.ListPublicTripsByUserRes(
350 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
351 next_page_token=next_page_token,
352 )
354 def UpdatePublicTrip(
355 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session
356 ) -> public_trips_pb2.PublicTrip:
357 if not context.get_boolean_value("public_trips_enabled", False): 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true
358 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
360 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none()
362 if not public_trip or public_trip.user_id != context.user_id:
363 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
365 editing_content = (
366 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description")
367 )
369 if editing_content:
370 today_local = today_in_timezone(public_trip.node.timezone)
372 if public_trip.to_date < today_local:
373 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
375 new_from_date = public_trip.from_date
376 new_to_date = public_trip.to_date
378 if request.HasField("from_date"):
379 parsed = parse_date(request.from_date)
380 if not parsed: 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, "invalid_date")
382 new_from_date = parsed
384 if request.HasField("to_date"):
385 parsed = parse_date(request.to_date)
386 if not parsed: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true
387 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
388 new_to_date = parsed
390 if new_from_date < today_local: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true
391 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
393 if new_from_date > new_to_date:
394 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
396 if new_from_date - today_local > timedelta(days=365): 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true
397 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
399 if new_to_date - new_from_date > 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_to_after_one_year")
402 if request.HasField("description"):
403 if not request.description.strip():
404 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
405 if not _is_description_long_enough(request.description):
406 context.abort_with_error_code(
407 grpc.StatusCode.INVALID_ARGUMENT,
408 "public_trip_description_too_short",
409 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
410 )
411 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
413 public_trip.description = request.description
415 public_trip.from_date = new_from_date
416 public_trip.to_date = new_to_date
418 if request.HasField("same_gender_only"):
419 public_trip.same_gender_only = request.same_gender_only
421 if request.HasField("status"):
422 new_status = publictripstatus2sql.get(request.status)
423 if new_status == PublicTripStatus.searching_for_host:
424 # Reopening is only allowed if the trip hasn't started yet, matching creation logic.
425 today_local = today_in_timezone(public_trip.node.timezone)
426 if public_trip.from_date < today_local:
427 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
428 elif new_status != PublicTripStatus.closed: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true
429 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status")
430 public_trip.status = new_status
432 log_event(
433 context,
434 session,
435 "public_trip.updated",
436 {
437 "public_trip_id": public_trip.id,
438 "from_date": str(public_trip.from_date),
439 "to_date": str(public_trip.to_date),
440 "status": public_trip.status.name,
441 },
442 )
444 return public_trip_to_pb(public_trip, session, context)