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

133 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 17:16 +0000

1import logging 

2from datetime import timedelta 

3 

4import grpc 

5from sqlalchemy import or_, select 

6from sqlalchemy.orm import Session 

7 

8from couchers.constants import PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16 

9from couchers.context import CouchersContext 

10from couchers.event_log import log_event 

11from couchers.helpers.completed_profile import has_completed_profile 

12from couchers.models import Node, NodeType, User 

13from couchers.models.public_trips import PublicTrip, PublicTripStatus 

14from couchers.proto import public_trips_pb2, public_trips_pb2_grpc 

15from couchers.servicers.api import user_model_to_pb 

16from couchers.sql import to_bool, where_users_column_visible 

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

18 

19logger = logging.getLogger(__name__) 

20 

21MAX_PAGINATION_LENGTH = 25 

22PUBLIC_TRIP_DESCRIPTION_MAX_LENGTH = 10_000 

23 

24publictripstatus2api = { 

25 PublicTripStatus.searching_for_host: public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

26 PublicTripStatus.closed: public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

27} 

28 

29publictripstatus2sql = { 

30 public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST: PublicTripStatus.searching_for_host, 

31 public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED: PublicTripStatus.closed, 

32} 

33 

34 

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

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

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

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

39 return text_length_utf16 >= PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16 

40 

41 

42def public_trip_to_pb( 

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

44) -> public_trips_pb2.PublicTrip: 

45 return public_trips_pb2.PublicTrip( 

46 trip_id=public_trip.id, 

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

48 node_id=public_trip.node_id, 

49 from_date=date_to_api(public_trip.from_date), 

50 to_date=date_to_api(public_trip.to_date), 

51 description=public_trip.description, 

52 status=publictripstatus2api[public_trip.status], 

53 created=Timestamp_from_datetime(public_trip.created), 

54 ) 

55 

56 

57class PublicTrips(public_trips_pb2_grpc.PublicTripsServicer): 

58 def CreatePublicTrip( 

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

60 ) -> public_trips_pb2.PublicTrip: 

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

62 if not has_completed_profile(session, user): 

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

64 

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

66 if not node: 

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

68 

69 # Disallow world- and macroregion-level communities (too broad for a trip). 

70 # Region/subregion/locality/sublocality are all acceptable. 

71 if node.node_type.value < NodeType.region.value: 

72 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "community_too_broad_for_public_trip") 

73 

74 from_date = parse_date(request.from_date) 

75 to_date = parse_date(request.to_date) 

76 

77 if not from_date or not to_date: 

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

79 

80 today = today_in_timezone(user.timezone) 

81 

82 if from_date < today: 

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

84 

85 if from_date > to_date: 

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

87 

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

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

90 

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

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

93 

94 if not request.description.strip(): 

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

96 

97 if not _is_description_long_enough(request.description): 

98 context.abort_with_error_code( 

99 grpc.StatusCode.INVALID_ARGUMENT, 

100 "public_trip_description_too_short", 

101 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

102 ) 

103 

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

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

106 

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

108 existing = session.execute( 

109 select(PublicTrip) 

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

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

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

113 .where(PublicTrip.to_date >= from_date) 

114 .where(PublicTrip.from_date <= to_date) 

115 ).scalar_one_or_none() 

116 if existing: 

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

118 

119 public_trip = PublicTrip( 

120 user_id=context.user_id, 

121 node_id=node.id, 

122 from_date=from_date, 

123 to_date=to_date, 

124 description=request.description, 

125 ) 

126 session.add(public_trip) 

127 session.flush() 

128 

129 log_event( 

130 context, 

131 session, 

132 "public_trip.created", 

133 { 

134 "public_trip_id": public_trip.id, 

135 "node_id": node.id, 

136 "from_date": str(from_date), 

137 "to_date": str(to_date), 

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

139 }, 

140 ) 

141 

142 return public_trip_to_pb(public_trip, session, context) 

143 

144 def GetPublicTrip( 

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

146 ) -> public_trips_pb2.PublicTrip: 

147 public_trip = session.execute( 

148 where_users_column_visible(select(PublicTrip), context, PublicTrip.user_id).where( 

149 PublicTrip.id == request.trip_id 

150 ) 

151 ).scalar_one_or_none() 

152 

153 if not public_trip: 

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

155 

156 return public_trip_to_pb(public_trip, session, context) 

157 

158 def ListPublicTrips( 

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

160 ) -> public_trips_pb2.ListPublicTripsRes: 

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

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

163 

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

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

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

167 

168 statement = ( 

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

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

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

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

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

174 .order_by(PublicTrip.id.desc()) 

175 .limit(page_size + 1) 

176 ) 

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

178 

179 return public_trips_pb2.ListPublicTripsRes( 

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

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

182 ) 

183 

184 def ListPublicTripsByUser( 

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

186 ) -> public_trips_pb2.ListPublicTripsByUserRes: 

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

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

189 

190 is_self = request.user_id == context.user_id 

191 

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

193 PublicTrip.user_id == request.user_id 

194 ) 

195 if not is_self: 

196 # On other users' profiles show only active, upcoming trips 

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

198 PublicTrip.to_date >= today() 

199 ) 

200 statement = ( 

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

202 .order_by(PublicTrip.id.desc()) 

203 .limit(page_size + 1) 

204 ) 

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

206 

207 return public_trips_pb2.ListPublicTripsByUserRes( 

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

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

210 ) 

211 

212 def UpdatePublicTrip( 

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

214 ) -> public_trips_pb2.PublicTrip: 

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

216 

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

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

219 

220 editing_content = ( 

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

222 ) 

223 

224 if editing_content: 

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

226 today_local = today_in_timezone(user.timezone) 

227 

228 if public_trip.to_date < today_local: 

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

230 

231 new_from_date = public_trip.from_date 

232 new_to_date = public_trip.to_date 

233 

234 if request.HasField("from_date"): 

235 parsed = parse_date(request.from_date) 

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

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

238 new_from_date = parsed 

239 

240 if request.HasField("to_date"): 

241 parsed = parse_date(request.to_date) 

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

244 new_to_date = parsed 

245 

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

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

248 

249 if new_from_date > new_to_date: 

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

251 

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

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

254 

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

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

257 

258 if request.HasField("description"): 

259 if not request.description.strip(): 

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

261 if not _is_description_long_enough(request.description): 

262 context.abort_with_error_code( 

263 grpc.StatusCode.INVALID_ARGUMENT, 

264 "public_trip_description_too_short", 

265 substitutions={"count": PUBLIC_TRIP_DESCRIPTION_MIN_LENGTH_UTF16}, 

266 ) 

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

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

269 public_trip.description = request.description 

270 

271 public_trip.from_date = new_from_date 

272 public_trip.to_date = new_to_date 

273 

274 if request.HasField("status"): 

275 new_status = publictripstatus2sql.get(request.status) 

276 # Only closing is permitted (can't reopen a closed trip). 

277 if new_status != PublicTripStatus.closed: 

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

279 public_trip.status = new_status 

280 

281 log_event( 

282 context, 

283 session, 

284 "public_trip.updated", 

285 { 

286 "public_trip_id": public_trip.id, 

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

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

289 "status": public_trip.status.name, 

290 }, 

291 ) 

292 

293 return public_trip_to_pb(public_trip, session, context)