Coverage for app/backend/src/couchers/servicers/public_trips.py: 85%
196 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 16:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 16:01 +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 # Per-status tally of offers, for the trip owner's grouped view.
86 counts_by_status: dict[HostRequestStatus, int] = dict(
87 session.execute( # type: ignore[arg-type]
88 select(HostRequest.status, func.count())
89 .where(HostRequest.public_trip_id == public_trip.id)
90 .where(HostRequest.status != HostRequestStatus.cancelled)
91 .group_by(HostRequest.status)
92 ).all()
93 )
94 pb.offer_tally.pending = counts_by_status.get(HostRequestStatus.pending, 0)
95 pb.offer_tally.accepted = counts_by_status.get(HostRequestStatus.accepted, 0)
96 pb.offer_tally.confirmed = counts_by_status.get(HostRequestStatus.confirmed, 0)
97 pb.offer_tally.declined = counts_by_status.get(HostRequestStatus.rejected, 0)
98 else:
99 # The viewer's own existing offer on this trip (if any), so the client can
100 # show an "already offered" state and link to the thread.
101 pb.viewer_host_request_id = (
102 session.execute(
103 select(HostRequest.conversation_id)
104 .where(HostRequest.public_trip_id == public_trip.id)
105 .where(HostRequest.initiator_user_id == context.user_id)
106 .where(HostRequest.status != HostRequestStatus.cancelled)
107 ).scalar_one_or_none()
108 or 0
109 )
110 return pb
113class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer):
114 def CreatePublicTrip(
115 self, request: public_trips_pb2.CreatePublicTripReq, context: CouchersContext, session: Session
116 ) -> public_trips_pb2.PublicTrip:
117 if not context.get_boolean_value("public_trips_enabled", False): 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.UNAVAILABLE, "public_trips_disabled")
120 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
121 if not has_completed_profile(session, user):
122 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip")
124 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
125 if not node:
126 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
128 if not node.official_cluster.small_community_features_enabled:
129 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trips_not_enabled")
131 from_date = parse_date(request.from_date)
132 to_date = parse_date(request.to_date)
134 if not from_date or not to_date:
135 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
137 today = today_in_timezone(node.timezone)
139 if from_date < today:
140 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
142 if from_date > to_date:
143 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
145 if from_date - today > timedelta(days=365): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
148 if to_date - from_date > timedelta(days=365): 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, "date_to_after_one_year")
151 if not request.description.strip():
152 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
154 if not _is_description_long_enough(request.description):
155 context.abort_with_error_code(
156 grpc.StatusCode.INVALID_ARGUMENT,
157 "public_trip_description_too_short",
158 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
159 )
161 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
164 # Disallow overlapping active trips by the same user in the same community
165 existing = session.execute(
166 select(PublicTrip)
167 .where(PublicTrip.user_id == context.user_id)
168 .where(PublicTrip.node_id == node.id)
169 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
170 .where(PublicTrip.to_date >= from_date)
171 .where(PublicTrip.from_date <= to_date)
172 ).scalar_one_or_none()
173 if existing:
174 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists")
176 public_trip = PublicTrip(
177 user_id=context.user_id,
178 node_id=node.id,
179 from_date=from_date,
180 to_date=to_date,
181 description=request.description,
182 same_gender_only=request.same_gender_only,
183 )
184 session.add(public_trip)
185 session.flush()
187 log_event(
188 context,
189 session,
190 "public_trip.created",
191 {
192 "public_trip_id": public_trip.id,
193 "node_id": node.id,
194 "from_date": str(from_date),
195 "to_date": str(to_date),
196 "nights": (to_date - from_date).days,
197 },
198 )
200 return public_trip_to_pb(public_trip, session, context)
202 def GetPublicTrip(
203 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session
204 ) -> public_trips_pb2.PublicTrip:
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 trip_node_id = session.execute(
209 select(PublicTrip.node_id).where(PublicTrip.id == request.trip_id)
210 ).scalar_one_or_none()
211 viewer_is_moderator = trip_node_id is not None and can_moderate_node(session, context.user_id, trip_node_id)
213 statement = (
214 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
215 .where(PublicTrip.id == request.trip_id)
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_trip = session.execute(statement).scalar_one_or_none()
222 if not public_trip:
223 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
225 return public_trip_to_pb(public_trip, session, context)
227 def ListPublicTrips(
228 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session
229 ) -> public_trips_pb2.ListPublicTripsRes:
230 if not context.get_boolean_value("public_trips_enabled", False): 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
233 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
234 next_page_id = int(request.page_token) if request.page_token else 0
236 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
237 if not node: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
240 viewer_is_moderator = can_moderate_node(session, context.user_id, node.id)
242 statement = (
243 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
244 .where(PublicTrip.node_id == node.id)
245 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
246 .where(PublicTrip.to_date >= today())
247 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
248 .order_by(PublicTrip.id.desc())
249 .limit(page_size + 1)
250 .options(selectinload(PublicTrip.node, Node.official_cluster))
251 )
252 if not viewer_is_moderator:
253 statement = statement.where(_same_gender_filter(context))
254 public_trips = session.execute(statement).scalars().all()
256 return public_trips_pb2.ListPublicTripsRes(
257 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
258 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
259 )
261 def ListPublicTripsByUser(
262 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session
263 ) -> public_trips_pb2.ListPublicTripsByUserRes:
264 if not context.get_boolean_value("public_trips_enabled", False): 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true
265 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
267 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
268 cursor_date, cursor_id = _parse_page_token(request.page_token)
269 ascending = request.ascending
270 is_self = request.user_id == context.user_id
272 statement = where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where(
273 PublicTrip.user_id == request.user_id
274 )
276 if not is_self:
277 # On other users' profiles show only active, upcoming trips that the viewer is allowed to see.
278 # Check moderation against each distinct node the user has active trips in.
279 active_node_ids = (
280 session.execute(
281 select(PublicTrip.node_id)
282 .where(PublicTrip.user_id == request.user_id)
283 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
284 .where(PublicTrip.to_date >= today())
285 .distinct()
286 )
287 .scalars()
288 .all()
289 )
290 viewer_is_moderator = any(can_moderate_node(session, context.user_id, nid) for nid in active_node_ids)
292 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where(
293 PublicTrip.to_date >= today()
294 )
295 if not viewer_is_moderator: 295 ↛ 303line 295 didn't jump to line 303 because the condition on line 295 was always true
296 statement = statement.where(_same_gender_filter(context))
297 elif request.statuses_in:
298 statuses = [publictripstatus2sql[s] for s in request.statuses_in if s in publictripstatus2sql]
299 if statuses: 299 ↛ 303line 299 didn't jump to line 303 because the condition on line 299 was always true
300 statement = statement.where(PublicTrip.status.in_(statuses))
302 # Cursor-based pagination using (from_date, id) composite key
303 if cursor_date is not None and cursor_id is not None: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 if ascending:
305 statement = statement.where(
306 or_(
307 PublicTrip.from_date > cursor_date,
308 and_(PublicTrip.from_date == cursor_date, PublicTrip.id > cursor_id),
309 )
310 )
311 else:
312 statement = statement.where(
313 or_(
314 PublicTrip.from_date < cursor_date,
315 and_(PublicTrip.from_date == cursor_date, PublicTrip.id < cursor_id),
316 )
317 )
318 if ascending:
319 statement = statement.order_by(PublicTrip.from_date.asc(), PublicTrip.id.asc())
320 else:
321 statement = statement.order_by(PublicTrip.from_date.desc(), PublicTrip.id.desc())
323 statement = statement.limit(page_size + 1).options(selectinload(PublicTrip.node, Node.official_cluster))
324 public_trips = session.execute(statement).scalars().all()
326 next_page_token = None
327 if len(public_trips) > page_size: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 last = public_trips[page_size - 1]
329 next_page_token = f"{last.from_date.isoformat()}:{last.id}"
331 return public_trips_pb2.ListPublicTripsByUserRes(
332 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
333 next_page_token=next_page_token,
334 )
336 def UpdatePublicTrip(
337 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session
338 ) -> public_trips_pb2.PublicTrip:
339 if not context.get_boolean_value("public_trips_enabled", False): 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true
340 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled")
342 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none()
344 if not public_trip or public_trip.user_id != context.user_id:
345 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
347 editing_content = (
348 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description")
349 )
351 if editing_content:
352 today_local = today_in_timezone(public_trip.node.timezone)
354 if public_trip.to_date < today_local:
355 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
357 new_from_date = public_trip.from_date
358 new_to_date = public_trip.to_date
360 if request.HasField("from_date"):
361 parsed = parse_date(request.from_date)
362 if not parsed: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
364 new_from_date = parsed
366 if request.HasField("to_date"):
367 parsed = parse_date(request.to_date)
368 if not parsed: 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, "invalid_date")
370 new_to_date = parsed
372 if new_from_date < today_local: 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true
373 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
375 if new_from_date > new_to_date:
376 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
378 if new_from_date - today_local > timedelta(days=365): 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true
379 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
381 if new_to_date - new_from_date > timedelta(days=365): 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true
382 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
384 if request.HasField("description"):
385 if not request.description.strip():
386 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
387 if not _is_description_long_enough(request.description):
388 context.abort_with_error_code(
389 grpc.StatusCode.INVALID_ARGUMENT,
390 "public_trip_description_too_short",
391 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
392 )
393 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 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, "public_trip_description_too_long")
395 public_trip.description = request.description
397 public_trip.from_date = new_from_date
398 public_trip.to_date = new_to_date
400 if request.HasField("same_gender_only"):
401 public_trip.same_gender_only = request.same_gender_only
403 if request.HasField("status"):
404 new_status = publictripstatus2sql.get(request.status)
405 if new_status == PublicTripStatus.searching_for_host:
406 # Reopening is only allowed if the trip hasn't started yet, matching creation logic.
407 today_local = today_in_timezone(public_trip.node.timezone)
408 if public_trip.from_date < today_local:
409 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
410 elif new_status != PublicTripStatus.closed: 410 ↛ 411line 410 didn't jump to line 411 because the condition on line 410 was never true
411 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status")
412 public_trip.status = new_status
414 log_event(
415 context,
416 session,
417 "public_trip.updated",
418 {
419 "public_trip_id": public_trip.id,
420 "from_date": str(public_trip.from_date),
421 "to_date": str(public_trip.to_date),
422 "status": public_trip.status.name,
423 },
424 )
426 return public_trip_to_pb(public_trip, session, context)