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

155 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-05 15:46 +0000

1import logging 

2from datetime import timedelta 

3 

4import grpc 

5from sqlalchemy import ColumnElement, 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.public_trips import PublicTrip, PublicTripStatus 

15from couchers.proto import public_trips_pb2, public_trips_pb2_grpc 

16from couchers.servicers.api import user_model_to_pb 

17from couchers.sql import to_bool, where_users_column_visible 

18from couchers.utils import Timestamp_from_datetime, date_to_api, parse_date, today, today_in_timezone 

19 

20logger = logging.getLogger(__name__) 

21 

22MAX_PAGINATION_LENGTH = 25 

23PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000 

24 

25publictripstatus2api = { 

26 PublicTripStatus.searching_for_host: public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

27 PublicTripStatus.closed: public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

28} 

29 

30publictripstatus2sql = { 

31 public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST: PublicTripStatus.searching_for_host, 

32 public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED: PublicTripStatus.closed, 

33} 

34 

35 

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

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

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

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

40 return text_length_utf16 >= PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16 

41 

42 

43def _same_gender_filter(context: CouchersContext) -> ColumnElement[bool]: 

44 # Show the trip if same_gender_only is off or the viewer's gender matches the poster's gender. 

45 # Moderator bypass is handled by callers via can_moderate_node before applying this filter. 

46 # Uses scalar subqueries rather than extra joins since where_users_column_visible 

47 # already joins User on PublicTrip.user_id. 

48 viewer_gender = select(User.gender).where(User.id == context.user_id).scalar_subquery() 

49 poster_gender = select(User.gender).where(User.id == PublicTrip.user_id).scalar_subquery() 

50 return or_(~PublicTrip.same_gender_only, poster_gender == viewer_gender) 

51 

52 

53def public_trip_to_pb( 

54 public_trip: PublicTrip, session: Session, context: CouchersContext 

55) -> public_trips_pb2.PublicTrip: 

56 return public_trips_pb2.PublicTrip( 

57 trip_id=public_trip.id, 

58 user=user_model_to_pb(public_trip.user, session, context), 

59 node_id=public_trip.node_id, 

60 node_slug=public_trip.node.official_cluster.slug, 

61 from_date=date_to_api(public_trip.from_date), 

62 to_date=date_to_api(public_trip.to_date), 

63 description=public_trip.description, 

64 status=publictripstatus2api[public_trip.status], 

65 created=Timestamp_from_datetime(public_trip.created), 

66 same_gender_only=public_trip.same_gender_only, 

67 ) 

68 

69 

70class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer): 

71 def CreatePublicTrip( 

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

73 ) -> public_trips_pb2.PublicTrip: 

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

75 if not has_completed_profile(session, user): 

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

77 

78 node = session.execute(select(Node).where(Node.id == request.node_id)).scalar_one_or_none() 

79 if not node: 

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

81 

82 if not node.official_cluster.small_community_features_enabled: 

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

84 

85 from_date = parse_date(request.from_date) 

86 to_date = parse_date(request.to_date) 

87 

88 if not from_date or not to_date: 

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

90 

91 today = today_in_timezone(node.timezone) 

92 

93 if from_date < today: 

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

95 

96 if from_date > to_date: 

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

98 

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

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

101 

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

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

104 

105 if not request.description.strip(): 

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

107 

108 if not _is_description_long_enough(request.description): 

109 context.abort_with_error_code( 

110 grpc.StatusCode.INVALID_ARGUMENT, 

111 "public_trip_description_too_short", 

112 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

113 ) 

114 

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

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

117 

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

119 existing = session.execute( 

120 select(PublicTrip) 

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

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

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

124 .where(PublicTrip.to_date >= from_date) 

125 .where(PublicTrip.from_date <= to_date) 

126 ).scalar_one_or_none() 

127 if existing: 

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

129 

130 public_trip = PublicTrip( 

131 user_id=context.user_id, 

132 node_id=node.id, 

133 from_date=from_date, 

134 to_date=to_date, 

135 description=request.description, 

136 same_gender_only=request.same_gender_only, 

137 ) 

138 session.add(public_trip) 

139 session.flush() 

140 

141 log_event( 

142 context, 

143 session, 

144 "public_trip.created", 

145 { 

146 "public_trip_id": public_trip.id, 

147 "node_id": node.id, 

148 "from_date": str(from_date), 

149 "to_date": str(to_date), 

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

151 }, 

152 ) 

153 

154 return public_trip_to_pb(public_trip, session, context) 

155 

156 def GetPublicTrip( 

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

158 ) -> public_trips_pb2.PublicTrip: 

159 trip_node_id = session.execute( 

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

161 ).scalar_one_or_none() 

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

163 

164 statement = ( 

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

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

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

168 ) 

169 if not viewer_is_moderator: 

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

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

172 

173 if not public_trip: 

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

175 

176 return public_trip_to_pb(public_trip, session, context) 

177 

178 def ListPublicTrips( 

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

180 ) -> public_trips_pb2.ListPublicTripsRes: 

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

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

183 

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

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

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

187 

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

189 

190 statement = ( 

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

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

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

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

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

196 .order_by(PublicTrip.id.desc()) 

197 .limit(page_size + 1) 

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

199 ) 

200 if not viewer_is_moderator: 

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

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

203 

204 return public_trips_pb2.ListPublicTripsRes( 

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

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

207 ) 

208 

209 def ListPublicTripsByUser( 

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

211 ) -> public_trips_pb2.ListPublicTripsByUserRes: 

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

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

214 

215 is_self = request.user_id == context.user_id 

216 

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

218 PublicTrip.user_id == request.user_id 

219 ) 

220 if not is_self: 

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

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

223 active_node_ids = ( 

224 session.execute( 

225 select(PublicTrip.node_id) 

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

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

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

229 .distinct() 

230 ) 

231 .scalars() 

232 .all() 

233 ) 

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

235 

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

237 PublicTrip.to_date >= today() 

238 ) 

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

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

241 statement = ( 

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

243 .order_by(PublicTrip.id.desc()) 

244 .limit(page_size + 1) 

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

246 ) 

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

248 

249 return public_trips_pb2.ListPublicTripsByUserRes( 

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

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

252 ) 

253 

254 def UpdatePublicTrip( 

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

256 ) -> public_trips_pb2.PublicTrip: 

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

258 

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

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

261 

262 editing_content = ( 

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

264 ) 

265 

266 if editing_content: 

267 today_local = today_in_timezone(public_trip.node.timezone) 

268 

269 if public_trip.to_date < today_local: 

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

271 

272 new_from_date = public_trip.from_date 

273 new_to_date = public_trip.to_date 

274 

275 if request.HasField("from_date"): 

276 parsed = parse_date(request.from_date) 

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

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

279 new_from_date = parsed 

280 

281 if request.HasField("to_date"): 

282 parsed = parse_date(request.to_date) 

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

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

285 new_to_date = parsed 

286 

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

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

289 

290 if new_from_date > new_to_date: 

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

292 

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

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

295 

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

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

298 

299 if request.HasField("description"): 

300 if not request.description.strip(): 

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

302 if not _is_description_long_enough(request.description): 

303 context.abort_with_error_code( 

304 grpc.StatusCode.INVALID_ARGUMENT, 

305 "public_trip_description_too_short", 

306 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

307 ) 

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

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

310 public_trip.description = request.description 

311 

312 public_trip.from_date = new_from_date 

313 public_trip.to_date = new_to_date 

314 

315 if request.HasField("same_gender_only"): 

316 public_trip.same_gender_only = request.same_gender_only 

317 

318 if request.HasField("status"): 

319 new_status = publictripstatus2sql.get(request.status) 

320 if new_status == PublicTripStatus.searching_for_host: 

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

322 today_local = today_in_timezone(public_trip.node.timezone) 

323 if public_trip.from_date < today_local: 

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

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

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

327 public_trip.status = new_status 

328 

329 log_event( 

330 context, 

331 session, 

332 "public_trip.updated", 

333 { 

334 "public_trip_id": public_trip.id, 

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

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

337 "status": public_trip.status.name, 

338 }, 

339 ) 

340 

341 return public_trip_to_pb(public_trip, session, context)