Coverage for app/backend/src/couchers/servicers/public_trips.py: 84%

191 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-20 15:12 +0000

1import logging 

2from datetime import date, timedelta 

3 

4import grpc 

5from sqlalchemy import ColumnElement, and_, func, or_, select 

6from sqlalchemy.orm import Session, selectinload 

7 

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 

20 

21logger = logging.getLogger(__name__) 

22 

23MAX_PAGINATION_LENGTH = 25 

24PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000 

25 

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} 

30 

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} 

35 

36 

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 

42 

43 

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) 

50 

51 

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) 

60 

61 

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 else: 

86 # The viewer's own existing offer on this trip (if any), so the client can 

87 # show an "already offered" state and link to the thread. 

88 pb.viewer_host_request_id = ( 

89 session.execute( 

90 select(HostRequest.conversation_id) 

91 .where(HostRequest.public_trip_id == public_trip.id) 

92 .where(HostRequest.initiator_user_id == context.user_id) 

93 .where(HostRequest.status != HostRequestStatus.cancelled) 

94 ).scalar_one_or_none() 

95 or 0 

96 ) 

97 return pb 

98 

99 

100class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer): 

101 def CreatePublicTrip( 

102 self, request: public_trips_pb2.CreatePublicTripReq, context: CouchersContext, session: Session 

103 ) -> public_trips_pb2.PublicTrip: 

104 if not context.get_boolean_value("public_trips_enabled", False): 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.UNAVAILABLE, "public_trips_disabled") 

106 

107 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

108 if not has_completed_profile(session, user): 

109 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_public_trip") 

110 

111 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

112 if not node: 

113 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

114 

115 if not node.official_cluster.small_community_features_enabled: 

116 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trips_not_enabled") 

117 

118 from_date = parse_date(request.from_date) 

119 to_date = parse_date(request.to_date) 

120 

121 if not from_date or not to_date: 

122 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date") 

123 

124 today = today_in_timezone(node.timezone) 

125 

126 if from_date < today: 

127 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today") 

128 

129 if from_date > to_date: 

130 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to") 

131 

132 if from_date - today > timedelta(days=365): 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true

133 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year") 

134 

135 if to_date - from_date > timedelta(days=365): 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_to_after_one_year") 

137 

138 if not request.description.strip(): 

139 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description") 

140 

141 if not _is_description_long_enough(request.description): 

142 context.abort_with_error_code( 

143 grpc.StatusCode.INVALID_ARGUMENT, 

144 "public_trip_description_too_short", 

145 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

146 ) 

147 

148 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 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, "public_trip_description_too_long") 

150 

151 # Disallow overlapping active trips by the same user in the same community 

152 existing = session.execute( 

153 select(PublicTrip) 

154 .where(PublicTrip.user_id == context.user_id) 

155 .where(PublicTrip.node_id == node.id) 

156 .where(PublicTrip.status == PublicTripStatus.searching_for_host) 

157 .where(PublicTrip.to_date >= from_date) 

158 .where(PublicTrip.from_date <= to_date) 

159 ).scalar_one_or_none() 

160 if existing: 

161 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "overlapping_public_trip_exists") 

162 

163 public_trip = PublicTrip( 

164 user_id=context.user_id, 

165 node_id=node.id, 

166 from_date=from_date, 

167 to_date=to_date, 

168 description=request.description, 

169 same_gender_only=request.same_gender_only, 

170 ) 

171 session.add(public_trip) 

172 session.flush() 

173 

174 log_event( 

175 context, 

176 session, 

177 "public_trip.created", 

178 { 

179 "public_trip_id": public_trip.id, 

180 "node_id": node.id, 

181 "from_date": str(from_date), 

182 "to_date": str(to_date), 

183 "nights": (to_date - from_date).days, 

184 }, 

185 ) 

186 

187 return public_trip_to_pb(public_trip, session, context) 

188 

189 def GetPublicTrip( 

190 self, request: public_trips_pb2.GetPublicTripReq, context: CouchersContext, session: Session 

191 ) -> public_trips_pb2.PublicTrip: 

192 if not context.get_boolean_value("public_trips_enabled", False): 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true

193 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled") 

194 

195 trip_node_id = session.execute( 

196 select(PublicTrip.node_id).where(PublicTrip.id == request.trip_id) 

197 ).scalar_one_or_none() 

198 viewer_is_moderator = trip_node_id is not None and can_moderate_node(session, context.user_id, trip_node_id) 

199 

200 statement = ( 

201 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id) 

202 .where(PublicTrip.id == request.trip_id) 

203 .options(selectinload(PublicTrip.node, Node.official_cluster)) 

204 ) 

205 if not viewer_is_moderator: 

206 statement = statement.where(_same_gender_filter(context)) 

207 public_trip = session.execute(statement).scalar_one_or_none() 

208 

209 if not public_trip: 

210 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found") 

211 

212 return public_trip_to_pb(public_trip, session, context) 

213 

214 def ListPublicTrips( 

215 self, request: public_trips_pb2.ListPublicTripsReq, context: CouchersContext, session: Session 

216 ) -> public_trips_pb2.ListPublicTripsRes: 

217 if not context.get_boolean_value("public_trips_enabled", False): 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled") 

219 

220 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

221 next_page_id = int(request.page_token) if request.page_token else 0 

222 

223 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

224 if not node: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

226 

227 viewer_is_moderator = can_moderate_node(session, context.user_id, node.id) 

228 

229 statement = ( 

230 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id) 

231 .where(PublicTrip.node_id == node.id) 

232 .where(PublicTrip.status == PublicTripStatus.searching_for_host) 

233 .where(PublicTrip.to_date >= today()) 

234 .where(or_(PublicTrip.id <= next_page_id, to_bool(next_page_id == 0))) 

235 .order_by(PublicTrip.id.desc()) 

236 .limit(page_size + 1) 

237 .options(selectinload(PublicTrip.node, Node.official_cluster)) 

238 ) 

239 if not viewer_is_moderator: 

240 statement = statement.where(_same_gender_filter(context)) 

241 public_trips = session.execute(statement).scalars().all() 

242 

243 return public_trips_pb2.ListPublicTripsRes( 

244 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]], 

245 next_page_token=str(public_trips[-1].id) if len(public_trips) > page_size else None, 

246 ) 

247 

248 def ListPublicTripsByUser( 

249 self, request: public_trips_pb2.ListPublicTripsByUserReq, context: CouchersContext, session: Session 

250 ) -> public_trips_pb2.ListPublicTripsByUserRes: 

251 if not context.get_boolean_value("public_trips_enabled", False): 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled") 

253 

254 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

255 cursor_date, cursor_id = _parse_page_token(request.page_token) 

256 ascending = request.ascending 

257 is_self = request.user_id == context.user_id 

258 

259 statement = where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where( 

260 PublicTrip.user_id == request.user_id 

261 ) 

262 

263 if not is_self: 

264 # On other users' profiles show only active, upcoming trips that the viewer is allowed to see. 

265 # Check moderation against each distinct node the user has active trips in. 

266 active_node_ids = ( 

267 session.execute( 

268 select(PublicTrip.node_id) 

269 .where(PublicTrip.user_id == request.user_id) 

270 .where(PublicTrip.status == PublicTripStatus.searching_for_host) 

271 .where(PublicTrip.to_date >= today()) 

272 .distinct() 

273 ) 

274 .scalars() 

275 .all() 

276 ) 

277 viewer_is_moderator = any(can_moderate_node(session, context.user_id, nid) for nid in active_node_ids) 

278 

279 statement = statement.where(PublicTrip.status == PublicTripStatus.searching_for_host).where( 

280 PublicTrip.to_date >= today() 

281 ) 

282 if not viewer_is_moderator: 282 ↛ 290line 282 didn't jump to line 290 because the condition on line 282 was always true

283 statement = statement.where(_same_gender_filter(context)) 

284 elif request.statuses_in: 

285 statuses = [publictripstatus2sql[s] for s in request.statuses_in if s in publictripstatus2sql] 

286 if statuses: 286 ↛ 290line 286 didn't jump to line 290 because the condition on line 286 was always true

287 statement = statement.where(PublicTrip.status.in_(statuses)) 

288 

289 # Cursor-based pagination using (from_date, id) composite key 

290 if cursor_date is not None and cursor_id is not None: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 if ascending: 

292 statement = statement.where( 

293 or_( 

294 PublicTrip.from_date > cursor_date, 

295 and_(PublicTrip.from_date == cursor_date, PublicTrip.id > cursor_id), 

296 ) 

297 ) 

298 else: 

299 statement = statement.where( 

300 or_( 

301 PublicTrip.from_date < cursor_date, 

302 and_(PublicTrip.from_date == cursor_date, PublicTrip.id < cursor_id), 

303 ) 

304 ) 

305 if ascending: 

306 statement = statement.order_by(PublicTrip.from_date.asc(), PublicTrip.id.asc()) 

307 else: 

308 statement = statement.order_by(PublicTrip.from_date.desc(), PublicTrip.id.desc()) 

309 

310 statement = statement.limit(page_size + 1).options(selectinload(PublicTrip.node, Node.official_cluster)) 

311 public_trips = session.execute(statement).scalars().all() 

312 

313 next_page_token = None 

314 if len(public_trips) > page_size: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true

315 last = public_trips[page_size - 1] 

316 next_page_token = f"{last.from_date.isoformat()}:{last.id}" 

317 

318 return public_trips_pb2.ListPublicTripsByUserRes( 

319 public_trips=[public_trip_to_pb(trip, session, context) for trip in public_trips[:page_size]], 

320 next_page_token=next_page_token, 

321 ) 

322 

323 def UpdatePublicTrip( 

324 self, request: public_trips_pb2.UpdatePublicTripReq, context: CouchersContext, session: Session 

325 ) -> public_trips_pb2.PublicTrip: 

326 if not context.get_boolean_value("public_trips_enabled", False): 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true

327 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "public_trips_disabled") 

328 

329 public_trip = session.execute(select(PublicTrip).where(PublicTrip.id == request.trip_id)).scalar_one_or_none() 

330 

331 if not public_trip or public_trip.user_id != context.user_id: 

332 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "public_trip_not_found") 

333 

334 editing_content = ( 

335 request.HasField("from_date") or request.HasField("to_date") or request.HasField("description") 

336 ) 

337 

338 if editing_content: 

339 today_local = today_in_timezone(public_trip.node.timezone) 

340 

341 if public_trip.to_date < today_local: 

342 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past") 

343 

344 new_from_date = public_trip.from_date 

345 new_to_date = public_trip.to_date 

346 

347 if request.HasField("from_date"): 

348 parsed = parse_date(request.from_date) 

349 if not parsed: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date") 

351 new_from_date = parsed 

352 

353 if request.HasField("to_date"): 

354 parsed = parse_date(request.to_date) 

355 if not parsed: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_date") 

357 new_to_date = parsed 

358 

359 if new_from_date < today_local: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_before_today") 

361 

362 if new_from_date > new_to_date: 

363 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_to") 

364 

365 if new_from_date - today_local > timedelta(days=365): 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true

366 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "date_from_after_one_year") 

367 

368 if new_to_date - new_from_date > timedelta(days=365): 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, "date_to_after_one_year") 

370 

371 if request.HasField("description"): 

372 if not request.description.strip(): 

373 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_public_trip_description") 

374 if not _is_description_long_enough(request.description): 

375 context.abort_with_error_code( 

376 grpc.StatusCode.INVALID_ARGUMENT, 

377 "public_trip_description_too_short", 

378 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

379 ) 

380 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 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, "public_trip_description_too_long") 

382 public_trip.description = request.description 

383 

384 public_trip.from_date = new_from_date 

385 public_trip.to_date = new_to_date 

386 

387 if request.HasField("same_gender_only"): 

388 public_trip.same_gender_only = request.same_gender_only 

389 

390 if request.HasField("status"): 

391 new_status = publictripstatus2sql.get(request.status) 

392 if new_status == PublicTripStatus.searching_for_host: 

393 # Reopening is only allowed if the trip hasn't started yet, matching creation logic. 

394 today_local = today_in_timezone(public_trip.node.timezone) 

395 if public_trip.from_date < today_local: 

396 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "public_trip_in_past") 

397 elif new_status != PublicTripStatus.closed: 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true

398 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_public_trip_status") 

399 public_trip.status = new_status 

400 

401 log_event( 

402 context, 

403 session, 

404 "public_trip.updated", 

405 { 

406 "public_trip_id": public_trip.id, 

407 "from_date": str(public_trip.from_date), 

408 "to_date": str(public_trip.to_date), 

409 "status": public_trip.status.name, 

410 }, 

411 ) 

412 

413 return public_trip_to_pb(public_trip, session, context)