Coverage for app / backend / src / couchers / servicers / public_trips.py: 90%
133 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 17:16 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 17:16 +0000
1import logging
2from datetime import timedelta
4import grpc
5from sqlalchemy import or_, select
6from sqlalchemy.orm import Session
8from couchers.constants import PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16
9from couchers.context import CouchersContext
10from couchers.event_log import log_event
11from couchers.helpers.completed_profile import has_completed_profile
12from couchers.models import Node, NodeType, User
13from couchers.models.public_trips import PublicTrip, PublicTripStatus
14from couchers.proto import public_trips_pb2, public_trips_pb2_grpc
15from couchers.servicers.api import user_model_to_pb
16from couchers.sql import to_bool, where_users_column_visible
17from couchers.utils import Timestamp_from_datetime, date_to_api, parse_date, today, today_in_timezone
19logger = logging.getLogger(__name__)
21MAX_PAGINATION_LENGTH = 25
22PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000
24publictripstatus2api = {
25 PublicTripStatus.searching_for_host: public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST,
26 PublicTripStatus.closed: public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED,
27}
29publictripstatus2sql = {
30 public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST: PublicTripStatus.searching_for_host,
31 public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED: PublicTripStatus.closed,
32}
35def _is_description_long_enough(text: str) -> bool:
36 # Match Javascript's string.length (utf16 code units) rather than Python's len()
37 # so the backend check aligns with the frontend character counter.
38 text_length_utf16 = len(text.encode("utf-16-le")) // 2
39 return text_length_utf16 >= PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16
42def public_trip_to_pb(
43 public_trip: PublicTrip, session: Session, context: CouchersContext
44) -> public_trips_pb2.PublicTrip:
45 return public_trips_pb2.PublicTrip(
46 trip_id=public_trip.id,
47 user=user_model_to_pb(public_trip.user, session, context),
48 node_id=public_trip.node_id,
49 from_date=date_to_api(public_trip.from_date),
50 to_date=date_to_api(public_trip.to_date),
51 description=public_trip.description,
52 status=publictripstatus2api[public_trip.status],
53 created=Timestamp_from_datetime(public_trip.created),
54 )
57class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer):
58 def CreatePublicTrip(
59 self, request: public_trips_pb2.CreatePublicTripReq, context: CouchersContext, session: Session
60 ) -> public_trips_pb2.PublicTrip:
61 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
62 if not has_completed_profile(session, user):
63 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip")
65 node = session.execute(select(Node).where(Node.id == request.node_id)).scalar_one_or_none()
66 if not node:
67 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
69 # Disallow world- and macroregion-level communities (too broad for a trip).
70 # Region/subregion/locality/sublocality are all acceptable.
71 if node.node_type.value < NodeType.region.value:
72 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "community_too_broad_for_public_trip")
74 from_date = parse_date(request.from_date)
75 to_date = parse_date(request.to_date)
77 if not from_date or not to_date:
78 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
80 today = today_in_timezone(user.timezone)
82 if from_date < today:
83 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
85 if from_date > to_date:
86 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
88 if from_date - today > timedelta(days=365): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
91 if to_date - from_date > timedelta(days=365): 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true
92 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
94 if not request.description.strip():
95 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
97 if not _is_description_long_enough(request.description):
98 context.abort_with_error_code(
99 grpc.StatusCode.INVALID_ARGUMENT,
100 "public_trip_description_too_short",
101 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
102 )
104 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
107 # Disallow overlapping active trips by the same user in the same community
108 existing = session.execute(
109 select(PublicTrip)
110 .where(PublicTrip.user_id == context.user_id)
111 .where(PublicTrip.node_id == node.id)
112 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
113 .where(PublicTrip.to_date >= from_date)
114 .where(PublicTrip.from_date <= to_date)
115 ).scalar_one_or_none()
116 if existing:
117 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists")
119 public_trip = PublicTrip(
120 user_id=context.user_id,
121 node_id=node.id,
122 from_date=from_date,
123 to_date=to_date,
124 description=request.description,
125 )
126 session.add(public_trip)
127 session.flush()
129 log_event(
130 context,
131 session,
132 "public_trip.created",
133 {
134 "public_trip_id": public_trip.id,
135 "node_id": node.id,
136 "from_date": str(from_date),
137 "to_date": str(to_date),
138 "nights": (to_date - from_date).days,
139 },
140 )
142 return public_trip_to_pb(public_trip, session, context)
144 def GetPublicTrip(
145 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session
146 ) -> public_trips_pb2.PublicTrip:
147 public_trip = session.execute(
148 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where(
149 PublicTrip.id == request.trip_id
150 )
151 ).scalar_one_or_none()
153 if not public_trip:
154 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
156 return public_trip_to_pb(public_trip, session, context)
158 def ListPublicTrips(
159 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session
160 ) -> public_trips_pb2.ListPublicTripsRes:
161 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
162 next_page_id = int(request.page_token) if request.page_token else 0
164 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
165 if not node: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true
166 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
168 statement = (
169 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id)
170 .where(PublicTrip.node_id == node.id)
171 .where(PublicTrip.status == PublicTripStatus.searching_for_host)
172 .where(PublicTrip.to_date >= today())
173 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
174 .order_by(PublicTrip.id.desc())
175 .limit(page_size + 1)
176 )
177 public_trips = session.execute(statement).scalars().all()
179 return public_trips_pb2.ListPublicTripsRes(
180 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
181 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
182 )
184 def ListPublicTripsByUser(
185 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session
186 ) -> public_trips_pb2.ListPublicTripsByUserRes:
187 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
188 next_page_id = int(request.page_token) if request.page_token else 0
190 is_self = request.user_id == context.user_id
192 statement = where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where(
193 PublicTrip.user_id == request.user_id
194 )
195 if not is_self:
196 # On other users' profiles show only active, upcoming trips
197 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where(
198 PublicTrip.to_date >= today()
199 )
200 statement = (
201 statement.where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0)))
202 .order_by(PublicTrip.id.desc())
203 .limit(page_size + 1)
204 )
205 public_trips = session.execute(statement).scalars().all()
207 return public_trips_pb2.ListPublicTripsByUserRes(
208 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]],
209 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None,
210 )
212 def UpdatePublicTrip(
213 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session
214 ) -> public_trips_pb2.PublicTrip:
215 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none()
217 if not public_trip or public_trip.user_id != context.user_id:
218 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found")
220 editing_content = (
221 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description")
222 )
224 if editing_content:
225 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
226 today_local = today_in_timezone(user.timezone)
228 if public_trip.to_date < today_local:
229 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past")
231 new_from_date = public_trip.from_date
232 new_to_date = public_trip.to_date
234 if request.HasField("from_date"):
235 parsed = parse_date(request.from_date)
236 if not parsed: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
238 new_from_date = parsed
240 if request.HasField("to_date"):
241 parsed = parse_date(request.to_date)
242 if not parsed: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date")
244 new_to_date = parsed
246 if new_from_date < today_local: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true
247 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today")
249 if new_from_date > new_to_date:
250 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to")
252 if new_from_date - today_local > timedelta(days=365): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year")
255 if new_to_date - new_from_date > timedelta(days=365): 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true
256 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year")
258 if request.HasField("description"):
259 if not request.description.strip():
260 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description")
261 if not _is_description_long_enough(request.description):
262 context.abort_with_error_code(
263 grpc.StatusCode.INVALID_ARGUMENT,
264 "public_trip_description_too_short",
265 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16},
266 )
267 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true
268 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long")
269 public_trip.description = request.description
271 public_trip.from_date = new_from_date
272 public_trip.to_date = new_to_date
274 if request.HasField("status"):
275 new_status = publictripstatus2sql.get(request.status)
276 # Only closing is permitted (can't reopen a closed trip).
277 if new_status != PublicTripStatus.closed:
278 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status")
279 public_trip.status = new_status
281 log_event(
282 context,
283 session,
284 "public_trip.updated",
285 {
286 "public_trip_id": public_trip.id,
287 "from_date": str(public_trip.from_date),
288 "to_date": str(public_trip.to_date),
289 "status": public_trip.status.name,
290 },
291 )
293 return public_trip_to_pb(public_trip, session, context)