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

196 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-22 16:01 +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 # Per-status tally of offers, for the trip owner's grouped view. 

86 counts_by_status: dict[HostRequestStatus, int] = dict( 

87 session.execute( # type: ignore[arg-type] 

88 select(HostRequest.status, func.count()) 

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

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

91 .group_by(HostRequest.status) 

92 ).all() 

93 ) 

94 pb.offer_tally.pending = counts_by_status.get(HostRequestStatus.pending, 0) 

95 pb.offer_tally.accepted = counts_by_status.get(HostRequestStatus.accepted, 0) 

96 pb.offer_tally.confirmed = counts_by_status.get(HostRequestStatus.confirmed, 0) 

97 pb.offer_tally.declined = counts_by_status.get(HostRequestStatus.rejected, 0) 

98 else: 

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

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

101 pb.viewer_host_request_id = ( 

102 session.execute( 

103 select(HostRequest.conversation_id) 

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

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

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

107 ).scalar_one_or_none() 

108 or 0 

109 ) 

110 return pb 

111 

112 

113class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer): 

114 def CreatePublicTrip( 

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

116 ) -> public_trips_pb2.PublicTrip: 

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

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

119 

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

121 if not has_completed_profile(session, user): 

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

123 

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

125 if not node: 

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

127 

128 if not node.official_cluster.small_community_features_enabled: 

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

130 

131 from_date = parse_date(request.from_date) 

132 to_date = parse_date(request.to_date) 

133 

134 if not from_date or not to_date: 

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

136 

137 today = today_in_timezone(node.timezone) 

138 

139 if from_date < today: 

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

141 

142 if from_date > to_date: 

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

144 

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

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

147 

148 if to_date - from_date > timedelta(days=365): 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, "date_to_after_one_year") 

150 

151 if not request.description.strip(): 

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

153 

154 if not _is_description_long_enough(request.description): 

155 context.abort_with_error_code( 

156 grpc.StatusCode.INVALID_ARGUMENT, 

157 "public_trip_description_too_short", 

158 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

159 ) 

160 

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

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

163 

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

165 existing = session.execute( 

166 select(PublicTrip) 

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

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

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

170 .where(PublicTrip.to_date >= from_date) 

171 .where(PublicTrip.from_date <= to_date) 

172 ).scalar_one_or_none() 

173 if existing: 

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

175 

176 public_trip = PublicTrip( 

177 user_id=context.user_id, 

178 node_id=node.id, 

179 from_date=from_date, 

180 to_date=to_date, 

181 description=request.description, 

182 same_gender_only=request.same_gender_only, 

183 ) 

184 session.add(public_trip) 

185 session.flush() 

186 

187 log_event( 

188 context, 

189 session, 

190 "public_trip.created", 

191 { 

192 "public_trip_id": public_trip.id, 

193 "node_id": node.id, 

194 "from_date": str(from_date), 

195 "to_date": str(to_date), 

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

197 }, 

198 ) 

199 

200 return public_trip_to_pb(public_trip, session, context) 

201 

202 def GetPublicTrip( 

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

204 ) -> public_trips_pb2.PublicTrip: 

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

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

207 

208 trip_node_id = session.execute( 

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

210 ).scalar_one_or_none() 

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

212 

213 statement = ( 

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

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

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

217 ) 

218 if not viewer_is_moderator: 

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

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

221 

222 if not public_trip: 

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

224 

225 return public_trip_to_pb(public_trip, session, context) 

226 

227 def ListPublicTrips( 

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

229 ) -> public_trips_pb2.ListPublicTripsRes: 

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

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

232 

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

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

235 

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

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

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

239 

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

241 

242 statement = ( 

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

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

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

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

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

248 .order_by(PublicTrip.id.desc()) 

249 .limit(page_size + 1) 

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

251 ) 

252 if not viewer_is_moderator: 

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

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

255 

256 return public_trips_pb2.ListPublicTripsRes( 

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

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

259 ) 

260 

261 def ListPublicTripsByUser( 

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

263 ) -> public_trips_pb2.ListPublicTripsByUserRes: 

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

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

266 

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

268 cursor_date, cursor_id = _parse_page_token(request.page_token) 

269 ascending = request.ascending 

270 is_self = request.user_id == context.user_id 

271 

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

273 PublicTrip.user_id == request.user_id 

274 ) 

275 

276 if not is_self: 

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

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

279 active_node_ids = ( 

280 session.execute( 

281 select(PublicTrip.node_id) 

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

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

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

285 .distinct() 

286 ) 

287 .scalars() 

288 .all() 

289 ) 

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

291 

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

293 PublicTrip.to_date >= today() 

294 ) 

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

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

297 elif request.statuses_in: 

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

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

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

301 

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

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

304 if ascending: 

305 statement = statement.where( 

306 or_( 

307 PublicTrip.from_date > cursor_date, 

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

309 ) 

310 ) 

311 else: 

312 statement = statement.where( 

313 or_( 

314 PublicTrip.from_date < cursor_date, 

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

316 ) 

317 ) 

318 if ascending: 

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

320 else: 

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

322 

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

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

325 

326 next_page_token = None 

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

328 last = public_trips[page_size - 1] 

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

330 

331 return public_trips_pb2.ListPublicTripsByUserRes( 

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

333 next_page_token=next_page_token, 

334 ) 

335 

336 def UpdatePublicTrip( 

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

338 ) -> public_trips_pb2.PublicTrip: 

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

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

341 

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

343 

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

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

346 

347 editing_content = ( 

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

349 ) 

350 

351 if editing_content: 

352 today_local = today_in_timezone(public_trip.node.timezone) 

353 

354 if public_trip.to_date < today_local: 

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

356 

357 new_from_date = public_trip.from_date 

358 new_to_date = public_trip.to_date 

359 

360 if request.HasField("from_date"): 

361 parsed = parse_date(request.from_date) 

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

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

364 new_from_date = parsed 

365 

366 if request.HasField("to_date"): 

367 parsed = parse_date(request.to_date) 

368 if not parsed: 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, "invalid_date") 

370 new_to_date = parsed 

371 

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

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

374 

375 if new_from_date > new_to_date: 

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

377 

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

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

380 

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

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

383 

384 if request.HasField("description"): 

385 if not request.description.strip(): 

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

387 if not _is_description_long_enough(request.description): 

388 context.abort_with_error_code( 

389 grpc.StatusCode.INVALID_ARGUMENT, 

390 "public_trip_description_too_short", 

391 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

392 ) 

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

395 public_trip.description = request.description 

396 

397 public_trip.from_date = new_from_date 

398 public_trip.to_date = new_to_date 

399 

400 if request.HasField("same_gender_only"): 

401 public_trip.same_gender_only = request.same_gender_only 

402 

403 if request.HasField("status"): 

404 new_status = publictripstatus2sql.get(request.status) 

405 if new_status == PublicTripStatus.searching_for_host: 

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

407 today_local = today_in_timezone(public_trip.node.timezone) 

408 if public_trip.from_date < today_local: 

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

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

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

412 public_trip.status = new_status 

413 

414 log_event( 

415 context, 

416 session, 

417 "public_trip.updated", 

418 { 

419 "public_trip_id": public_trip.id, 

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

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

422 "status": public_trip.status.name, 

423 }, 

424 ) 

425 

426 return public_trip_to_pb(public_trip, session, context)