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

183 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1import json 

2import logging 

3 

4import grpc 

5from geoalchemy2.shape import from_shape 

6from google.protobuf import empty_pb2 

7from shapely.geometry import shape 

8from shapely.geometry.base import BaseGeometry 

9from sqlalchemy import select, tuple_ 

10from sqlalchemy.orm import Session 

11from sqlalchemy.sql import exists, update 

12 

13from couchers import urls 

14from couchers.context import CouchersContext 

15from couchers.db import session_scope 

16from couchers.helpers.clusters import CHILD_NODE_TYPE, create_cluster, create_node 

17from couchers.jobs.enqueue import queue_job 

18from couchers.materialized_views import LiteUser 

19from couchers.models import EventCommunityInviteRequest, Node, User, Volunteer 

20from couchers.models.notifications import NotificationTopicAction 

21from couchers.models.postal_verification import PostalVerificationAttempt 

22from couchers.notifications.notify import notify 

23from couchers.postal.my_postcard import download_pdf 

24from couchers.proto import communities_pb2, editor_pb2, editor_pb2_grpc, notification_data_pb2, postal_verification_pb2 

25from couchers.proto.internal import jobs_pb2 

26from couchers.resources import get_static_badge_dict 

27from couchers.servicers.communities import community_to_pb 

28from couchers.servicers.events import generate_event_create_notifications, get_users_to_notify_for_new_event 

29from couchers.servicers.postal_verification import postalverificationstatus2pb 

30from couchers.servicers.public import format_volunteer_link 

31from couchers.utils import ( 

32 Timestamp_from_datetime, 

33 date_to_api, 

34 dt_id_from_page_token, 

35 dt_id_to_page_token, 

36 not_none, 

37 now, 

38 parse_date, 

39) 

40 

41logger = logging.getLogger(__name__) 

42 

43MAX_PAGINATION_LENGTH = 250 

44 

45 

46def load_community_geom(geojson: str, context: CouchersContext) -> BaseGeometry: 

47 geom = shape(json.loads(geojson)) 

48 

49 if geom.geom_type != "MultiPolygon": 

50 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:no_multipolygon") 

51 

52 return geom 

53 

54 

55def volunteer_to_pb(session: Session, volunteer: Volunteer) -> editor_pb2.Volunteer: 

56 """Convert a Volunteer model to the editor protobuf message.""" 

57 lite_user = session.execute(select(LiteUser).where(LiteUser.id == volunteer.user_id)).scalar_one() 

58 board_members = set(get_static_badge_dict()["board_member"]) 

59 

60 return editor_pb2.Volunteer( 

61 user_id=volunteer.user_id, 

62 name=volunteer.display_name or lite_user.name, 

63 username=lite_user.username, 

64 is_board_member=lite_user.id in board_members, 

65 role=volunteer.role, 

66 location=volunteer.display_location or lite_user.city, 

67 img=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail") if lite_user.avatar_filename else None, 

68 sort_key=volunteer.sort_key, 

69 started_volunteering=date_to_api(volunteer.started_volunteering), 

70 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None, 

71 show_on_team_page=volunteer.show_on_team_page, 

72 **format_volunteer_link(volunteer, lite_user.username), 

73 ) 

74 

75 

76def generate_new_blog_post_notifications(payload: jobs_pb2.GenerateNewBlogPostNotificationsPayload) -> None: 

77 with session_scope() as session: 

78 all_users_ids = session.execute(select(User.id).where(User.is_visible)).scalars().all() 

79 for user_id in all_users_ids: 

80 notify( 

81 session, 

82 user_id=user_id, 

83 topic_action=NotificationTopicAction.general__new_blog_post, 

84 key=payload.url, 

85 data=notification_data_pb2.GeneralNewBlogPost( 

86 url=payload.url, 

87 title=payload.title, 

88 blurb=payload.blurb, 

89 ), 

90 ) 

91 

92 

93class Editor(editor_pb2_grpc.EditorServicer): 

94 def CreateCommunity( 

95 self, request: editor_pb2.CreateCommunityReq, context: CouchersContext, session: Session 

96 ) -> communities_pb2.Community: 

97 geom = load_community_geom(request.geojson, context) 

98 

99 parent_node_id = request.parent_node_id if request.parent_node_id != 0 else None 

100 if parent_node_id is not None: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 parent_node = session.execute(select(Node).where(Node.id == parent_node_id)).scalar_one_or_none() 

102 if not parent_node: 

103 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:parent_node_not_found") 

104 parent_type = parent_node.node_type 

105 else: 

106 parent_type = None 

107 node_type = CHILD_NODE_TYPE[parent_type] 

108 node = create_node(session, geom, parent_node_id, node_type) 

109 create_cluster(session, node.id, request.name, request.description, context.user_id, request.admin_ids, True) 

110 

111 return community_to_pb(session, node, context) 

112 

113 def UpdateCommunity( 

114 self, request: editor_pb2.UpdateCommunityReq, context: CouchersContext, session: Session 

115 ) -> communities_pb2.Community: 

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

117 if not node: 

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

119 cluster = node.official_cluster 

120 

121 if request.name: 121 ↛ 124line 121 didn't jump to line 124 because the condition on line 121 was always true

122 cluster.name = request.name 

123 

124 if request.description: 124 ↛ 127line 124 didn't jump to line 127 because the condition on line 124 was always true

125 cluster.description = request.description 

126 

127 if request.geojson: 127 ↛ 132line 127 didn't jump to line 132 because the condition on line 127 was always true

128 geom = load_community_geom(request.geojson, context) 

129 

130 node.geom = from_shape(geom) 

131 

132 if request.parent_node_id != 0: 

133 node.parent_node_id = request.parent_node_id 

134 

135 session.flush() 

136 

137 return community_to_pb(session, cluster.parent_node, context) 

138 

139 def ListEventCommunityInviteRequests( 

140 self, request: editor_pb2.ListEventCommunityInviteRequestsReq, context: CouchersContext, session: Session 

141 ) -> editor_pb2.ListEventCommunityInviteRequestsRes: 

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

143 next_request_id = int(request.page_token) if request.page_token else 0 

144 requests = ( 

145 session.execute( 

146 select(EventCommunityInviteRequest) 

147 .where(EventCommunityInviteRequest.approved.is_(None)) 

148 .where(EventCommunityInviteRequest.id >= next_request_id) 

149 .order_by(EventCommunityInviteRequest.id) 

150 .limit(page_size + 1) 

151 ) 

152 .scalars() 

153 .all() 

154 ) 

155 

156 def _request_to_pb(request: EventCommunityInviteRequest) -> editor_pb2.EventCommunityInviteRequest: 

157 users_to_notify, node_id = get_users_to_notify_for_new_event(session, request.occurrence) 

158 return editor_pb2.EventCommunityInviteRequest( 

159 event_community_invite_request_id=request.id, 

160 user_id=request.user_id, 

161 event_url=urls.event_link(occurrence_id=request.occurrence.id, slug=request.occurrence.event.slug), 

162 approx_users_to_notify=len(users_to_notify), 

163 community_id=node_id, 

164 ) 

165 

166 return editor_pb2.ListEventCommunityInviteRequestsRes( 

167 requests=[_request_to_pb(request) for request in requests[:page_size]], 

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

169 ) 

170 

171 def ListDecidedEventCommunityInviteRequests( 

172 self, request: editor_pb2.ListDecidedEventCommunityInviteRequestsReq, context: CouchersContext, session: Session 

173 ) -> editor_pb2.ListDecidedEventCommunityInviteRequestsRes: 

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

175 

176 query = ( 

177 select(EventCommunityInviteRequest) 

178 .where(EventCommunityInviteRequest.decided.is_not(None)) 

179 .order_by(EventCommunityInviteRequest.decided.desc(), EventCommunityInviteRequest.id.desc()) 

180 .limit(page_size + 1) 

181 ) 

182 

183 if request.HasField("approved"): 

184 query = query.where(EventCommunityInviteRequest.approved == request.approved.value) 

185 

186 if request.page_token: 

187 decided, request_id = dt_id_from_page_token(request.page_token) 

188 query = query.where( 

189 tuple_(EventCommunityInviteRequest.decided, EventCommunityInviteRequest.id) <= (decided, request_id) 

190 ) 

191 

192 requests = session.execute(query).scalars().all() 

193 

194 return editor_pb2.ListDecidedEventCommunityInviteRequestsRes( 

195 requests=[ 

196 editor_pb2.DecidedEventCommunityInviteRequest( 

197 event_community_invite_request_id=req.id, 

198 user_id=req.user_id, 

199 event_url=urls.event_link(occurrence_id=req.occurrence.id, slug=req.occurrence.event.slug), 

200 community_id=req.occurrence.event.parent_node_id, 

201 created=Timestamp_from_datetime(req.created), 

202 decided=Timestamp_from_datetime(not_none(req.decided)), 

203 decided_by_user_id=not_none(req.decided_by_user_id), 

204 approved=not_none(req.approved), 

205 ) 

206 for req in requests[:page_size] 

207 ], 

208 next_page_token=dt_id_to_page_token(not_none(requests[page_size].decided), requests[page_size].id) 

209 if len(requests) > page_size 

210 else None, 

211 ) 

212 

213 def DecideEventCommunityInviteRequest( 

214 self, request: editor_pb2.DecideEventCommunityInviteRequestReq, context: CouchersContext, session: Session 

215 ) -> editor_pb2.DecideEventCommunityInviteRequestRes: 

216 req = session.execute( 

217 select(EventCommunityInviteRequest).where( 

218 EventCommunityInviteRequest.id == request.event_community_invite_request_id 

219 ) 

220 ).scalar_one_or_none() 

221 

222 if not req: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true

223 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:event_community_invite_not_found") 

224 

225 if req.decided: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true

226 context.abort_with_error_code( 

227 grpc.StatusCode.FAILED_PRECONDITION, "admin:event_community_invite_already_decided" 

228 ) 

229 

230 decided = now() 

231 req.decided = decided 

232 req.decided_by_user_id = context.user_id 

233 req.approved = request.approve 

234 

235 # deny other reqs for the same event 

236 if request.approve: 

237 session.execute( 

238 update(EventCommunityInviteRequest) 

239 .where(EventCommunityInviteRequest.occurrence_id == req.occurrence_id) 

240 .where(EventCommunityInviteRequest.decided.is_(None)) 

241 .values(decided=decided, decided_by_user_id=context.user_id, approved=False) 

242 ) 

243 

244 session.flush() 

245 

246 if request.approve: 

247 queue_job( 

248 session, 

249 job=generate_event_create_notifications, 

250 payload=jobs_pb2.GenerateEventCreateNotificationsPayload( 

251 inviting_user_id=req.user_id, 

252 occurrence_id=req.occurrence_id, 

253 approved=True, 

254 ), 

255 ) 

256 

257 return editor_pb2.DecideEventCommunityInviteRequestRes() 

258 

259 def SendBlogPostNotification( 

260 self, request: editor_pb2.SendBlogPostNotificationReq, context: CouchersContext, session: Session 

261 ) -> empty_pb2.Empty: 

262 if len(request.title) > 50: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:blog_title_too_long") 

264 if len(request.blurb) > 100: 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.FAILED_PRECONDITION, "admin:blog_blurb_too_long") 

266 queue_job( 

267 session, 

268 job=generate_new_blog_post_notifications, 

269 payload=jobs_pb2.GenerateNewBlogPostNotificationsPayload( 

270 url=request.url, 

271 title=request.title, 

272 blurb=request.blurb, 

273 ), 

274 ) 

275 return empty_pb2.Empty() 

276 

277 def MakeUserVolunteer( 

278 self, request: editor_pb2.MakeUserVolunteerReq, context: CouchersContext, session: Session 

279 ) -> editor_pb2.Volunteer: 

280 # Check if user exists 

281 if not session.execute(select(exists().where(User.id == request.user_id))).scalar(): 

282 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

283 

284 # Check if user is already a volunteer 

285 if session.execute(select(exists().where(Volunteer.user_id == request.user_id))).scalar(): 

286 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:user_already_volunteer") 

287 

288 # Parse started_volunteering date 

289 started_volunteering = None 

290 if request.started_volunteering: 

291 started_volunteering = parse_date(request.started_volunteering) 

292 if not started_volunteering: 

293 context.abort_with_error_code( 

294 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_started_volunteering_date" 

295 ) 

296 

297 # Create a volunteer record 

298 volunteer = Volunteer( 

299 user_id=request.user_id, 

300 role=request.role, 

301 show_on_team_page=not request.hide_on_team_page, 

302 ) 

303 if started_volunteering: 

304 volunteer.started_volunteering = started_volunteering 

305 session.add(volunteer) 

306 session.flush() 

307 

308 return volunteer_to_pb(session, volunteer) 

309 

310 def UpdateVolunteer( 

311 self, request: editor_pb2.UpdateVolunteerReq, context: CouchersContext, session: Session 

312 ) -> editor_pb2.Volunteer: 

313 # Check if volunteer exists 

314 volunteer = session.execute(select(Volunteer).where(Volunteer.user_id == request.user_id)).scalar_one_or_none() 

315 if not volunteer: 

316 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:volunteer_not_found") 

317 

318 # Update role if provided 

319 if request.HasField("role"): 

320 volunteer.role = request.role.value 

321 

322 # Update sort_key if provided 

323 if request.HasField("sort_key"): 

324 volunteer.sort_key = request.sort_key.value 

325 

326 # Update started_volunteering if provided 

327 if request.HasField("started_volunteering"): 

328 started_volunteering = parse_date(request.started_volunteering.value) 

329 if not started_volunteering: 

330 context.abort_with_error_code( 

331 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_started_volunteering_date" 

332 ) 

333 volunteer.started_volunteering = started_volunteering 

334 

335 # Reinstate (clear stopped_volunteering) or update stopped_volunteering 

336 if request.reinstate_volunteer and request.HasField("stopped_volunteering"): 

337 context.abort_with_error_code( 

338 grpc.StatusCode.INVALID_ARGUMENT, "admin:cannot_reinstate_and_set_stopped_date" 

339 ) 

340 if request.reinstate_volunteer: 

341 volunteer.stopped_volunteering = None 

342 elif request.HasField("stopped_volunteering"): 

343 stopped_volunteering = parse_date(request.stopped_volunteering.value) 

344 if not stopped_volunteering: 

345 context.abort_with_error_code( 

346 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_stopped_volunteering_date" 

347 ) 

348 volunteer.stopped_volunteering = stopped_volunteering 

349 

350 # Update show_on_team_page if provided 

351 if request.HasField("show_on_team_page"): 

352 volunteer.show_on_team_page = request.show_on_team_page.value 

353 

354 session.flush() 

355 

356 return volunteer_to_pb(session, volunteer) 

357 

358 def ListVolunteers( 

359 self, request: editor_pb2.ListVolunteersReq, context: CouchersContext, session: Session 

360 ) -> editor_pb2.ListVolunteersRes: 

361 # Query volunteers 

362 query = select(Volunteer).join(LiteUser, LiteUser.id == Volunteer.user_id).where(LiteUser.is_visible) 

363 

364 # Filter based on include_past flag 

365 if not request.include_past: 

366 query = query.where(Volunteer.stopped_volunteering.is_(None)) 

367 

368 # Order by same criteria as public API 

369 query = query.order_by( 

370 Volunteer.sort_key.asc().nulls_last(), 

371 Volunteer.stopped_volunteering.desc().nulls_first(), 

372 Volunteer.started_volunteering.asc(), 

373 ) 

374 

375 volunteers = session.execute(query).scalars().all() 

376 

377 return editor_pb2.ListVolunteersRes( 

378 volunteers=[volunteer_to_pb(session, volunteer) for volunteer in volunteers] 

379 ) 

380 

381 def ListPostcards( 

382 self, request: editor_pb2.ListPostcardsReq, context: CouchersContext, session: Session 

383 ) -> editor_pb2.ListPostcardsRes: 

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

385 next_id = int(request.page_token) if request.page_token else None 

386 

387 query = ( 

388 select(PostalVerificationAttempt, User) 

389 .join(User, User.id == PostalVerificationAttempt.user_id) 

390 .order_by(PostalVerificationAttempt.id.desc()) 

391 .limit(page_size + 1) 

392 ) 

393 if next_id is not None: 

394 query = query.where(PostalVerificationAttempt.id <= next_id) 

395 

396 results = session.execute(query).all() 

397 

398 def _attempt_to_pb(attempt: PostalVerificationAttempt, user: User) -> editor_pb2.PostcardInfo: 

399 return editor_pb2.PostcardInfo( 

400 postal_verification_attempt_id=attempt.id, 

401 user_id=attempt.user_id, 

402 username=user.username, 

403 name=user.name, 

404 status=postalverificationstatus2pb.get( 

405 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN 

406 ), 

407 address=postal_verification_pb2.PostalAddress( 

408 address_line_1=attempt.address_line_1, 

409 address_line_2=attempt.address_line_2, 

410 city=attempt.city, 

411 state=attempt.state, 

412 postal_code=attempt.postal_code, 

413 country_code=attempt.country_code, 

414 ), 

415 created=Timestamp_from_datetime(attempt.created), 

416 postcard_sent_at=Timestamp_from_datetime(attempt.postcard_sent_at) 

417 if attempt.postcard_sent_at 

418 else None, 

419 verified_at=Timestamp_from_datetime(attempt.verified_at) if attempt.verified_at else None, 

420 ) 

421 

422 return editor_pb2.ListPostcardsRes( 

423 postcards=[_attempt_to_pb(attempt, user) for attempt, user in results[:page_size]], 

424 next_page_token=str(results[-1][0].id) if len(results) > page_size else None, 

425 ) 

426 

427 def DownloadPostcardPdf( 

428 self, request: editor_pb2.DownloadPostcardPdfReq, context: CouchersContext, session: Session 

429 ) -> editor_pb2.DownloadPostcardPdfRes: 

430 attempt = session.execute( 

431 select(PostalVerificationAttempt).where( 

432 PostalVerificationAttempt.id == request.postal_verification_attempt_id 

433 ) 

434 ).scalar_one_or_none() 

435 

436 if not attempt: 

437 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found") 

438 

439 if not attempt.mypostcard_job_id: 

440 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:postcard_not_sent") 

441 

442 pdf_data = download_pdf(attempt.mypostcard_job_id) 

443 return editor_pb2.DownloadPostcardPdfRes(pdf=pdf_data)