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
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
1import json
2import logging
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
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)
41logger = logging.getLogger(__name__)
43MAX_PAGINATION_LENGTH = 250
46def load_community_geom(geojson: str, context: CouchersContext) -> BaseGeometry:
47 geom = shape(json.loads(geojson))
49 if geom.geom_type != "MultiPolygon":
50 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:no_multipolygon")
52 return geom
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"])
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 )
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 )
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)
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)
111 return community_to_pb(session, node, context)
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
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
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
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)
130 node.geom = from_shape(geom)
132 if request.parent_node_id != 0:
133 node.parent_node_id = request.parent_node_id
135 session.flush()
137 return community_to_pb(session, cluster.parent_node, context)
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 )
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 )
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 )
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)
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 )
183 if request.HasField("approved"):
184 query = query.where(EventCommunityInviteRequest.approved == request.approved.value)
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 )
192 requests = session.execute(query).scalars().all()
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 )
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()
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")
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 )
230 decided = now()
231 req.decided = decided
232 req.decided_by_user_id = context.user_id
233 req.approved = request.approve
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 )
244 session.flush()
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 )
257 return editor_pb2.DecideEventCommunityInviteRequestRes()
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()
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")
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")
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 )
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()
308 return volunteer_to_pb(session, volunteer)
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")
318 # Update role if provided
319 if request.HasField("role"):
320 volunteer.role = request.role.value
322 # Update sort_key if provided
323 if request.HasField("sort_key"):
324 volunteer.sort_key = request.sort_key.value
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
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
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
354 session.flush()
356 return volunteer_to_pb(session, volunteer)
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)
364 # Filter based on include_past flag
365 if not request.include_past:
366 query = query.where(Volunteer.stopped_volunteering.is_(None))
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 )
375 volunteers = session.execute(query).scalars().all()
377 return editor_pb2.ListVolunteersRes(
378 volunteers=[volunteer_to_pb(session, volunteer) for volunteer in volunteers]
379 )
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
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)
396 results = session.execute(query).all()
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 )
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 )
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()
436 if not attempt:
437 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found")
439 if not attempt.mypostcard_job_id:
440 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:postcard_not_sent")
442 pdf_data = download_pdf(attempt.mypostcard_job_id)
443 return editor_pb2.DownloadPostcardPdfRes(pdf=pdf_data)