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

190 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-22 15:22 +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 return pb 

86 

87 

88class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer): 

89 def CreatePublicTrip( 

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

91 ) -> public_trips_pb2.PublicTrip: 

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

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

94 

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

96 if not has_completed_profile(session, user): 

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

98 

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

100 if not node: 

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

102 

103 if not node.official_cluster.small_community_features_enabled: 

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

105 

106 from_date = parse_date(request.from_date) 

107 to_date = parse_date(request.to_date) 

108 

109 if not from_date or not to_date: 

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

111 

112 today = today_in_timezone(node.timezone) 

113 

114 if from_date < today: 

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

116 

117 if from_date > to_date: 

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

119 

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

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

122 

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

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

125 

126 if not request.description.strip(): 

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

128 

129 if not _is_description_long_enough(request.description): 

130 context.abort_with_error_code( 

131 grpc.StatusCode.INVALID_ARGUMENT, 

132 "public_trip_description_too_short", 

133 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

134 ) 

135 

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

138 

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

140 existing = session.execute( 

141 select(PublicTrip) 

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

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

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

145 .where(PublicTrip.to_date >= from_date) 

146 .where(PublicTrip.from_date <= to_date) 

147 ).scalar_one_or_none() 

148 if existing: 

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

150 

151 public_trip = PublicTrip( 

152 user_id=context.user_id, 

153 node_id=node.id, 

154 from_date=from_date, 

155 to_date=to_date, 

156 description=request.description, 

157 same_gender_only=request.same_gender_only, 

158 ) 

159 session.add(public_trip) 

160 session.flush() 

161 

162 log_event( 

163 context, 

164 session, 

165 "public_trip.created", 

166 { 

167 "public_trip_id": public_trip.id, 

168 "node_id": node.id, 

169 "from_date": str(from_date), 

170 "to_date": str(to_date), 

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

172 }, 

173 ) 

174 

175 return public_trip_to_pb(public_trip, session, context) 

176 

177 def GetPublicTrip( 

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

179 ) -> public_trips_pb2.PublicTrip: 

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

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

182 

183 trip_node_id = session.execute( 

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

185 ).scalar_one_or_none() 

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

187 

188 statement = ( 

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

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

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

192 ) 

193 if not viewer_is_moderator: 

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

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

196 

197 if not public_trip: 

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

199 

200 return public_trip_to_pb(public_trip, session, context) 

201 

202 def ListPublicTrips( 

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

204 ) -> public_trips_pb2.ListPublicTripsRes: 

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 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

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

210 

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

212 if not node: 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.NOT_FOUND, "community_not_found") 

214 

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

216 

217 statement = ( 

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

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

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

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

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

223 .order_by(PublicTrip.id.desc()) 

224 .limit(page_size + 1) 

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

226 ) 

227 if not viewer_is_moderator: 

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

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

230 

231 return public_trips_pb2.ListPublicTripsRes( 

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

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

234 ) 

235 

236 def ListPublicTripsByUser( 

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

238 ) -> public_trips_pb2.ListPublicTripsByUserRes: 

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

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

241 

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

243 cursor_date, cursor_id = _parse_page_token(request.page_token) 

244 ascending = request.ascending 

245 is_self = request.user_id == context.user_id 

246 

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

248 PublicTrip.user_id == request.user_id 

249 ) 

250 

251 if not is_self: 

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

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

254 active_node_ids = ( 

255 session.execute( 

256 select(PublicTrip.node_id) 

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

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

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

260 .distinct() 

261 ) 

262 .scalars() 

263 .all() 

264 ) 

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

266 

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

268 PublicTrip.to_date >= today() 

269 ) 

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

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

272 elif request.statuses_in: 

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

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

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

276 

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

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

279 if ascending: 

280 statement = statement.where( 

281 or_( 

282 PublicTrip.from_date > cursor_date, 

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

284 ) 

285 ) 

286 else: 

287 statement = statement.where( 

288 or_( 

289 PublicTrip.from_date < cursor_date, 

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

291 ) 

292 ) 

293 if ascending: 

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

295 else: 

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

297 

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

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

300 

301 next_page_token = None 

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

303 last = public_trips[page_size - 1] 

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

305 

306 return public_trips_pb2.ListPublicTripsByUserRes( 

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

308 next_page_token=next_page_token, 

309 ) 

310 

311 def UpdatePublicTrip( 

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

313 ) -> public_trips_pb2.PublicTrip: 

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

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

316 

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

318 

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

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

321 

322 editing_content = ( 

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

324 ) 

325 

326 if editing_content: 

327 today_local = today_in_timezone(public_trip.node.timezone) 

328 

329 if public_trip.to_date < today_local: 

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

331 

332 new_from_date = public_trip.from_date 

333 new_to_date = public_trip.to_date 

334 

335 if request.HasField("from_date"): 

336 parsed = parse_date(request.from_date) 

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

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

339 new_from_date = parsed 

340 

341 if request.HasField("to_date"): 

342 parsed = parse_date(request.to_date) 

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

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

345 new_to_date = parsed 

346 

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

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

349 

350 if new_from_date > new_to_date: 

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

352 

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

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

355 

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

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

358 

359 if request.HasField("description"): 

360 if not request.description.strip(): 

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

362 if not _is_description_long_enough(request.description): 

363 context.abort_with_error_code( 

364 grpc.StatusCode.INVALID_ARGUMENT, 

365 "public_trip_description_too_short", 

366 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

367 ) 

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

370 public_trip.description = request.description 

371 

372 public_trip.from_date = new_from_date 

373 public_trip.to_date = new_to_date 

374 

375 if request.HasField("same_gender_only"): 

376 public_trip.same_gender_only = request.same_gender_only 

377 

378 if request.HasField("status"): 

379 new_status = publictripstatus2sql.get(request.status) 

380 if new_status == PublicTripStatus.searching_for_host: 

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

382 today_local = today_in_timezone(public_trip.node.timezone) 

383 if public_trip.from_date < today_local: 

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

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

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

387 public_trip.status = new_status 

388 

389 log_event( 

390 context, 

391 session, 

392 "public_trip.updated", 

393 { 

394 "public_trip_id": public_trip.id, 

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

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

397 "status": public_trip.status.name, 

398 }, 

399 ) 

400 

401 return public_trip_to_pb(public_trip, session, context)