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

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

21 

22logger = logging.getLogger(__name__) 

23 

24MAX_PAGINATION_LENGTH = 25 

25PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000 

26 

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} 

31 

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} 

36 

37 

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 

43 

44 

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) 

51 

52 

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) 

61 

62 

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 

102 

103 

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") 

110 

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") 

114 

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") 

118 

119 if not node.official_cluster.small_community_features_enabled: 

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

121 

122 from_date = parse_date(request.from_date) 

123 to_date = parse_date(request.to_date) 

124 

125 if not from_date or not to_date: 

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

127 

128 today = today_in_timezone(node.timezone) 

129 

130 if from_date < today: 

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

132 

133 if from_date > to_date: 

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

135 

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") 

138 

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") 

141 

142 if not request.description.strip(): 

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

144 

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 ) 

151 

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") 

154 

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") 

166 

167 public_trip: PublicTrip | None = None 

168 

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 

183 

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 

191 

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 ) 

204 

205 return public_trip_to_pb(public_trip, session, context) 

206 

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") 

212 

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) 

217 

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() 

231 

232 if not public_trip: 

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

234 

235 return public_trip_to_pb(public_trip, session, context) 

236 

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") 

242 

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 

245 

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") 

249 

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

251 

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() 

270 

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 ) 

275 

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") 

281 

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 

286 

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) 

293 

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) 

309 

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)) 

319 

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()) 

340 

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

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

343 

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}" 

348 

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 ) 

353 

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") 

359 

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

361 

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") 

364 

365 editing_content = ( 

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

367 ) 

368 

369 if editing_content: 

370 today_local = today_in_timezone(public_trip.node.timezone) 

371 

372 if public_trip.to_date < today_local: 

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

374 

375 new_from_date = public_trip.from_date 

376 new_to_date = public_trip.to_date 

377 

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 

383 

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 

389 

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") 

392 

393 if new_from_date > new_to_date: 

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

395 

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") 

398 

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") 

401 

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 

414 

415 public_trip.from_date = new_from_date 

416 public_trip.to_date = new_to_date 

417 

418 if request.HasField("same_gender_only"): 

419 public_trip.same_gender_only = request.same_gender_only 

420 

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 

431 

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 ) 

443 

444 return public_trip_to_pb(public_trip, session, context)