Coverage for app/backend/src/couchers/servicers/events.py: 84%
529 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +0000
1import logging
2from datetime import datetime, timedelta
3from typing import Any, cast
5import grpc
6from google.protobuf import empty_pb2
7from psycopg.types.range import TimestamptzRange
8from sqlalchemy import Select, select
9from sqlalchemy.orm import Session
10from sqlalchemy.sql import and_, func, or_, update
12from couchers.context import CouchersContext, make_notification_user_context
13from couchers.db import can_moderate_node, get_parent_node_at_location, session_scope
14from couchers.event_log import log_event
15from couchers.helpers.completed_profile import has_completed_profile
16from couchers.jobs.enqueue import queue_job
17from couchers.models import (
18 AttendeeStatus,
19 Cluster,
20 ClusterSubscription,
21 Event,
22 EventCommunityInviteRequest,
23 EventOccurrence,
24 EventOccurrenceAttendee,
25 EventOrganizer,
26 EventSubscription,
27 ModerationObjectType,
28 Node,
29 NodeType,
30 Thread,
31 Upload,
32 User,
33)
34from couchers.models.notifications import NotificationTopicAction
35from couchers.moderation.utils import create_moderation
36from couchers.notifications.notify import notify
37from couchers.proto import events_pb2, events_pb2_grpc, notification_data_pb2
38from couchers.proto.internal import jobs_pb2
39from couchers.servicers.api import user_model_to_pb
40from couchers.servicers.blocking import is_not_visible
41from couchers.servicers.threads import thread_to_pb
42from couchers.sql import users_visible, where_moderated_content_visible, where_users_column_visible
43from couchers.tasks import send_event_community_invite_request_email
44from couchers.utils import (
45 Timestamp_from_datetime,
46 create_coordinate,
47 dt_from_millis,
48 millis_from_dt,
49 not_none,
50 now,
51 to_aware_datetime,
52)
54logger = logging.getLogger(__name__)
56attendancestate2sql = {
57 events_pb2.AttendanceState.ATTENDANCE_STATE_NOT_GOING: None,
58 events_pb2.AttendanceState.ATTENDANCE_STATE_GOING: AttendeeStatus.going,
59}
61attendancestate2api = {
62 None: events_pb2.AttendanceState.ATTENDANCE_STATE_NOT_GOING,
63 AttendeeStatus.going: events_pb2.AttendanceState.ATTENDANCE_STATE_GOING,
64}
66MAX_PAGINATION_LENGTH = 25
69def _is_event_owner(event: Event, user_id: int) -> bool:
70 """
71 Checks whether the user can act as an owner of the event
72 """
73 if event.owner_user:
74 return event.owner_user_id == user_id
75 # otherwise owned by a cluster
76 return not_none(event.owner_cluster).admins.where(User.id == user_id).one_or_none() is not None
79def _is_event_organizer(event: Event, user_id: int) -> bool:
80 """
81 Checks whether the user is as an organizer of the event
82 """
83 return event.organizers.where(EventOrganizer.user_id == user_id).one_or_none() is not None
86def _can_moderate_event(session: Session, event: Event, user_id: int) -> bool:
87 # if the event is owned by a cluster, then any moderator of that cluster can moderate this event
88 if event.owner_cluster is not None and can_moderate_node(session, user_id, event.owner_cluster.parent_node_id):
89 return True
91 # finally check if the user can moderate the parent node of the cluster
92 return can_moderate_node(session, user_id, event.parent_node_id)
95def _can_edit_event(session: Session, event: Event, user_id: int) -> bool:
96 return (
97 _is_event_owner(event, user_id)
98 or _is_event_organizer(event, user_id)
99 or _can_moderate_event(session, event, user_id)
100 )
103def event_to_pb(session: Session, occurrence: EventOccurrence, context: CouchersContext) -> events_pb2.Event:
104 event = occurrence.event
106 next_occurrence = (
107 event.occurrences.where(EventOccurrence.end_time >= now())
108 .order_by(EventOccurrence.end_time.asc())
109 .limit(1)
110 .one_or_none()
111 )
113 owner_community_id = None
114 owner_group_id = None
115 if event.owner_cluster:
116 if event.owner_cluster.is_official_cluster:
117 owner_community_id = event.owner_cluster.parent_node_id
118 else:
119 owner_group_id = event.owner_cluster.id
121 attendance = occurrence.attendances.where(EventOccurrenceAttendee.user_id == context.user_id).one_or_none()
122 attendance_state = attendance.attendee_status if attendance else None
124 can_moderate = _can_moderate_event(session, event, context.user_id)
125 can_edit = _can_edit_event(session, event, context.user_id)
127 going_count = session.execute(
128 where_users_column_visible(
129 select(func.count())
130 .select_from(EventOccurrenceAttendee)
131 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
132 .where(EventOccurrenceAttendee.attendee_status == AttendeeStatus.going),
133 context,
134 EventOccurrenceAttendee.user_id,
135 )
136 ).scalar_one()
137 organizer_count = session.execute(
138 where_users_column_visible(
139 select(func.count()).select_from(EventOrganizer).where(EventOrganizer.event_id == event.id),
140 context,
141 EventOrganizer.user_id,
142 )
143 ).scalar_one()
144 subscriber_count = session.execute(
145 where_users_column_visible(
146 select(func.count()).select_from(EventSubscription).where(EventSubscription.event_id == event.id),
147 context,
148 EventSubscription.user_id,
149 )
150 ).scalar_one()
152 location: events_pb2.EventLocation
153 if occurrence.geom:
154 location = events_pb2.EventLocation(
155 lat=not_none(occurrence.coordinates)[0], lng=not_none(occurrence.coordinates)[1], address=occurrence.address
156 )
157 else:
158 # Backcompat: Surface legacy online events as offline events.
159 # They'll appear at null island, but there are so few we're ok with this.
160 location = events_pb2.EventLocation(address=occurrence.link, lat=0, lng=0)
162 return events_pb2.Event(
163 event_id=occurrence.id,
164 is_next=False if not next_occurrence else occurrence.id == next_occurrence.id,
165 is_cancelled=occurrence.is_cancelled,
166 is_deleted=occurrence.is_deleted,
167 title=event.title,
168 slug=event.slug,
169 content=occurrence.content,
170 photo_url=occurrence.photo.full_url if occurrence.photo else None,
171 photo_key=occurrence.photo_key or "",
172 location=location,
173 created=Timestamp_from_datetime(occurrence.created),
174 last_edited=Timestamp_from_datetime(occurrence.last_edited),
175 creator_user_id=occurrence.creator_user_id,
176 start_time=Timestamp_from_datetime(occurrence.start_time),
177 end_time=Timestamp_from_datetime(occurrence.end_time),
178 timezone=occurrence.timezone,
179 attendance_state=attendancestate2api[attendance_state],
180 organizer=event.organizers.where(EventOrganizer.user_id == context.user_id).one_or_none() is not None,
181 subscriber=event.subscribers.where(EventSubscription.user_id == context.user_id).one_or_none() is not None,
182 going_count=going_count,
183 organizer_count=organizer_count,
184 subscriber_count=subscriber_count,
185 owner_user_id=event.owner_user_id,
186 owner_community_id=owner_community_id,
187 owner_group_id=owner_group_id,
188 thread=thread_to_pb(session, context, event.thread_id),
189 can_edit=can_edit,
190 can_moderate=can_moderate,
191 )
194def _get_event_and_occurrence_query(
195 occurrence_id: int,
196 include_deleted: bool,
197 context: CouchersContext | None = None,
198) -> Select[tuple[Event, EventOccurrence]]:
199 query = (
200 select(Event, EventOccurrence)
201 .where(EventOccurrence.id == occurrence_id)
202 .where(EventOccurrence.event_id == Event.id)
203 )
205 if not include_deleted: 205 ↛ 208line 205 didn't jump to line 208 because the condition on line 205 was always true
206 query = query.where(~EventOccurrence.is_deleted)
208 if context is not None:
209 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=False)
211 return query
214def _get_event_and_occurrence_one(
215 session: Session, occurrence_id: int, include_deleted: bool = False
216) -> tuple[Event, EventOccurrence]:
217 """For background jobs only - no visibility filtering."""
218 result = session.execute(_get_event_and_occurrence_query(occurrence_id, include_deleted)).one()
219 return result._tuple()
222def _get_event_and_occurrence_one_or_none(
223 session: Session, occurrence_id: int, context: CouchersContext, include_deleted: bool = False
224) -> tuple[Event, EventOccurrence] | None:
225 result = session.execute(
226 _get_event_and_occurrence_query(occurrence_id, include_deleted, context=context)
227 ).one_or_none()
228 return result._tuple() if result else None
231def _check_occurrence_time_validity(start_time: datetime, end_time: datetime, context: CouchersContext) -> None:
232 if start_time < now():
233 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_in_past")
234 if end_time < start_time:
235 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_ends_before_starts")
236 if end_time - start_time > timedelta(days=7):
237 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_too_long")
238 if start_time - now() > timedelta(days=365):
239 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_too_far_in_future")
242def get_users_to_notify_for_new_event(session: Session, occurrence: EventOccurrence) -> tuple[list[User], int | None]:
243 """
244 Returns the users to notify, as well as the community id that is being notified (None if based on geo search)
245 """
246 cluster = occurrence.event.parent_node.official_cluster
247 if occurrence.event.parent_node.node_type.value <= NodeType.region.value:
248 logger.info("Global, macroregion, and region communities are too big for email notifications.")
249 return [], occurrence.event.parent_node_id
250 elif occurrence.creator_user in cluster.admins or cluster.is_leaf: 250 ↛ 253line 250 didn't jump to line 253 because the condition on line 250 was always true
251 return list(cluster.members.where(User.is_visible)), occurrence.event.parent_node_id
252 else:
253 max_radius = 20000 # m
254 users = (
255 session.execute(
256 select(User)
257 .join(ClusterSubscription, ClusterSubscription.user_id == User.id)
258 .where(User.is_visible)
259 .where(ClusterSubscription.cluster_id == cluster.id)
260 .where(func.ST_DWithin(User.geom, occurrence.geom, max_radius / 111111))
261 )
262 .scalars()
263 .all()
264 )
265 return cast(tuple[list[User], int | None], (users, None))
268def generate_event_create_notifications(payload: jobs_pb2.GenerateEventCreateNotificationsPayload) -> None:
269 """
270 Background job to generated/fan out event notifications
271 """
272 # Import here to avoid circular dependency
273 from couchers.servicers.communities import community_to_pb # noqa: PLC0415
275 logger.info(f"Fanning out notifications for event occurrence id = {payload.occurrence_id}")
277 with session_scope() as session:
278 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
279 creator = occurrence.creator_user
281 users, node_id = get_users_to_notify_for_new_event(session, occurrence)
283 inviting_user = session.execute(select(User).where(User.id == payload.inviting_user_id)).scalar_one_or_none()
285 if not inviting_user: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true
286 logger.error(f"Inviting user {payload.inviting_user_id} is gone while trying to send event notification?")
287 return
289 for user in users:
290 if is_not_visible(session, user.id, creator.id): 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true
291 continue
292 context = make_notification_user_context(user_id=user.id)
293 topic_action = (
294 NotificationTopicAction.event__create_approved
295 if payload.approved
296 else NotificationTopicAction.event__create_any
297 )
298 notify(
299 session,
300 user_id=user.id,
301 topic_action=topic_action,
302 key=str(payload.occurrence_id),
303 data=notification_data_pb2.EventCreate(
304 event=event_to_pb(session, occurrence, context),
305 inviting_user=user_model_to_pb(inviting_user, session, context),
306 nearby=True if node_id is None else None,
307 in_community=community_to_pb(session, event.parent_node, context) if node_id is not None else None,
308 ),
309 moderation_state_id=occurrence.moderation_state_id,
310 )
313def generate_event_update_notifications(payload: jobs_pb2.GenerateEventUpdateNotificationsPayload) -> None:
314 with session_scope() as session:
315 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
317 updating_user = session.execute(select(User).where(User.id == payload.updating_user_id)).scalar_one()
319 subscribed_user_ids = [user.id for user in event.subscribers]
320 attending_user_ids = [user.user_id for user in occurrence.attendances]
322 for user_id in set(subscribed_user_ids + attending_user_ids):
323 if is_not_visible(session, user_id, updating_user.id): 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true
324 continue
325 context = make_notification_user_context(user_id=user_id)
326 notify(
327 session,
328 user_id=user_id,
329 topic_action=NotificationTopicAction.event__update,
330 key=str(payload.occurrence_id),
331 data=notification_data_pb2.EventUpdate(
332 event=event_to_pb(session, occurrence, context),
333 updating_user=user_model_to_pb(updating_user, session, context),
334 # TODO(#9117): Remove update_str_items once known unused.
335 updated_str_items=payload.updated_str_items,
336 updated_enum_items=(
337 notification_data_pb2.EventUpdateItem.ValueType(value) for value in payload.updated_enum_items
338 ),
339 ),
340 moderation_state_id=occurrence.moderation_state_id,
341 )
344def generate_event_cancel_notifications(payload: jobs_pb2.GenerateEventCancelNotificationsPayload) -> None:
345 with session_scope() as session:
346 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
348 cancelling_user = session.execute(select(User).where(User.id == payload.cancelling_user_id)).scalar_one()
350 subscribed_user_ids = [user.id for user in event.subscribers]
351 attending_user_ids = [user.user_id for user in occurrence.attendances]
353 for user_id in set(subscribed_user_ids + attending_user_ids):
354 if is_not_visible(session, user_id, cancelling_user.id): 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 continue
356 context = make_notification_user_context(user_id=user_id)
357 notify(
358 session,
359 user_id=user_id,
360 topic_action=NotificationTopicAction.event__cancel,
361 key=str(payload.occurrence_id),
362 data=notification_data_pb2.EventCancel(
363 event=event_to_pb(session, occurrence, context),
364 cancelling_user=user_model_to_pb(cancelling_user, session, context),
365 ),
366 moderation_state_id=occurrence.moderation_state_id,
367 )
370def generate_event_delete_notifications(payload: jobs_pb2.GenerateEventDeleteNotificationsPayload) -> None:
371 with session_scope() as session:
372 event, occurrence = _get_event_and_occurrence_one(
373 session, occurrence_id=payload.occurrence_id, include_deleted=True
374 )
376 subscribed_user_ids = [user.id for user in event.subscribers]
377 attending_user_ids = [user.user_id for user in occurrence.attendances]
379 for user_id in set(subscribed_user_ids + attending_user_ids):
380 context = make_notification_user_context(user_id=user_id)
381 notify(
382 session,
383 user_id=user_id,
384 topic_action=NotificationTopicAction.event__delete,
385 key=str(payload.occurrence_id),
386 data=notification_data_pb2.EventDelete(
387 event=event_to_pb(session, occurrence, context),
388 ),
389 moderation_state_id=occurrence.moderation_state_id,
390 )
393class Events(events_pb2_grpc.EventsServicer):
394 def CreateEvent(
395 self, request: events_pb2.CreateEventReq, context: CouchersContext, session: Session
396 ) -> events_pb2.Event:
397 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
398 if not has_completed_profile(session, user):
399 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_event")
400 if not request.title:
401 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_title")
402 if not request.content:
403 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content")
405 # As protobuf parses a missing value as 0.0, this is not a permitted event coordinate value
406 if not (
407 request.HasField("location") and request.location.address and request.location.lat and request.location.lng
408 ):
409 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location")
410 if request.location.lat == 0 and request.location.lng == 0: 410 ↛ 411line 410 didn't jump to line 411 because the condition on line 410 was never true
411 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
412 geom = create_coordinate(request.location.lat, request.location.lng)
413 address = request.location.address
415 start_time = to_aware_datetime(request.start_time)
416 end_time = to_aware_datetime(request.end_time)
418 _check_occurrence_time_validity(start_time, end_time, context)
420 if request.parent_community_id:
421 parent_node = session.execute(
422 select(Node).where(Node.id == request.parent_community_id)
423 ).scalar_one_or_none()
425 if not parent_node or not parent_node.official_cluster.small_community_features_enabled: 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true
426 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "events_not_enabled")
427 else:
428 # parent community computed from geom
429 parent_node = get_parent_node_at_location(session, not_none(geom))
431 if not parent_node: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "community_not_found")
434 if (
435 request.photo_key
436 and not session.execute(select(Upload).where(Upload.key == request.photo_key)).scalar_one_or_none()
437 ):
438 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found")
440 thread = Thread()
441 session.add(thread)
442 session.flush()
444 event = Event(
445 title=request.title,
446 parent_node_id=parent_node.id,
447 owner_user_id=context.user_id,
448 thread_id=thread.id,
449 creator_user_id=context.user_id,
450 )
451 session.add(event)
452 session.flush()
454 occurrence: EventOccurrence | None = None
456 def create_occurrence(moderation_state_id: int) -> int:
457 nonlocal occurrence
458 occurrence = EventOccurrence(
459 event_id=event.id,
460 content=request.content,
461 geom=geom,
462 address=address,
463 link=None,
464 photo_key=request.photo_key if request.photo_key != "" else None,
465 # timezone=timezone,
466 during=TimestamptzRange(start_time, end_time),
467 creator_user_id=context.user_id,
468 moderation_state_id=moderation_state_id,
469 )
470 session.add(occurrence)
471 session.flush()
472 return occurrence.id
474 create_moderation(
475 session=session,
476 object_type=ModerationObjectType.event_occurrence,
477 object_id=create_occurrence,
478 creator_user_id=context.user_id,
479 )
481 assert occurrence is not None
483 session.add(
484 EventOrganizer(
485 user_id=context.user_id,
486 event_id=event.id,
487 )
488 )
490 session.add(
491 EventSubscription(
492 user_id=context.user_id,
493 event_id=event.id,
494 )
495 )
497 session.add(
498 EventOccurrenceAttendee(
499 user_id=context.user_id,
500 occurrence_id=occurrence.id,
501 attendee_status=AttendeeStatus.going,
502 )
503 )
505 session.commit()
507 log_event(
508 context,
509 session,
510 "event.created",
511 {
512 "event_id": event.id,
513 "occurrence_id": occurrence.id,
514 "parent_community_id": parent_node.id,
515 "parent_community_name": parent_node.official_cluster.name,
516 },
517 )
519 if has_completed_profile(session, user): 519 ↛ 530line 519 didn't jump to line 530 because the condition on line 519 was always true
520 queue_job(
521 session,
522 job=generate_event_create_notifications,
523 payload=jobs_pb2.GenerateEventCreateNotificationsPayload(
524 inviting_user_id=user.id,
525 occurrence_id=occurrence.id,
526 approved=False,
527 ),
528 )
530 return event_to_pb(session, occurrence, context)
532 def ScheduleEvent(
533 self, request: events_pb2.ScheduleEventReq, context: CouchersContext, session: Session
534 ) -> events_pb2.Event:
535 if not request.content: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true
536 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content")
537 if not ( 537 ↛ 540line 537 didn't jump to line 540 because the condition on line 537 was never true
538 request.HasField("location") and request.location.address and request.location.lat and request.location.lng
539 ):
540 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location")
541 if request.location.lat == 0 and request.location.lng == 0: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true
542 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
543 geom = create_coordinate(request.location.lat, request.location.lng)
544 address = request.location.address
546 start_time = to_aware_datetime(request.start_time)
547 end_time = to_aware_datetime(request.end_time)
549 _check_occurrence_time_validity(start_time, end_time, context)
551 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
552 if not res: 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true
553 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
555 event, occurrence = res
557 if not _can_edit_event(session, event, context.user_id): 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true
558 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
560 if occurrence.is_cancelled: 560 ↛ 561line 560 didn't jump to line 561 because the condition on line 560 was never true
561 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
563 if ( 563 ↛ 567line 563 didn't jump to line 567 because the condition on line 563 was never true
564 request.photo_key
565 and not session.execute(select(Upload).where(Upload.key == request.photo_key)).scalar_one_or_none()
566 ):
567 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found")
569 during = TimestamptzRange(start_time, end_time)
571 # && is the overlap operator for ranges
572 if (
573 session.execute(
574 select(EventOccurrence.id)
575 .where(EventOccurrence.event_id == event.id)
576 .where(EventOccurrence.during.op("&&")(during))
577 .limit(1)
578 )
579 .scalars()
580 .one_or_none()
581 is not None
582 ):
583 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_overlap")
585 new_occurrence: EventOccurrence | None = None
587 def create_occurrence(moderation_state_id: int) -> int:
588 nonlocal new_occurrence
589 new_occurrence = EventOccurrence(
590 event_id=event.id,
591 content=request.content,
592 geom=geom,
593 address=address,
594 link=None,
595 photo_key=request.photo_key if request.photo_key != "" else None,
596 # timezone=timezone,
597 during=during,
598 creator_user_id=context.user_id,
599 moderation_state_id=moderation_state_id,
600 )
601 session.add(new_occurrence)
602 session.flush()
603 return new_occurrence.id
605 create_moderation(
606 session=session,
607 object_type=ModerationObjectType.event_occurrence,
608 object_id=create_occurrence,
609 creator_user_id=context.user_id,
610 )
612 assert new_occurrence is not None
614 session.add(
615 EventOccurrenceAttendee(
616 user_id=context.user_id,
617 occurrence_id=new_occurrence.id,
618 attendee_status=AttendeeStatus.going,
619 )
620 )
622 session.flush()
624 # TODO: notify
626 return event_to_pb(session, new_occurrence, context)
628 def UpdateEvent(
629 self, request: events_pb2.UpdateEventReq, context: CouchersContext, session: Session
630 ) -> events_pb2.Event:
631 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
632 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
633 if not res: 633 ↛ 634line 633 didn't jump to line 634 because the condition on line 633 was never true
634 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
636 event, occurrence = res
638 if not _can_edit_event(session, event, context.user_id): 638 ↛ 639line 638 didn't jump to line 639 because the condition on line 638 was never true
639 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
641 # the things that were updated and need to be notified about
642 notify_updated: list[notification_data_pb2.EventUpdateItem.ValueType] = []
644 if occurrence.is_cancelled:
645 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
647 occurrence_update: dict[str, Any] = {"last_edited": now()}
649 if request.HasField("title"):
650 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE)
651 event.title = request.title.value
653 if request.HasField("content"):
654 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT)
655 occurrence_update["content"] = request.content.value
657 if request.HasField("photo_key"): 657 ↛ 658line 657 didn't jump to line 658 because the condition on line 657 was never true
658 occurrence_update["photo_key"] = request.photo_key.value
660 if request.HasField("location"):
661 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION)
662 occurrence_update["link"] = None
663 if request.location.lat == 0 and request.location.lng == 0: 663 ↛ 664line 663 didn't jump to line 664 because the condition on line 663 was never true
664 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
665 occurrence_update["geom"] = create_coordinate(request.location.lat, request.location.lng)
666 occurrence_update["address"] = request.location.address
668 if request.HasField("start_time") or request.HasField("end_time"):
669 if request.update_all_future: 669 ↛ 670line 669 didn't jump to line 670 because the condition on line 669 was never true
670 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_cant_update_all_times")
671 if request.HasField("start_time"): 671 ↛ 675line 671 didn't jump to line 675 because the condition on line 671 was always true
672 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME)
673 start_time = to_aware_datetime(request.start_time)
674 else:
675 start_time = occurrence.start_time
676 if request.HasField("end_time"):
677 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME)
678 end_time = to_aware_datetime(request.end_time)
679 else:
680 end_time = occurrence.end_time
682 _check_occurrence_time_validity(start_time, end_time, context)
684 during = TimestamptzRange(start_time, end_time)
686 # && is the overlap operator for ranges
687 if (
688 session.execute(
689 select(EventOccurrence.id)
690 .where(EventOccurrence.event_id == event.id)
691 .where(EventOccurrence.id != occurrence.id)
692 .where(EventOccurrence.during.op("&&")(during))
693 .limit(1)
694 )
695 .scalars()
696 .one_or_none()
697 is not None
698 ):
699 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_overlap")
701 occurrence_update["during"] = during
703 # TODO
704 # if request.HasField("timezone"):
705 # occurrence_update["timezone"] = request.timezone
707 # allow editing any event which hasn't ended more than 24 hours before now
708 # when editing all future events, we edit all which have not yet ended
710 cutoff_time = now() - timedelta(hours=24)
711 if request.update_all_future:
712 session.execute(
713 update(EventOccurrence)
714 .where(EventOccurrence.end_time >= cutoff_time)
715 .where(EventOccurrence.start_time >= occurrence.start_time)
716 .values(occurrence_update)
717 .execution_options(synchronize_session=False)
718 )
719 else:
720 if occurrence.end_time < cutoff_time: 720 ↛ 721line 720 didn't jump to line 721 because the condition on line 720 was never true
721 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
722 session.execute(
723 update(EventOccurrence)
724 .where(EventOccurrence.end_time >= cutoff_time)
725 .where(EventOccurrence.id == occurrence.id)
726 .values(occurrence_update)
727 .execution_options(synchronize_session=False)
728 )
730 session.flush()
732 if notify_updated:
733 items_str = ",".join(notification_data_pb2.EventUpdateItem.Name(item) for item in notify_updated)
734 if request.should_notify:
735 logger.info(f"Items {items_str} updated in event {event.id=}, notifying")
737 queue_job(
738 session,
739 job=generate_event_update_notifications,
740 payload=jobs_pb2.GenerateEventUpdateNotificationsPayload(
741 updating_user_id=user.id,
742 occurrence_id=occurrence.id,
743 updated_enum_items=notify_updated,
744 ),
745 )
746 else:
747 logger.info(f"Items {items_str} updated in event {event.id=}, but skipping notifications")
749 # since we have synchronize_session=False, we have to refresh the object
750 session.refresh(occurrence)
752 return event_to_pb(session, occurrence, context)
754 def GetEvent(self, request: events_pb2.GetEventReq, context: CouchersContext, session: Session) -> events_pb2.Event:
755 query = select(EventOccurrence).where(EventOccurrence.id == request.event_id).where(~EventOccurrence.is_deleted)
756 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=False)
757 occurrence = session.execute(query).scalar_one_or_none()
759 if not occurrence:
760 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
762 return event_to_pb(session, occurrence, context)
764 def CancelEvent(
765 self, request: events_pb2.CancelEventReq, context: CouchersContext, session: Session
766 ) -> empty_pb2.Empty:
767 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
768 if not res: 768 ↛ 769line 768 didn't jump to line 769 because the condition on line 768 was never true
769 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
771 event, occurrence = res
773 if not _can_edit_event(session, event, context.user_id): 773 ↛ 774line 773 didn't jump to line 774 because the condition on line 773 was never true
774 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
776 if occurrence.end_time < now() - timedelta(hours=24): 776 ↛ 777line 776 didn't jump to line 777 because the condition on line 776 was never true
777 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_cancel_old_event")
779 occurrence.is_cancelled = True
781 log_event(context, session, "event.cancelled", {"event_id": event.id, "occurrence_id": occurrence.id})
783 queue_job(
784 session,
785 job=generate_event_cancel_notifications,
786 payload=jobs_pb2.GenerateEventCancelNotificationsPayload(
787 cancelling_user_id=context.user_id,
788 occurrence_id=occurrence.id,
789 ),
790 )
792 return empty_pb2.Empty()
794 def RequestCommunityInvite(
795 self, request: events_pb2.RequestCommunityInviteReq, context: CouchersContext, session: Session
796 ) -> empty_pb2.Empty:
797 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
798 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
799 if not res: 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true
800 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
802 event, occurrence = res
804 if not _can_edit_event(session, event, context.user_id):
805 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
807 if occurrence.is_cancelled: 807 ↛ 808line 807 didn't jump to line 808 because the condition on line 807 was never true
808 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
810 if occurrence.end_time < now() - timedelta(hours=24): 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true
811 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
813 this_user_reqs = [req for req in occurrence.community_invite_requests if req.user_id == context.user_id]
815 if len(this_user_reqs) > 0:
816 context.abort_with_error_code(
817 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_requested"
818 )
820 approved_reqs = [req for req in occurrence.community_invite_requests if req.approved]
822 if len(approved_reqs) > 0:
823 context.abort_with_error_code(
824 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_approved"
825 )
827 req = EventCommunityInviteRequest(
828 occurrence_id=request.event_id,
829 user_id=context.user_id,
830 )
831 session.add(req)
832 session.flush()
834 send_event_community_invite_request_email(session, req)
836 return empty_pb2.Empty()
838 def ListEventOccurrences(
839 self, request: events_pb2.ListEventOccurrencesReq, context: CouchersContext, session: Session
840 ) -> events_pb2.ListEventOccurrencesRes:
841 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
842 # the page token is a unix timestamp of where we left off
843 page_token = dt_from_millis(int(request.page_token)) if request.page_token else now()
844 initial_query = (
845 select(EventOccurrence).where(EventOccurrence.id == request.event_id).where(~EventOccurrence.is_deleted)
846 )
847 initial_query = where_moderated_content_visible(
848 initial_query, context, EventOccurrence, is_list_operation=False
849 )
850 occurrence = session.execute(initial_query).scalar_one_or_none()
851 if not occurrence: 851 ↛ 852line 851 didn't jump to line 852 because the condition on line 851 was never true
852 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
854 query = (
855 select(EventOccurrence)
856 .where(EventOccurrence.event_id == occurrence.event_id)
857 .where(~EventOccurrence.is_deleted)
858 )
859 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
861 if not request.include_cancelled:
862 query = query.where(~EventOccurrence.is_cancelled)
864 if not request.past: 864 ↛ 868line 864 didn't jump to line 868 because the condition on line 864 was always true
865 cutoff = page_token - timedelta(seconds=1)
866 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc())
867 else:
868 cutoff = page_token + timedelta(seconds=1)
869 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc())
871 query = query.limit(page_size + 1)
872 occurrences = session.execute(query).scalars().all()
874 return events_pb2.ListEventOccurrencesRes(
875 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
876 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None,
877 )
879 def ListEventAttendees(
880 self, request: events_pb2.ListEventAttendeesReq, context: CouchersContext, session: Session
881 ) -> events_pb2.ListEventAttendeesRes:
882 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
883 next_user_id = int(request.page_token) if request.page_token else 0
884 occurrence = session.execute(
885 where_moderated_content_visible(
886 select(EventOccurrence)
887 .where(EventOccurrence.id == request.event_id)
888 .where(~EventOccurrence.is_deleted),
889 context,
890 EventOccurrence,
891 is_list_operation=False,
892 )
893 ).scalar_one_or_none()
894 if not occurrence: 894 ↛ 895line 894 didn't jump to line 895 because the condition on line 894 was never true
895 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
896 attendees = (
897 session.execute(
898 where_users_column_visible(
899 select(EventOccurrenceAttendee)
900 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
901 .where(EventOccurrenceAttendee.user_id >= next_user_id)
902 .order_by(EventOccurrenceAttendee.user_id)
903 .limit(page_size + 1),
904 context,
905 EventOccurrenceAttendee.user_id,
906 )
907 )
908 .scalars()
909 .all()
910 )
911 return events_pb2.ListEventAttendeesRes(
912 attendee_user_ids=[attendee.user_id for attendee in attendees[:page_size]],
913 next_page_token=str(attendees[-1].user_id) if len(attendees) > page_size else None,
914 )
916 def ListEventSubscribers(
917 self, request: events_pb2.ListEventSubscribersReq, context: CouchersContext, session: Session
918 ) -> events_pb2.ListEventSubscribersRes:
919 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
920 next_user_id = int(request.page_token) if request.page_token else 0
921 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
922 if not res: 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true
923 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
924 event, occurrence = res
925 subscribers = (
926 session.execute(
927 where_users_column_visible(
928 select(EventSubscription)
929 .where(EventSubscription.event_id == event.id)
930 .where(EventSubscription.user_id >= next_user_id)
931 .order_by(EventSubscription.user_id)
932 .limit(page_size + 1),
933 context,
934 EventSubscription.user_id,
935 )
936 )
937 .scalars()
938 .all()
939 )
940 return events_pb2.ListEventSubscribersRes(
941 subscriber_user_ids=[subscriber.user_id for subscriber in subscribers[:page_size]],
942 next_page_token=str(subscribers[-1].user_id) if len(subscribers) > page_size else None,
943 )
945 def ListEventOrganizers(
946 self, request: events_pb2.ListEventOrganizersReq, context: CouchersContext, session: Session
947 ) -> events_pb2.ListEventOrganizersRes:
948 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
949 next_user_id = int(request.page_token) if request.page_token else 0
950 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
951 if not res: 951 ↛ 952line 951 didn't jump to line 952 because the condition on line 951 was never true
952 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
953 event, occurrence = res
954 organizers = (
955 session.execute(
956 where_users_column_visible(
957 select(EventOrganizer)
958 .where(EventOrganizer.event_id == event.id)
959 .where(EventOrganizer.user_id >= next_user_id)
960 .order_by(EventOrganizer.user_id)
961 .limit(page_size + 1),
962 context,
963 EventOrganizer.user_id,
964 )
965 )
966 .scalars()
967 .all()
968 )
969 return events_pb2.ListEventOrganizersRes(
970 organizer_user_ids=[organizer.user_id for organizer in organizers[:page_size]],
971 next_page_token=str(organizers[-1].user_id) if len(organizers) > page_size else None,
972 )
974 def TransferEvent(
975 self, request: events_pb2.TransferEventReq, context: CouchersContext, session: Session
976 ) -> events_pb2.Event:
977 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
978 if not res: 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true
979 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
981 event, occurrence = res
983 if not _can_edit_event(session, event, context.user_id):
984 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_transfer_permission_denied")
986 if occurrence.is_cancelled:
987 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
989 if occurrence.end_time < now() - timedelta(hours=24): 989 ↛ 990line 989 didn't jump to line 990 because the condition on line 989 was never true
990 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
992 if request.WhichOneof("new_owner") == "new_owner_group_id":
993 cluster = session.execute(
994 select(Cluster).where(~Cluster.is_official_cluster).where(Cluster.id == request.new_owner_group_id)
995 ).scalar_one_or_none()
996 elif request.WhichOneof("new_owner") == "new_owner_community_id": 996 ↛ 1003line 996 didn't jump to line 1003 because the condition on line 996 was always true
997 cluster = session.execute(
998 select(Cluster)
999 .where(Cluster.parent_node_id == request.new_owner_community_id)
1000 .where(Cluster.is_official_cluster)
1001 ).scalar_one_or_none()
1003 if not cluster: 1003 ↛ 1004line 1003 didn't jump to line 1004 because the condition on line 1003 was never true
1004 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "group_or_community_not_found")
1006 event.owner_user = None
1007 event.owner_cluster = cluster
1009 session.commit()
1010 return event_to_pb(session, occurrence, context)
1012 def SetEventSubscription(
1013 self, request: events_pb2.SetEventSubscriptionReq, context: CouchersContext, session: Session
1014 ) -> events_pb2.Event:
1015 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1016 if not res: 1016 ↛ 1017line 1016 didn't jump to line 1017 because the condition on line 1016 was never true
1017 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1019 event, occurrence = res
1021 if occurrence.is_cancelled:
1022 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1024 if occurrence.end_time < now() - timedelta(hours=24): 1024 ↛ 1025line 1024 didn't jump to line 1025 because the condition on line 1024 was never true
1025 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1027 current_subscription = session.execute(
1028 select(EventSubscription)
1029 .where(EventSubscription.user_id == context.user_id)
1030 .where(EventSubscription.event_id == event.id)
1031 ).scalar_one_or_none()
1033 # if not subscribed, subscribe
1034 if request.subscribe and not current_subscription:
1035 session.add(EventSubscription(user_id=context.user_id, event_id=event.id))
1037 # if subscribed but unsubbing, remove subscription
1038 if not request.subscribe and current_subscription:
1039 session.delete(current_subscription)
1041 session.flush()
1043 log_event(
1044 context,
1045 session,
1046 "event.subscription_set",
1047 {"event_id": event.id, "occurrence_id": occurrence.id, "subscribed": request.subscribe},
1048 )
1050 return event_to_pb(session, occurrence, context)
1052 def SetEventAttendance(
1053 self, request: events_pb2.SetEventAttendanceReq, context: CouchersContext, session: Session
1054 ) -> events_pb2.Event:
1055 occurrence = session.execute(
1056 where_moderated_content_visible(
1057 select(EventOccurrence)
1058 .where(EventOccurrence.id == request.event_id)
1059 .where(~EventOccurrence.is_deleted),
1060 context,
1061 EventOccurrence,
1062 is_list_operation=False,
1063 )
1064 ).scalar_one_or_none()
1066 if not occurrence: 1066 ↛ 1067line 1066 didn't jump to line 1067 because the condition on line 1066 was never true
1067 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1069 if occurrence.is_cancelled:
1070 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1072 if occurrence.end_time < now() - timedelta(hours=24): 1072 ↛ 1073line 1072 didn't jump to line 1073 because the condition on line 1072 was never true
1073 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1075 current_attendance = session.execute(
1076 select(EventOccurrenceAttendee)
1077 .where(EventOccurrenceAttendee.user_id == context.user_id)
1078 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
1079 ).scalar_one_or_none()
1081 if request.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING:
1082 if current_attendance: 1082 ↛ 1097line 1082 didn't jump to line 1097 because the condition on line 1082 was always true
1083 session.delete(current_attendance)
1084 # if unset/not going, nothing to do!
1085 else:
1086 if current_attendance: 1086 ↛ 1087line 1086 didn't jump to line 1087 because the condition on line 1086 was never true
1087 current_attendance.attendee_status = attendancestate2sql[request.attendance_state] # type: ignore[assignment]
1088 else:
1089 # create new
1090 attendance = EventOccurrenceAttendee(
1091 user_id=context.user_id,
1092 occurrence_id=occurrence.id,
1093 attendee_status=not_none(attendancestate2sql[request.attendance_state]),
1094 )
1095 session.add(attendance)
1097 session.flush()
1099 log_event(
1100 context,
1101 session,
1102 "event.attendance_set",
1103 {"occurrence_id": occurrence.id, "attendance_state": request.attendance_state},
1104 )
1106 return event_to_pb(session, occurrence, context)
1108 def ListMyEvents(
1109 self, request: events_pb2.ListMyEventsReq, context: CouchersContext, session: Session
1110 ) -> events_pb2.ListMyEventsRes:
1111 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1112 # the page token is a unix timestamp of where we left off
1113 page_token = (
1114 dt_from_millis(int(request.page_token)) if request.page_token and not request.page_number else now()
1115 )
1116 # the page number is the page number we are on
1117 page_number = request.page_number or 1
1118 # Calculate the offset for pagination
1119 offset = (page_number - 1) * page_size
1120 query = (
1121 select(EventOccurrence).join(Event, Event.id == EventOccurrence.event_id).where(~EventOccurrence.is_deleted)
1122 )
1123 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
1125 include_all = not (request.subscribed or request.attending or request.organizing or request.my_communities)
1126 include_subscribed = request.subscribed or include_all
1127 include_organizing = request.organizing or include_all
1128 include_attending = request.attending or include_all
1129 include_my_communities = request.my_communities or include_all
1131 if include_attending and request.exclude_attending:
1132 context.abort_with_error_code(
1133 grpc.StatusCode.INVALID_ARGUMENT, "cannot_combine_attending_and_exclude_attending"
1134 )
1136 where_ = []
1138 if include_subscribed:
1139 query = query.outerjoin(
1140 EventSubscription,
1141 and_(EventSubscription.event_id == Event.id, EventSubscription.user_id == context.user_id),
1142 )
1143 where_.append(EventSubscription.user_id != None)
1144 if include_organizing:
1145 query = query.outerjoin(
1146 EventOrganizer, and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id)
1147 )
1148 where_.append(EventOrganizer.user_id != None)
1149 if include_attending or request.exclude_attending:
1150 query = query.outerjoin(
1151 EventOccurrenceAttendee,
1152 and_(
1153 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id,
1154 EventOccurrenceAttendee.user_id == context.user_id,
1155 ),
1156 )
1157 if include_attending:
1158 where_.append(EventOccurrenceAttendee.user_id != None)
1159 elif request.exclude_attending: 1159 ↛ 1166line 1159 didn't jump to line 1166 because the condition on line 1159 was always true
1160 if not include_organizing: 1160 ↛ 1165line 1160 didn't jump to line 1165 because the condition on line 1160 was always true
1161 query = query.outerjoin(
1162 EventOrganizer,
1163 and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id),
1164 )
1165 query = query.where(EventOccurrenceAttendee.user_id == None, EventOrganizer.user_id == None)
1166 if include_my_communities:
1167 my_communities = (
1168 session.execute(
1169 select(Node.id)
1170 .join(Cluster, Cluster.parent_node_id == Node.id)
1171 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id)
1172 .where(ClusterSubscription.user_id == context.user_id)
1173 .where(Cluster.is_official_cluster)
1174 .order_by(Node.id)
1175 .limit(100000)
1176 )
1177 .scalars()
1178 .all()
1179 )
1180 where_.append(Event.parent_node_id.in_(my_communities))
1182 query = query.where(or_(*where_))
1184 if request.my_communities_exclude_global:
1185 query = query.join(Node, Node.id == Event.parent_node_id).where(Node.node_type > NodeType.region)
1187 if not request.include_cancelled:
1188 query = query.where(~EventOccurrence.is_cancelled)
1190 if not request.past: 1190 ↛ 1194line 1190 didn't jump to line 1194 because the condition on line 1190 was always true
1191 cutoff = page_token - timedelta(seconds=1)
1192 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc())
1193 else:
1194 cutoff = page_token + timedelta(seconds=1)
1195 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc())
1196 # Count the total number of items for pagination
1197 total_items = session.execute(select(func.count()).select_from(query.subquery())).scalar()
1198 # Apply pagination by page number
1199 query = query.offset(offset).limit(page_size) if request.page_number else query.limit(page_size + 1)
1200 occurrences = session.execute(query).scalars().all()
1202 return events_pb2.ListMyEventsRes(
1203 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
1204 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None,
1205 total_items=total_items,
1206 )
1208 def ListAllEvents(
1209 self, request: events_pb2.ListAllEventsReq, context: CouchersContext, session: Session
1210 ) -> events_pb2.ListAllEventsRes:
1211 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1212 # the page token is a unix timestamp of where we left off
1213 page_token = dt_from_millis(int(request.page_token)) if request.page_token else now()
1215 query = select(EventOccurrence).where(~EventOccurrence.is_deleted)
1216 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
1218 if not request.include_cancelled: 1218 ↛ 1221line 1218 didn't jump to line 1221 because the condition on line 1218 was always true
1219 query = query.where(~EventOccurrence.is_cancelled)
1221 if not request.past:
1222 cutoff = page_token - timedelta(seconds=1)
1223 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc())
1224 else:
1225 cutoff = page_token + timedelta(seconds=1)
1226 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc())
1228 query = query.limit(page_size + 1)
1229 occurrences = session.execute(query).scalars().all()
1231 return events_pb2.ListAllEventsRes(
1232 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
1233 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None,
1234 )
1236 def InviteEventOrganizer(
1237 self, request: events_pb2.InviteEventOrganizerReq, context: CouchersContext, session: Session
1238 ) -> empty_pb2.Empty:
1239 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
1240 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1241 if not res: 1241 ↛ 1242line 1241 didn't jump to line 1242 because the condition on line 1241 was never true
1242 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1244 event, occurrence = res
1246 if not _can_edit_event(session, event, context.user_id):
1247 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
1249 if occurrence.is_cancelled:
1250 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1252 if occurrence.end_time < now() - timedelta(hours=24): 1252 ↛ 1253line 1252 didn't jump to line 1253 because the condition on line 1252 was never true
1253 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1255 if not session.execute( 1255 ↛ 1258line 1255 didn't jump to line 1258 because the condition on line 1255 was never true
1256 select(User).where(users_visible(context)).where(User.id == request.user_id)
1257 ).scalar_one_or_none():
1258 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
1260 session.add(
1261 EventOrganizer(
1262 user_id=request.user_id,
1263 event_id=event.id,
1264 )
1265 )
1266 session.flush()
1268 other_user_context = make_notification_user_context(user_id=request.user_id)
1270 notify(
1271 session,
1272 user_id=request.user_id,
1273 topic_action=NotificationTopicAction.event__invite_organizer,
1274 key=str(event.id),
1275 data=notification_data_pb2.EventInviteOrganizer(
1276 event=event_to_pb(session, occurrence, other_user_context),
1277 inviting_user=user_model_to_pb(user, session, other_user_context),
1278 ),
1279 )
1281 return empty_pb2.Empty()
1283 def RemoveEventOrganizer(
1284 self, request: events_pb2.RemoveEventOrganizerReq, context: CouchersContext, session: Session
1285 ) -> empty_pb2.Empty:
1286 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1287 if not res: 1287 ↛ 1288line 1287 didn't jump to line 1288 because the condition on line 1287 was never true
1288 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1290 event, occurrence = res
1292 if occurrence.is_cancelled: 1292 ↛ 1293line 1292 didn't jump to line 1293 because the condition on line 1292 was never true
1293 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1295 if occurrence.end_time < now() - timedelta(hours=24): 1295 ↛ 1296line 1295 didn't jump to line 1296 because the condition on line 1295 was never true
1296 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1298 # Determine which user to remove
1299 user_id_to_remove = request.user_id.value if request.HasField("user_id") else context.user_id
1301 # Check if the target user is the event owner (only after permission check)
1302 if event.owner_user_id == user_id_to_remove:
1303 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_remove_owner_as_organizer")
1305 # Check permissions: either an organizer removing an organizer OR you're the event owner
1306 if not _can_edit_event(session, event, context.user_id):
1307 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_edit_permission_denied")
1309 # Find the organizer to remove
1310 organizer_to_remove = session.execute(
1311 select(EventOrganizer)
1312 .where(EventOrganizer.user_id == user_id_to_remove)
1313 .where(EventOrganizer.event_id == event.id)
1314 ).scalar_one_or_none()
1316 if not organizer_to_remove: 1316 ↛ 1317line 1316 didn't jump to line 1317 because the condition on line 1316 was never true
1317 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_not_an_organizer")
1319 session.delete(organizer_to_remove)
1321 return empty_pb2.Empty()