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

180 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 00:30 +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 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

93 if not has_completed_profile(session, user): 

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

95 

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

97 if not node: 

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

99 

100 if not node.official_cluster.small_community_features_enabled: 

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

102 

103 from_date = parse_date(request.from_date) 

104 to_date = parse_date(request.to_date) 

105 

106 if not from_date or not to_date: 

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

108 

109 today = today_in_timezone(node.timezone) 

110 

111 if from_date < today: 

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

113 

114 if from_date > to_date: 

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

116 

117 if from_date - today > timedelta(days=365): 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.INVALID_ARGUMENT, "date_from_after_one_year") 

119 

120 if to_date - from_date > 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_to_after_one_year") 

122 

123 if not request.description.strip(): 

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

125 

126 if not _is_description_long_enough(request.description): 

127 context.abort_with_error_code( 

128 grpc.StatusCode.INVALID_ARGUMENT, 

129 "public_trip_description_too_short", 

130 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

131 ) 

132 

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

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

135 

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

137 existing = session.execute( 

138 select(PublicTrip) 

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

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

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

142 .where(PublicTrip.to_date >= from_date) 

143 .where(PublicTrip.from_date <= to_date) 

144 ).scalar_one_or_none() 

145 if existing: 

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

147 

148 public_trip = PublicTrip( 

149 user_id=context.user_id, 

150 node_id=node.id, 

151 from_date=from_date, 

152 to_date=to_date, 

153 description=request.description, 

154 same_gender_only=request.same_gender_only, 

155 ) 

156 session.add(public_trip) 

157 session.flush() 

158 

159 log_event( 

160 context, 

161 session, 

162 "public_trip.created", 

163 { 

164 "public_trip_id": public_trip.id, 

165 "node_id": node.id, 

166 "from_date": str(from_date), 

167 "to_date": str(to_date), 

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

169 }, 

170 ) 

171 

172 return public_trip_to_pb(public_trip, session, context) 

173 

174 def GetPublicTrip( 

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

176 ) -> public_trips_pb2.PublicTrip: 

177 trip_node_id = session.execute( 

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

179 ).scalar_one_or_none() 

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

181 

182 statement = ( 

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

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

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

186 ) 

187 if not viewer_is_moderator: 

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

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

190 

191 if not public_trip: 

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

193 

194 return public_trip_to_pb(public_trip, session, context) 

195 

196 def ListPublicTrips( 

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

198 ) -> public_trips_pb2.ListPublicTripsRes: 

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

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

201 

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

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

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

205 

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

207 

208 statement = ( 

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

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

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

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

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

214 .order_by(PublicTrip.id.desc()) 

215 .limit(page_size + 1) 

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_trips = session.execute(statement).scalars().all() 

221 

222 return public_trips_pb2.ListPublicTripsRes( 

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

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

225 ) 

226 

227 def ListPublicTripsByUser( 

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

229 ) -> public_trips_pb2.ListPublicTripsByUserRes: 

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

231 cursor_date, cursor_id = _parse_page_token(request.page_token) 

232 ascending = request.ascending 

233 is_self = request.user_id == context.user_id 

234 

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

236 PublicTrip.user_id == request.user_id 

237 ) 

238 

239 if not is_self: 

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

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

242 active_node_ids = ( 

243 session.execute( 

244 select(PublicTrip.node_id) 

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

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

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

248 .distinct() 

249 ) 

250 .scalars() 

251 .all() 

252 ) 

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

254 

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

256 PublicTrip.to_date >= today() 

257 ) 

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

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

260 elif request.statuses_in: 

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

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

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

264 

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

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

267 if ascending: 

268 statement = statement.where( 

269 or_( 

270 PublicTrip.from_date > cursor_date, 

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

272 ) 

273 ) 

274 else: 

275 statement = statement.where( 

276 or_( 

277 PublicTrip.from_date < cursor_date, 

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

279 ) 

280 ) 

281 if ascending: 

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

283 else: 

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

285 

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

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

288 

289 next_page_token = None 

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

291 last = public_trips[page_size - 1] 

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

293 

294 return public_trips_pb2.ListPublicTripsByUserRes( 

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

296 next_page_token=next_page_token, 

297 ) 

298 

299 def UpdatePublicTrip( 

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

301 ) -> public_trips_pb2.PublicTrip: 

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

303 

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

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

306 

307 editing_content = ( 

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

309 ) 

310 

311 if editing_content: 

312 today_local = today_in_timezone(public_trip.node.timezone) 

313 

314 if public_trip.to_date < today_local: 

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

316 

317 new_from_date = public_trip.from_date 

318 new_to_date = public_trip.to_date 

319 

320 if request.HasField("from_date"): 

321 parsed = parse_date(request.from_date) 

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

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

324 new_from_date = parsed 

325 

326 if request.HasField("to_date"): 

327 parsed = parse_date(request.to_date) 

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

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

330 new_to_date = parsed 

331 

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

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

334 

335 if new_from_date > new_to_date: 

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

337 

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

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

340 

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

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

343 

344 if request.HasField("description"): 

345 if not request.description.strip(): 

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

347 if not _is_description_long_enough(request.description): 

348 context.abort_with_error_code( 

349 grpc.StatusCode.INVALID_ARGUMENT, 

350 "public_trip_description_too_short", 

351 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

352 ) 

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

355 public_trip.description = request.description 

356 

357 public_trip.from_date = new_from_date 

358 public_trip.to_date = new_to_date 

359 

360 if request.HasField("same_gender_only"): 

361 public_trip.same_gender_only = request.same_gender_only 

362 

363 if request.HasField("status"): 

364 new_status = publictripstatus2sql.get(request.status) 

365 if new_status == PublicTripStatus.searching_for_host: 

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

367 today_local = today_in_timezone(public_trip.node.timezone) 

368 if public_trip.from_date < today_local: 

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

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

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

372 public_trip.status = new_status 

373 

374 log_event( 

375 context, 

376 session, 

377 "public_trip.updated", 

378 { 

379 "public_trip_id": public_trip.id, 

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

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

382 "status": public_trip.status.name, 

383 }, 

384 ) 

385 

386 return public_trip_to_pb(public_trip, session, context)