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

197 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 17:13 +0000

1import logging 

2from datetime import 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.crypto import decrypt_page_token, encrypt_page_token 

11from couchers.db import can_moderate_node 

12from couchers.event_log import log_event 

13from couchers.helpers.completed_profile import has_completed_profile 

14from couchers.models import ModerationObjectType, Node, User 

15from couchers.models.host_requests import HostRequest, HostRequestStatus 

16from couchers.models.public_trips import PublicTrip, PublicTripStatus 

17from couchers.moderation.utils import create_moderation 

18from couchers.proto import public_trips_pb2, public_trips_pb2_grpc 

19from couchers.servicers.api import user_model_to_pb 

20from couchers.sql import to_bool, where_moderated_content_visible, where_users_column_visible 

21from couchers.utils import ( 

22 Timestamp_from_datetime, 

23 date_id_from_page_token, 

24 date_id_to_page_token, 

25 date_to_api, 

26 parse_date, 

27 today_in_timezone, 

28) 

29 

30logger = logging.getLogger(__name__) 

31 

32MAX_PAGINATION_LENGTH = 25 

33PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000 

34 

35publictripstatus2api = { 

36 PublicTripStatus.searching_for_host: public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

37 PublicTripStatus.closed: public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

38} 

39 

40publictripstatus2sql = { 

41 public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST: PublicTripStatus.searching_for_host, 

42 public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED: PublicTripStatus.closed, 

43} 

44 

45 

46def _is_description_long_enough(text: str) -> bool: 

47 # Match Javascript's string.length (utf16 code units) rather than Python's len() 

48 # so the backend check aligns with the frontend character counter. 

49 text_length_utf16 = len(text.encode("utf-16-le")) // 2 

50 return text_length_utf16 >= PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16 

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 timezone=public_trip.timezone, 

75 description=public_trip.description, 

76 status=publictripstatus2api[public_trip.status], 

77 created=Timestamp_from_datetime(public_trip.created), 

78 same_gender_only=public_trip.same_gender_only, 

79 ) 

80 if public_trip.user_id == context.user_id: 

81 offers = ( 

82 select(func.count()) 

83 .select_from(HostRequest) 

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

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

86 ) 

87 offers = where_users_column_visible(offers, context, HostRequest.initiator_user_id) 

88 offers = where_moderated_content_visible(offers, context, HostRequest, is_list_operation=True) 

89 pb.offers_count = session.execute(offers).scalar_one() 

90 else: 

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

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

93 pb.viewer_host_request_id = ( 

94 session.execute( 

95 select(HostRequest.conversation_id) 

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

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

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

99 ).scalar_one_or_none() 

100 or 0 

101 ) 

102 return pb 

103 

104 

105class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer): 

106 def CreatePublicTrip( 

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

108 ) -> public_trips_pb2.PublicTrip: 

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

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

111 

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

113 if not has_completed_profile(session, user): 

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

115 

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

117 if not node: 

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

119 

120 if not node.official_cluster.small_community_features_enabled: 

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

122 

123 from_date = parse_date(request.from_date) 

124 to_date = parse_date(request.to_date) 

125 

126 if not from_date or not to_date: 

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

128 

129 today = today_in_timezone(node.timezone) 

130 

131 if from_date < today: 

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

133 

134 if from_date > to_date: 

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

136 

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

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

139 

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

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

142 

143 if not request.description.strip(): 

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

145 

146 if not _is_description_long_enough(request.description): 

147 context.abort_with_error_code( 

148 grpc.StatusCode.INVALID_ARGUMENT, 

149 "public_trip_description_too_short", 

150 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

151 ) 

152 

153 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true

154 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long") 

155 

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

157 # They share the same timezone so comparing dates is ok 

158 existing = session.execute( 

159 select(PublicTrip) 

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

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

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

163 .where(PublicTrip.to_date >= from_date) 

164 .where(PublicTrip.from_date <= to_date) 

165 ).scalar_one_or_none() 

166 if existing: 

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

168 

169 public_trip: PublicTrip | None = None 

170 

171 def create_object(moderation_state_id: int) -> int: 

172 nonlocal public_trip 

173 public_trip = PublicTrip( 

174 user_id=context.user_id, 

175 node_id=node.id, 

176 from_date=from_date, 

177 to_date=to_date, 

178 description=request.description, 

179 same_gender_only=request.same_gender_only, 

180 moderation_state_id=moderation_state_id, 

181 ) 

182 session.add(public_trip) 

183 session.flush() 

184 return public_trip.id 

185 

186 create_moderation( 

187 session=session, 

188 object_type=ModerationObjectType.public_trip, 

189 object_id=create_object, 

190 creator_user_id=context.user_id, 

191 ) 

192 assert public_trip is not None 

193 

194 log_event( 

195 context, 

196 session, 

197 "public_trip.created", 

198 { 

199 "public_trip_id": public_trip.id, 

200 "node_id": node.id, 

201 "from_date": str(from_date), 

202 "to_date": str(to_date), 

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

204 }, 

205 ) 

206 

207 return public_trip_to_pb(public_trip, session, context) 

208 

209 def GetPublicTrip( 

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

211 ) -> public_trips_pb2.PublicTrip: 

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

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

214 

215 trip_node_id = session.execute( 

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

217 ).scalar_one_or_none() 

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

219 

220 statement = ( 

221 where_moderated_content_visible( 

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

223 context, 

224 PublicTrip, 

225 is_list_operation=False, 

226 ) 

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

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

229 ) 

230 if not viewer_is_moderator: 

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

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

233 

234 if not public_trip: 

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

236 

237 return public_trip_to_pb(public_trip, session, context) 

238 

239 def ListPublicTrips( 

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

241 ) -> public_trips_pb2.ListPublicTripsRes: 

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

244 

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

246 next_page_id = int(decrypt_page_token(request.page_token)) if request.page_token else 0 

247 

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

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

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

251 

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

253 

254 statement = ( 

255 where_moderated_content_visible( 

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

257 context, 

258 PublicTrip, 

259 is_list_operation=True, 

260 ) 

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

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

263 .where(PublicTrip.end_time >= func.now()) 

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

265 .order_by(PublicTrip.id.desc()) 

266 .limit(page_size + 1) 

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

268 ) 

269 if not viewer_is_moderator: 

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

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

272 

273 return public_trips_pb2.ListPublicTripsRes( 

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

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

276 ) 

277 

278 def ListPublicTripsByUser( 

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

280 ) -> public_trips_pb2.ListPublicTripsByUserRes: 

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

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

283 

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

285 cursor = date_id_from_page_token(request.page_token) if request.page_token else None 

286 ascending = request.ascending 

287 is_self = request.user_id == context.user_id 

288 

289 statement = where_moderated_content_visible( 

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

291 context, 

292 PublicTrip, 

293 is_list_operation=True, 

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

295 

296 if not is_self: 

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

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

299 active_node_ids = ( 

300 session.execute( 

301 select(PublicTrip.node_id) 

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

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

304 .where(PublicTrip.end_time >= func.now()) 

305 .distinct() 

306 ) 

307 .scalars() 

308 .all() 

309 ) 

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

311 

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

313 PublicTrip.end_time >= func.now() 

314 ) 

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

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

317 elif request.statuses_in: 

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

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

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

321 

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

323 if cursor is not None: 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true

324 cursor_date, cursor_id = cursor 

325 if ascending: 

326 statement = statement.where( 

327 or_( 

328 PublicTrip.from_date > cursor_date, 

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

330 ) 

331 ) 

332 else: 

333 statement = statement.where( 

334 or_( 

335 PublicTrip.from_date < cursor_date, 

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

337 ) 

338 ) 

339 if ascending: 

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

341 else: 

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

343 

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

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

346 

347 next_page_token = None 

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

349 last = public_trips[page_size - 1] 

350 next_page_token = date_id_to_page_token(last.from_date, last.id) 

351 

352 return public_trips_pb2.ListPublicTripsByUserRes( 

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

354 next_page_token=next_page_token, 

355 ) 

356 

357 def UpdatePublicTrip( 

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

359 ) -> public_trips_pb2.PublicTrip: 

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

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

362 

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

364 

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

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

367 

368 editing_content = ( 

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

370 ) 

371 

372 if editing_content: 

373 today_local = today_in_timezone(public_trip.timezone) 

374 

375 if public_trip.to_date < today_local: 

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

377 

378 new_from_date = public_trip.from_date 

379 new_to_date = public_trip.to_date 

380 

381 if request.HasField("from_date"): 

382 parsed = parse_date(request.from_date) 

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

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

385 new_from_date = parsed 

386 

387 if request.HasField("to_date"): 

388 parsed = parse_date(request.to_date) 

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

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

391 new_to_date = parsed 

392 

393 if new_from_date < today_local: 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, "date_from_before_today") 

395 

396 if new_from_date > new_to_date: 

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

398 

399 if new_from_date - today_local > 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_from_after_one_year") 

401 

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

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

404 

405 if request.HasField("description"): 

406 if not request.description.strip(): 

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

408 if not _is_description_long_enough(request.description): 

409 context.abort_with_error_code( 

410 grpc.StatusCode.INVALID_ARGUMENT, 

411 "public_trip_description_too_short", 

412 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

413 ) 

414 if len(request.description) > PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

415 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "public_trip_description_too_long") 

416 public_trip.description = request.description 

417 

418 public_trip.from_date = new_from_date 

419 public_trip.to_date = new_to_date 

420 

421 if request.HasField("same_gender_only"): 

422 public_trip.same_gender_only = request.same_gender_only 

423 

424 if request.HasField("status"): 

425 new_status = publictripstatus2sql.get(request.status) 

426 if new_status == PublicTripStatus.searching_for_host: 

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

428 today_local = today_in_timezone(public_trip.timezone) 

429 if public_trip.from_date < today_local: 

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

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

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

433 public_trip.status = new_status 

434 

435 log_event( 

436 context, 

437 session, 

438 "public_trip.updated", 

439 { 

440 "public_trip_id": public_trip.id, 

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

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

443 "status": public_trip.status.name, 

444 }, 

445 ) 

446 

447 return public_trip_to_pb(public_trip, session, context)