Coverage for app/backend/src/couchers/servicers/editor.py: 87%
216 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 19:52 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 19:52 +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 get_parent_node_at_location, 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, EventOccurrence, Node, User, UserActivity, 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 (
29 generate_event_create_notifications,
30 get_users_to_notify_for_new_event,
31 is_community_too_large_for_event_notifications,
32)
33from couchers.servicers.postal_verification import postalverificationstatus2pb
34from couchers.servicers.public import format_volunteer_link
35from couchers.utils import (
36 Timestamp_from_datetime,
37 date_to_api,
38 dt_id_from_page_token,
39 dt_id_to_page_token,
40 not_none,
41 now,
42 parse_date,
43)
45logger = logging.getLogger(__name__)
47MAX_PAGINATION_LENGTH = 250
50def load_community_geom(geojson: str, context: CouchersContext) -> BaseGeometry:
51 geom = shape(json.loads(geojson))
53 if geom.geom_type != "MultiPolygon":
54 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:no_multipolygon")
56 return geom
59def _get_event_occurrence(session: Session, occurrence_id: int, context: CouchersContext) -> EventOccurrence:
60 # unfiltered by moderation state and deletion: editors need to fix up any event
61 occurrence = session.execute(
62 select(EventOccurrence).where(EventOccurrence.id == occurrence_id)
63 ).scalar_one_or_none()
64 if not occurrence:
65 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
66 return occurrence
69def _event_community_candidate_to_pb(
70 session: Session, occurrence: EventOccurrence, node: Node, context: CouchersContext
71) -> editor_pb2.EventCommunityCandidate:
72 users_to_notify, node_id = get_users_to_notify_for_new_event(session, occurrence, node)
73 return editor_pb2.EventCommunityCandidate(
74 community=community_to_pb(session, node, context),
75 approx_users_to_notify=len(users_to_notify),
76 too_large_for_notifications=is_community_too_large_for_event_notifications(node),
77 notifies_by_proximity=node_id is None,
78 )
81def event_community_info_to_pb(
82 session: Session, occurrence: EventOccurrence, context: CouchersContext
83) -> editor_pb2.EventCommunityInfo:
84 event = occurrence.event
85 smallest_local_node = get_parent_node_at_location(session, occurrence.geom)
87 return editor_pb2.EventCommunityInfo(
88 event_id=occurrence.id,
89 title=event.title,
90 event_url=urls.event_link(occurrence_id=occurrence.id, slug=event.slug),
91 creator_user_id=occurrence.creator_user_id,
92 address=occurrence.address,
93 occurrence_count=event.occurrences.count(),
94 current=_event_community_candidate_to_pb(session, occurrence, event.parent_node, context),
95 smallest_local=(
96 _event_community_candidate_to_pb(session, occurrence, smallest_local_node, context)
97 if smallest_local_node
98 else None
99 ),
100 is_in_smallest_local_community=smallest_local_node is not None
101 and smallest_local_node.id == event.parent_node_id,
102 )
105def volunteer_to_pb(session: Session, volunteer: Volunteer) -> editor_pb2.Volunteer:
106 """Convert a Volunteer model to the editor protobuf message."""
107 lite_user = session.execute(select(LiteUser).where(LiteUser.id == volunteer.user_id)).scalar_one()
108 board_members = set(get_static_badge_dict()["board_member"])
110 return editor_pb2.Volunteer(
111 user_id=volunteer.user_id,
112 name=volunteer.display_name or lite_user.name,
113 username=lite_user.username,
114 is_board_member=lite_user.id in board_members,
115 role=volunteer.role,
116 location=volunteer.display_location or lite_user.city,
117 img=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail") if lite_user.avatar_filename else None,
118 sort_key=volunteer.sort_key,
119 started_volunteering=date_to_api(volunteer.started_volunteering),
120 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None,
121 show_on_team_page=volunteer.show_on_team_page,
122 **format_volunteer_link(volunteer, lite_user.username),
123 )
126def generate_new_blog_post_notifications(payload: jobs_pb2.GenerateNewBlogPostNotificationsPayload) -> None:
127 with session_scope() as session:
128 all_users_ids = session.execute(select(User.id).where(User.is_visible)).scalars().all()
129 for user_id in all_users_ids:
130 notify(
131 session,
132 user_id=user_id,
133 topic_action=NotificationTopicAction.general__new_blog_post,
134 key=payload.url,
135 data=notification_data_pb2.GeneralNewBlogPost(
136 url=payload.url,
137 title=payload.title,
138 blurb=payload.blurb,
139 ),
140 )
143class Editor(editor_pb2_grpc.EditorServicer):
144 def CreateCommunity(
145 self, request: editor_pb2.CreateCommunityReq, context: CouchersContext, session: Session
146 ) -> communities_pb2.Community:
147 geom = load_community_geom(request.geojson, context)
149 parent_node_id = request.parent_node_id if request.parent_node_id != 0 else None
150 if parent_node_id is not None: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 parent_node = session.execute(select(Node).where(Node.id == parent_node_id)).scalar_one_or_none()
152 if not parent_node:
153 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:parent_node_not_found")
154 parent_type = parent_node.node_type
155 else:
156 parent_type = None
157 node_type = CHILD_NODE_TYPE[parent_type]
158 node = create_node(session, geom, parent_node_id, node_type)
159 create_cluster(session, node.id, request.name, request.description, context.user_id, request.admin_ids, True)
161 return community_to_pb(session, node, context)
163 def UpdateCommunity(
164 self, request: editor_pb2.UpdateCommunityReq, context: CouchersContext, session: Session
165 ) -> communities_pb2.Community:
166 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
167 if not node:
168 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
169 cluster = node.official_cluster
171 if request.name: 171 ↛ 174line 171 didn't jump to line 174 because the condition on line 171 was always true
172 cluster.name = request.name
174 if request.description: 174 ↛ 177line 174 didn't jump to line 177 because the condition on line 174 was always true
175 cluster.description = request.description
177 if request.geojson: 177 ↛ 182line 177 didn't jump to line 182 because the condition on line 177 was always true
178 geom = load_community_geom(request.geojson, context)
180 node.geom = from_shape(geom)
182 if request.parent_node_id != 0:
183 node.parent_node_id = request.parent_node_id
185 session.flush()
187 return community_to_pb(session, cluster.parent_node, context)
189 def ListEventCommunityInviteRequests(
190 self, request: editor_pb2.ListEventCommunityInviteRequestsReq, context: CouchersContext, session: Session
191 ) -> editor_pb2.ListEventCommunityInviteRequestsRes:
192 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
193 next_request_id = int(request.page_token) if request.page_token else 0
194 requests = (
195 session.execute(
196 select(EventCommunityInviteRequest)
197 .where(EventCommunityInviteRequest.approved.is_(None))
198 .where(EventCommunityInviteRequest.id >= next_request_id)
199 .order_by(EventCommunityInviteRequest.id)
200 .limit(page_size + 1)
201 )
202 .scalars()
203 .all()
204 )
206 def _request_to_pb(request: EventCommunityInviteRequest) -> editor_pb2.EventCommunityInviteRequest:
207 users_to_notify, node_id = get_users_to_notify_for_new_event(session, request.occurrence)
208 return editor_pb2.EventCommunityInviteRequest(
209 event_community_invite_request_id=request.id,
210 user_id=request.user_id,
211 event_url=urls.event_link(occurrence_id=request.occurrence.id, slug=request.occurrence.event.slug),
212 approx_users_to_notify=len(users_to_notify),
213 community_id=node_id,
214 )
216 return editor_pb2.ListEventCommunityInviteRequestsRes(
217 requests=[_request_to_pb(request) for request in requests[:page_size]],
218 next_page_token=str(requests[-1].id) if len(requests) > page_size else None,
219 )
221 def ListDecidedEventCommunityInviteRequests(
222 self, request: editor_pb2.ListDecidedEventCommunityInviteRequestsReq, context: CouchersContext, session: Session
223 ) -> editor_pb2.ListDecidedEventCommunityInviteRequestsRes:
224 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
226 query = (
227 select(EventCommunityInviteRequest)
228 .where(EventCommunityInviteRequest.decided.is_not(None))
229 .order_by(EventCommunityInviteRequest.decided.desc(), EventCommunityInviteRequest.id.desc())
230 .limit(page_size + 1)
231 )
233 if request.HasField("approved"):
234 query = query.where(EventCommunityInviteRequest.approved == request.approved.value)
236 if request.page_token:
237 decided, request_id = dt_id_from_page_token(request.page_token)
238 query = query.where(
239 tuple_(EventCommunityInviteRequest.decided, EventCommunityInviteRequest.id) <= (decided, request_id)
240 )
242 requests = session.execute(query).scalars().all()
244 return editor_pb2.ListDecidedEventCommunityInviteRequestsRes(
245 requests=[
246 editor_pb2.DecidedEventCommunityInviteRequest(
247 event_community_invite_request_id=req.id,
248 user_id=req.user_id,
249 event_url=urls.event_link(occurrence_id=req.occurrence.id, slug=req.occurrence.event.slug),
250 community_id=req.occurrence.event.parent_node_id,
251 created=Timestamp_from_datetime(req.created),
252 decided=Timestamp_from_datetime(not_none(req.decided)),
253 decided_by_user_id=not_none(req.decided_by_user_id),
254 approved=not_none(req.approved),
255 )
256 for req in requests[:page_size]
257 ],
258 next_page_token=dt_id_to_page_token(not_none(requests[page_size].decided), requests[page_size].id)
259 if len(requests) > page_size
260 else None,
261 )
263 def DecideEventCommunityInviteRequest(
264 self, request: editor_pb2.DecideEventCommunityInviteRequestReq, context: CouchersContext, session: Session
265 ) -> editor_pb2.DecideEventCommunityInviteRequestRes:
266 req = session.execute(
267 select(EventCommunityInviteRequest).where(
268 EventCommunityInviteRequest.id == request.event_community_invite_request_id
269 )
270 ).scalar_one_or_none()
272 if not req: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true
273 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:event_community_invite_not_found")
275 if req.decided: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 context.abort_with_error_code(
277 grpc.StatusCode.FAILED_PRECONDITION, "admin:event_community_invite_already_decided"
278 )
280 decided = now()
281 req.decided = decided
282 req.decided_by_user_id = context.user_id
283 req.approved = request.approve
285 # deny other reqs for the same event
286 if request.approve:
287 session.execute(
288 update(EventCommunityInviteRequest)
289 .where(EventCommunityInviteRequest.occurrence_id == req.occurrence_id)
290 .where(EventCommunityInviteRequest.decided.is_(None))
291 .values(decided=decided, decided_by_user_id=context.user_id, approved=False)
292 )
294 session.flush()
296 if request.approve:
297 queue_job(
298 session,
299 job=generate_event_create_notifications,
300 payload=jobs_pb2.GenerateEventCreateNotificationsPayload(
301 inviting_user_id=req.user_id,
302 occurrence_id=req.occurrence_id,
303 approved=True,
304 ),
305 )
307 return editor_pb2.DecideEventCommunityInviteRequestRes()
309 def GetEventCommunityInfo(
310 self, request: editor_pb2.GetEventCommunityInfoReq, context: CouchersContext, session: Session
311 ) -> editor_pb2.EventCommunityInfo:
312 occurrence = _get_event_occurrence(session, request.event_id, context)
313 return event_community_info_to_pb(session, occurrence, context)
315 def MoveEventToCommunity(
316 self, request: editor_pb2.MoveEventToCommunityReq, context: CouchersContext, session: Session
317 ) -> editor_pb2.EventCommunityInfo:
318 occurrence = _get_event_occurrence(session, request.event_id, context)
320 if request.WhichOneof("new_parent_community") == "community_id":
321 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
322 elif request.WhichOneof("new_parent_community") == "smallest_local_community":
323 node = get_parent_node_at_location(session, occurrence.geom)
324 else:
325 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:event_move_target_must_be_specified")
327 if not node:
328 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
330 # assign the relationship, not the id, or we read a stale parent_node below
331 occurrence.event.parent_node = node
332 session.flush()
334 return event_community_info_to_pb(session, occurrence, context)
336 def SendBlogPostNotification(
337 self, request: editor_pb2.SendBlogPostNotificationReq, context: CouchersContext, session: Session
338 ) -> empty_pb2.Empty:
339 if len(request.title) > 50: 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true
340 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:blog_title_too_long")
341 if len(request.blurb) > 100: 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.FAILED_PRECONDITION, "admin:blog_blurb_too_long")
343 queue_job(
344 session,
345 job=generate_new_blog_post_notifications,
346 payload=jobs_pb2.GenerateNewBlogPostNotificationsPayload(
347 url=request.url,
348 title=request.title,
349 blurb=request.blurb,
350 ),
351 )
352 return empty_pb2.Empty()
354 def MakeUserVolunteer(
355 self, request: editor_pb2.MakeUserVolunteerReq, context: CouchersContext, session: Session
356 ) -> editor_pb2.Volunteer:
357 # Check if user exists
358 if not session.execute(select(exists().where(User.id == request.user_id))).scalar():
359 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
361 # Check if user is already a volunteer
362 if session.execute(select(exists().where(Volunteer.user_id == request.user_id))).scalar():
363 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:user_already_volunteer")
365 # Parse started_volunteering date
366 started_volunteering = None
367 if request.started_volunteering:
368 started_volunteering = parse_date(request.started_volunteering)
369 if not started_volunteering:
370 context.abort_with_error_code(
371 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_started_volunteering_date"
372 )
374 # Create a volunteer record
375 volunteer = Volunteer(
376 user_id=request.user_id,
377 role=request.role,
378 show_on_team_page=not request.hide_on_team_page,
379 )
380 if started_volunteering:
381 volunteer.started_volunteering = started_volunteering
382 session.add(volunteer)
383 session.flush()
385 return volunteer_to_pb(session, volunteer)
387 def UpdateVolunteer(
388 self, request: editor_pb2.UpdateVolunteerReq, context: CouchersContext, session: Session
389 ) -> editor_pb2.Volunteer:
390 # Check if volunteer exists
391 volunteer = session.execute(select(Volunteer).where(Volunteer.user_id == request.user_id)).scalar_one_or_none()
392 if not volunteer:
393 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:volunteer_not_found")
395 # Update role if provided
396 if request.HasField("role"):
397 volunteer.role = request.role.value
399 # Update sort_key if provided
400 if request.HasField("sort_key"):
401 volunteer.sort_key = request.sort_key.value
403 # Update started_volunteering if provided
404 if request.HasField("started_volunteering"):
405 started_volunteering = parse_date(request.started_volunteering.value)
406 if not started_volunteering:
407 context.abort_with_error_code(
408 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_started_volunteering_date"
409 )
410 volunteer.started_volunteering = started_volunteering
412 # Reinstate (clear stopped_volunteering) or update stopped_volunteering
413 if request.reinstate_volunteer and request.HasField("stopped_volunteering"):
414 context.abort_with_error_code(
415 grpc.StatusCode.INVALID_ARGUMENT, "admin:cannot_reinstate_and_set_stopped_date"
416 )
417 if request.reinstate_volunteer:
418 volunteer.stopped_volunteering = None
419 elif request.HasField("stopped_volunteering"):
420 stopped_volunteering = parse_date(request.stopped_volunteering.value)
421 if not stopped_volunteering:
422 context.abort_with_error_code(
423 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_stopped_volunteering_date"
424 )
425 volunteer.stopped_volunteering = stopped_volunteering
427 # Update show_on_team_page if provided
428 if request.HasField("show_on_team_page"):
429 volunteer.show_on_team_page = request.show_on_team_page.value
431 session.flush()
433 return volunteer_to_pb(session, volunteer)
435 def ListVolunteers(
436 self, request: editor_pb2.ListVolunteersReq, context: CouchersContext, session: Session
437 ) -> editor_pb2.ListVolunteersRes:
438 # Query volunteers
439 query = select(Volunteer).join(LiteUser, LiteUser.id == Volunteer.user_id).where(LiteUser.is_visible)
441 # Filter based on include_past flag
442 if not request.include_past:
443 query = query.where(Volunteer.stopped_volunteering.is_(None))
445 # Order by same criteria as public API
446 query = query.order_by(
447 Volunteer.sort_key.asc().nulls_last(),
448 Volunteer.stopped_volunteering.desc().nulls_first(),
449 Volunteer.started_volunteering.asc(),
450 )
452 volunteers = session.execute(query).scalars().all()
454 return editor_pb2.ListVolunteersRes(
455 volunteers=[volunteer_to_pb(session, volunteer) for volunteer in volunteers]
456 )
458 def ListPostcards(
459 self, request: editor_pb2.ListPostcardsReq, context: CouchersContext, session: Session
460 ) -> editor_pb2.ListPostcardsRes:
461 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
462 next_id = int(request.page_token) if request.page_token else None
464 query = (
465 select(PostalVerificationAttempt, User)
466 .join(User, User.id == PostalVerificationAttempt.user_id)
467 .order_by(PostalVerificationAttempt.id.desc())
468 .limit(page_size + 1)
469 )
470 if next_id is not None:
471 query = query.where(PostalVerificationAttempt.id <= next_id)
473 results = session.execute(query).all()
475 def _attempt_to_pb(attempt: PostalVerificationAttempt, user: User) -> editor_pb2.PostcardInfo:
476 return editor_pb2.PostcardInfo(
477 postal_verification_attempt_id=attempt.id,
478 user_id=attempt.user_id,
479 username=user.username,
480 name=user.name,
481 status=postalverificationstatus2pb.get(
482 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN
483 ),
484 address=postal_verification_pb2.PostalAddress(
485 address_line_1=attempt.address_line_1,
486 address_line_2=attempt.address_line_2,
487 city=attempt.city,
488 state=attempt.state,
489 postal_code=attempt.postal_code,
490 country_code=attempt.country_code,
491 ),
492 created=Timestamp_from_datetime(attempt.created),
493 postcard_sent_at=Timestamp_from_datetime(attempt.postcard_sent_at)
494 if attempt.postcard_sent_at
495 else None,
496 verified_at=Timestamp_from_datetime(attempt.verified_at) if attempt.verified_at else None,
497 )
499 return editor_pb2.ListPostcardsRes(
500 postcards=[_attempt_to_pb(attempt, user) for attempt, user in results[:page_size]],
501 next_page_token=str(results[-1][0].id) if len(results) > page_size else None,
502 )
504 def DownloadPostcardPdf(
505 self, request: editor_pb2.DownloadPostcardPdfReq, context: CouchersContext, session: Session
506 ) -> editor_pb2.DownloadPostcardPdfRes:
507 attempt = session.execute(
508 select(PostalVerificationAttempt).where(
509 PostalVerificationAttempt.id == request.postal_verification_attempt_id
510 )
511 ).scalar_one_or_none()
513 if not attempt:
514 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found")
516 if not attempt.mypostcard_job_id:
517 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:postcard_not_sent")
519 pdf_data = download_pdf(attempt.mypostcard_job_id)
520 return editor_pb2.DownloadPostcardPdfRes(pdf=pdf_data)
522 def LookupUsersForDebug(
523 self, request: editor_pb2.LookupUsersForDebugReq, context: CouchersContext, session: Session
524 ) -> editor_pb2.LookupUsersForDebugRes:
525 sofa_prefix = request.sofa_prefix.strip()
526 if len(sofa_prefix) != 12:
527 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_sofa_prefix")
529 users = session.execute(
530 select(User.id, User.banned_at, User.deleted_at, User.shadowed_at)
531 .where(
532 User.id.in_(
533 select(UserActivity.user_id).where(UserActivity.sofa.startswith(sofa_prefix, autoescape=True))
534 )
535 )
536 .order_by(User.id)
537 ).all()
539 return editor_pb2.LookupUsersForDebugRes(
540 users=[
541 editor_pb2.LookupUser(
542 user_id=user_id,
543 banned=banned_at is not None,
544 deleted=deleted_at is not None,
545 shadowed=shadowed_at is not None,
546 )
547 for user_id, banned_at, deleted_at, shadowed_at in users
548 ]
549 )