Coverage for app/backend/src/couchers/servicers/public_trips.py: 90%
155 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 15:46 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 15:46 +0000
1import logging
2from datetime import timedelta
4import grpc
5from sqlalchemy import ColumnElement, 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.public_trips import PublicTrip, PublicTripStatus
15from couchers.proto import public_trips_pb2, public_trips_pb2_grpc
16from couchers.servicers.api import user_model_to_pb
17from couchers.sql import to_bool, where_users_column_visible
18from couchers.utils import Timestamp_from_datetime, date_to_api, parse_date, today, today_in_timezone
20logger = logging.getLogger(__name__)
22MAX_PAGINATION_LENGTH = 25
23PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000
25publictripstatus2api = {
26 PublicTripStatus.searching_for_host: public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
27 PublicTripStatus.closed: public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
28}
30publictripstatus2sql = {
31 public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST: PublicTripStatus.searching_for_host,
32 public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED: PublicTripStatus.closed,
33}
36def _is_description_long_enough(text: str) -> bool:
37 # Match Javascript's string.length (utf16 code units) rather than Python's len()
38 # so the backend check aligns with the frontend character counter.
39 text_length_utf16 = len(text.encode("utf-16-le")) // 2
40 return text_length_utf16 >= PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16
43def _same_gender_filter(context: CouchersContext) -> ColumnElement[bool]:
44 # Show the trip if same_gender_only is off or the viewer's gender matches the poster's gender.
45 # Moderator bypass is handled by callers via can_moderate_node before applying this filter.
46 # Uses scalar subqueries rather than extra joins since where_users_column_visible
47 # already joins User on PublicTrip.user_id.
48 viewer_gender = select(User.gender).where(User.id == context.user_id).scalar_subquery()
49 poster_gender = select(User.gender).where(User.id == PublicTrip.user_id).scalar_subquery()
50 return or_(~PublicTrip.same_gender_only, poster_gender == viewer_gender)
53def public_trip_to_pb(
54 public_trip: PublicTrip, session: Session, context: CouchersContext
55) -> public_trips_pb2.PublicTrip:
56 return public_trips_pb2.PublicTrip(
57 trip_id=public_trip.id,
58 user=user_model_to_pb(public_trip.user, session, context),
59 node_id=public_trip.node_id,
60 node_slug=public_trip.node.official_cluster.slug,
61 from_date=date_to_api(public_trip.from_date),
62 to_date=date_to_api(public_trip.to_date),
63 description=public_trip.description,
64 status=publictripstatus2api[public_trip.status],
65 created=Timestamp_from_datetime(public_trip.created),
66 same_gender_only=public_trip.same_gender_only,
67 )
70class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer):
71 def CreatePublicTrip(
72 self, request: public_trips_pb2.CreatePublicTripReq, context: CouchersContext, session: Session
73 ) -> public_trips_pb2.PublicTrip:
74 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
75 if not has_completed_profile(session, user):
76 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip")
78 node = session.execute(select(Node).where(Node.id == request.node_id)).scalar_one_or_none()
79 if not node:
80 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
82 if not node.official_cluster.small_community_features_enabled:
83 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trips_not_enabled")
85 from_date = parse_date(request.from_date)
86 to_date = parse_date(request.to_date)
88 if not from_date or not to_date:
89 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
91 today = today_in_timezone(node.timezone)
93 if from_date < today:
94 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
96 if from_date > to_date:
97 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
99 if from_date - today > timedelta(days=365): 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
102 if to_date - from_date > timedelta(days=365): 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
105 if not request.description.strip():
106 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
108 if not _is_description_long_enough(request.description):
109 context.abort_with_error_code(
110 grpc.StatusCode.INVALID_ARGUMENT,
111 "public_trip_description_too_short",
112 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
113 )
115 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
118 # Disallow overlapping active trips by the same user in the same community
119 existing = session.execute(
120 select(PublicTrip)
121 .where(PublicTrip.user_id == context.user_id)
122 .where(PublicTrip.node_id == node.id)
123 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
124 .where(PublicTrip.to_date >= from_date)
125 .where(PublicTrip.from_date <= to_date)
126 ).scalar_one_or_none()
127 if existing:
128 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists")
130 public_trip = PublicTrip(
131 user_id=context.user_id,
132 node_id=node.id,
133 from_date=from_date,
134 to_date=to_date,
135 description=request.description,
136 same_gender_only=request.same_gender_only,
137 )
138 session.add(public_trip)
139 session.flush()
141 log_event(
142 context,
143 session,
144 "public_trip.created",
145 {
146 "public_trip_id": public_trip.id,
147 "node_id": node.id,
148 "from_date": str(from_date),
149 "to_date": str(to_date),
150 "nights": (to_date - from_date).days,
151 },
152 )
154 return public_trip_to_pb(public_trip, session, context)
156 def GetPublicTrip(
157 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session
158 ) -> public_trips_pb2.PublicTrip:
159 trip_node_id = session.execute(
160 select(PublicTrip.node_id).where(PublicTrip.id == request.trip_id)
161 ).scalar_one_or_none()
162 viewer_is_moderator = trip_node_id is not None and can_moderate_node(session, context.user_id, trip_node_id)
164 statement = (
165 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
166 .where(PublicTrip.id == request.trip_id)
167 .options(selectinload(PublicTrip.node, Node.official_cluster))
168 )
169 if not viewer_is_moderator:
170 statement = statement.where(_same_gender_filter(context))
171 public_trip = session.execute(statement).scalar_one_or_none()
173 if not public_trip:
174 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
176 return public_trip_to_pb(public_trip, session, context)
178 def ListPublicTrips(
179 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session
180 ) -> public_trips_pb2.ListPublicTripsRes:
181 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
182 next_page_id = int(request.page_token) if request.page_token else 0
184 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
185 if not node: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
188 viewer_is_moderator = can_moderate_node(session, context.user_id, node.id)
190 statement = (
191 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
192 .where(PublicTrip.node_id == node.id)
193 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
194 .where(PublicTrip.to_date >= today())
195 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
196 .order_by(PublicTrip.id.desc())
197 .limit(page_size + 1)
198 .options(selectinload(PublicTrip.node, Node.official_cluster))
199 )
200 if not viewer_is_moderator:
201 statement = statement.where(_same_gender_filter(context))
202 public_trips = session.execute(statement).scalars().all()
204 return public_trips_pb2.ListPublicTripsRes(
205 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
206 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
207 )
209 def ListPublicTripsByUser(
210 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session
211 ) -> public_trips_pb2.ListPublicTripsByUserRes:
212 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
213 next_page_id = int(request.page_token) if request.page_token else 0
215 is_self = request.user_id == context.user_id
217 statement = where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where(
218 PublicTrip.user_id == request.user_id
219 )
220 if not is_self:
221 # On other users' profiles show only active, upcoming trips that the viewer is allowed to see.
222 # Check moderation against each distinct node the user has active trips in.
223 active_node_ids = (
224 session.execute(
225 select(PublicTrip.node_id)
226 .where(PublicTrip.user_id == request.user_id)
227 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
228 .where(PublicTrip.to_date >= today())
229 .distinct()
230 )
231 .scalars()
232 .all()
233 )
234 viewer_is_moderator = any(can_moderate_node(session, context.user_id, nid) for nid in active_node_ids)
236 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where(
237 PublicTrip.to_date >= today()
238 )
239 if not viewer_is_moderator: 239 ↛ 241line 239 didn't jump to line 241 because the condition on line 239 was always true
240 statement = statement.where(_same_gender_filter(context))
241 statement = (
242 statement.where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
243 .order_by(PublicTrip.id.desc())
244 .limit(page_size + 1)
245 .options(selectinload(PublicTrip.node, Node.official_cluster))
246 )
247 public_trips = session.execute(statement).scalars().all()
249 return public_trips_pb2.ListPublicTripsByUserRes(
250 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
251 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
252 )
254 def UpdatePublicTrip(
255 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session
256 ) -> public_trips_pb2.PublicTrip:
257 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none()
259 if not public_trip or public_trip.user_id != context.user_id:
260 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
262 editing_content = (
263 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description")
264 )
266 if editing_content:
267 today_local = today_in_timezone(public_trip.node.timezone)
269 if public_trip.to_date < today_local:
270 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
272 new_from_date = public_trip.from_date
273 new_to_date = public_trip.to_date
275 if request.HasField("from_date"):
276 parsed = parse_date(request.from_date)
277 if not parsed: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
279 new_from_date = parsed
281 if request.HasField("to_date"):
282 parsed = parse_date(request.to_date)
283 if not parsed: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true
284 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
285 new_to_date = parsed
287 if new_from_date < today_local: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
290 if new_from_date > new_to_date:
291 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
293 if new_from_date - today_local > timedelta(days=365): 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true
294 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
296 if new_to_date - new_from_date > timedelta(days=365): 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true
297 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
299 if request.HasField("description"):
300 if not request.description.strip():
301 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
302 if not _is_description_long_enough(request.description):
303 context.abort_with_error_code(
304 grpc.StatusCode.INVALID_ARGUMENT,
305 "public_trip_description_too_short",
306 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
307 )
308 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true
309 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
310 public_trip.description = request.description
312 public_trip.from_date = new_from_date
313 public_trip.to_date = new_to_date
315 if request.HasField("same_gender_only"):
316 public_trip.same_gender_only = request.same_gender_only
318 if request.HasField("status"):
319 new_status = publictripstatus2sql.get(request.status)
320 if new_status == PublicTripStatus.searching_for_host:
321 # Reopening is only allowed if the trip hasn't started yet, matching creation logic.
322 today_local = today_in_timezone(public_trip.node.timezone)
323 if public_trip.from_date < today_local:
324 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
325 elif new_status != PublicTripStatus.closed: 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status")
327 public_trip.status = new_status
329 log_event(
330 context,
331 session,
332 "public_trip.updated",
333 {
334 "public_trip_id": public_trip.id,
335 "from_date": str(public_trip.from_date),
336 "to_date": str(public_trip.to_date),
337 "status": public_trip.status.name,
338 },
339 )
341 return public_trip_to_pb(public_trip, session, context)