Coverage for app / backend / src / couchers / servicers / events.py: 84%
546 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +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_background_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 events_pb2.AttendanceState.ATTENDANCE_STATE_MAYBE: AttendeeStatus.maybe,
60}
62attendancestate2api = {
63 None: events_pb2.AttendanceState.ATTENDANCE_STATE_NOT_GOING,
64 AttendeeStatus.going: events_pb2.AttendanceState.ATTENDANCE_STATE_GOING,
65 AttendeeStatus.maybe: events_pb2.AttendanceState.ATTENDANCE_STATE_MAYBE,
66}
68MAX_PAGINATION_LENGTH = 25
71def _is_event_owner(event: Event, user_id: int) -> bool:
72 """
73 Checks whether the user can act as an owner of the event
74 """
75 if event.owner_user:
76 return event.owner_user_id == user_id
77 # otherwise owned by a cluster
78 return not_none(event.owner_cluster).admins.where(User.id == user_id).one_or_none() is not None
81def _is_event_organizer(event: Event, user_id: int) -> bool:
82 """
83 Checks whether the user is as an organizer of the event
84 """
85 return event.organizers.where(EventOrganizer.user_id == user_id).one_or_none() is not None
88def _can_moderate_event(session: Session, event: Event, user_id: int) -> bool:
89 # if the event is owned by a cluster, then any moderator of that cluster can moderate this event
90 if event.owner_cluster is not None and can_moderate_node(session, user_id, event.owner_cluster.parent_node_id):
91 return True
93 # finally check if the user can moderate the parent node of the cluster
94 return can_moderate_node(session, user_id, event.parent_node_id)
97def _can_edit_event(session: Session, event: Event, user_id: int) -> bool:
98 return (
99 _is_event_owner(event, user_id)
100 or _is_event_organizer(event, user_id)
101 or _can_moderate_event(session, event, user_id)
102 )
105def event_to_pb(session: Session, occurrence: EventOccurrence, context: CouchersContext) -> events_pb2.Event:
106 event = occurrence.event
108 next_occurrence = (
109 event.occurrences.where(EventOccurrence.end_time >= now())
110 .order_by(EventOccurrence.end_time.asc())
111 .limit(1)
112 .one_or_none()
113 )
115 owner_community_id = None
116 owner_group_id = None
117 if event.owner_cluster:
118 if event.owner_cluster.is_official_cluster:
119 owner_community_id = event.owner_cluster.parent_node_id
120 else:
121 owner_group_id = event.owner_cluster.id
123 attendance = occurrence.attendances.where(EventOccurrenceAttendee.user_id == context.user_id).one_or_none()
124 attendance_state = attendance.attendee_status if attendance else None
126 can_moderate = _can_moderate_event(session, event, context.user_id)
127 can_edit = _can_edit_event(session, event, context.user_id)
129 going_count = session.execute(
130 where_users_column_visible(
131 select(func.count())
132 .select_from(EventOccurrenceAttendee)
133 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
134 .where(EventOccurrenceAttendee.attendee_status == AttendeeStatus.going),
135 context,
136 EventOccurrenceAttendee.user_id,
137 )
138 ).scalar_one()
139 maybe_count = session.execute(
140 where_users_column_visible(
141 select(func.count())
142 .select_from(EventOccurrenceAttendee)
143 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
144 .where(EventOccurrenceAttendee.attendee_status == AttendeeStatus.maybe),
145 context,
146 EventOccurrenceAttendee.user_id,
147 )
148 ).scalar_one()
150 organizer_count = session.execute(
151 where_users_column_visible(
152 select(func.count()).select_from(EventOrganizer).where(EventOrganizer.event_id == event.id),
153 context,
154 EventOrganizer.user_id,
155 )
156 ).scalar_one()
157 subscriber_count = session.execute(
158 where_users_column_visible(
159 select(func.count()).select_from(EventSubscription).where(EventSubscription.event_id == event.id),
160 context,
161 EventSubscription.user_id,
162 )
163 ).scalar_one()
165 return events_pb2.Event(
166 event_id=occurrence.id,
167 is_next=False if not next_occurrence else occurrence.id == next_occurrence.id,
168 is_cancelled=occurrence.is_cancelled,
169 is_deleted=occurrence.is_deleted,
170 title=event.title,
171 slug=event.slug,
172 content=occurrence.content,
173 photo_url=occurrence.photo.full_url if occurrence.photo else None,
174 photo_key=occurrence.photo_key or "",
175 online_information=(
176 events_pb2.OnlineEventInformation(
177 link=occurrence.link,
178 )
179 if occurrence.link
180 else None
181 ),
182 offline_information=(
183 events_pb2.OfflineEventInformation(
184 lat=not_none(occurrence.coordinates)[0],
185 lng=not_none(occurrence.coordinates)[1],
186 address=occurrence.address,
187 )
188 if occurrence.geom
189 else None
190 ),
191 created=Timestamp_from_datetime(occurrence.created),
192 last_edited=Timestamp_from_datetime(occurrence.last_edited),
193 creator_user_id=occurrence.creator_user_id,
194 start_time=Timestamp_from_datetime(occurrence.start_time),
195 end_time=Timestamp_from_datetime(occurrence.end_time),
196 timezone=occurrence.timezone,
197 start_time_display=str(occurrence.start_time),
198 end_time_display=str(occurrence.end_time),
199 attendance_state=attendancestate2api[attendance_state],
200 organizer=event.organizers.where(EventOrganizer.user_id == context.user_id).one_or_none() is not None,
201 subscriber=event.subscribers.where(EventSubscription.user_id == context.user_id).one_or_none() is not None,
202 going_count=going_count,
203 maybe_count=maybe_count,
204 organizer_count=organizer_count,
205 subscriber_count=subscriber_count,
206 owner_user_id=event.owner_user_id,
207 owner_community_id=owner_community_id,
208 owner_group_id=owner_group_id,
209 thread=thread_to_pb(session, event.thread_id),
210 can_edit=can_edit,
211 can_moderate=can_moderate,
212 )
215def _get_event_and_occurrence_query(
216 occurrence_id: int,
217 include_deleted: bool,
218 context: CouchersContext | None = None,
219) -> Select[tuple[Event, EventOccurrence]]:
220 query = (
221 select(Event, EventOccurrence)
222 .where(EventOccurrence.id == occurrence_id)
223 .where(EventOccurrence.event_id == Event.id)
224 )
226 if not include_deleted: 226 ↛ 229line 226 didn't jump to line 229 because the condition on line 226 was always true
227 query = query.where(~EventOccurrence.is_deleted)
229 if context is not None:
230 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=False)
232 return query
235def _get_event_and_occurrence_one(
236 session: Session, occurrence_id: int, include_deleted: bool = False
237) -> tuple[Event, EventOccurrence]:
238 """For background jobs only - no visibility filtering."""
239 result = session.execute(_get_event_and_occurrence_query(occurrence_id, include_deleted)).one()
240 return result._tuple()
243def _get_event_and_occurrence_one_or_none(
244 session: Session, occurrence_id: int, context: CouchersContext, include_deleted: bool = False
245) -> tuple[Event, EventOccurrence] | None:
246 result = session.execute(
247 _get_event_and_occurrence_query(occurrence_id, include_deleted, context=context)
248 ).one_or_none()
249 return result._tuple() if result else None
252def _check_occurrence_time_validity(start_time: datetime, end_time: datetime, context: CouchersContext) -> None:
253 if start_time < now():
254 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_in_past")
255 if end_time < start_time:
256 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_ends_before_starts")
257 if end_time - start_time > timedelta(days=7):
258 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_too_long")
259 if start_time - now() > timedelta(days=365):
260 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_too_far_in_future")
263def get_users_to_notify_for_new_event(session: Session, occurrence: EventOccurrence) -> tuple[list[User], int | None]:
264 """
265 Returns the users to notify, as well as the community id that is being notified (None if based on geo search)
266 """
267 cluster = occurrence.event.parent_node.official_cluster
268 if occurrence.event.parent_node.node_type.value <= NodeType.region.value:
269 logger.info("Global, macroregion, and region communities are too big for email notifications.")
270 return [], occurrence.event.parent_node_id
271 elif occurrence.creator_user in cluster.admins or cluster.is_leaf: 271 ↛ 274line 271 didn't jump to line 274 because the condition on line 271 was always true
272 return list(cluster.members.where(User.is_visible)), occurrence.event.parent_node_id
273 else:
274 max_radius = 20000 # m
275 users = (
276 session.execute(
277 select(User)
278 .join(ClusterSubscription, ClusterSubscription.user_id == User.id)
279 .where(User.is_visible)
280 .where(ClusterSubscription.cluster_id == cluster.id)
281 .where(func.ST_DWithin(User.geom, occurrence.geom, max_radius / 111111))
282 )
283 .scalars()
284 .all()
285 )
286 return cast(tuple[list[User], int | None], (users, None))
289def generate_event_create_notifications(payload: jobs_pb2.GenerateEventCreateNotificationsPayload) -> None:
290 """
291 Background job to generated/fan out event notifications
292 """
293 # Import here to avoid circular dependency
294 from couchers.servicers.communities import community_to_pb # noqa: PLC0415
296 logger.info(f"Fanning out notifications for event occurrence id = {payload.occurrence_id}")
298 with session_scope() as session:
299 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
300 creator = occurrence.creator_user
302 users, node_id = get_users_to_notify_for_new_event(session, occurrence)
304 inviting_user = session.execute(select(User).where(User.id == payload.inviting_user_id)).scalar_one_or_none()
306 if not inviting_user: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true
307 logger.error(f"Inviting user {payload.inviting_user_id} is gone while trying to send event notification?")
308 return
310 for user in users:
311 if is_not_visible(session, user.id, creator.id): 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 continue
313 context = make_background_user_context(user_id=user.id)
314 topic_action = (
315 NotificationTopicAction.event__create_approved
316 if payload.approved
317 else NotificationTopicAction.event__create_any
318 )
319 notify(
320 session,
321 user_id=user.id,
322 topic_action=topic_action,
323 key=str(payload.occurrence_id),
324 data=notification_data_pb2.EventCreate(
325 event=event_to_pb(session, occurrence, context),
326 inviting_user=user_model_to_pb(inviting_user, session, context),
327 nearby=True if node_id is None else None,
328 in_community=community_to_pb(session, event.parent_node, context) if node_id is not None else None,
329 ),
330 moderation_state_id=occurrence.moderation_state_id,
331 )
334def generate_event_update_notifications(payload: jobs_pb2.GenerateEventUpdateNotificationsPayload) -> None:
335 with session_scope() as session:
336 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
338 updating_user = session.execute(select(User).where(User.id == payload.updating_user_id)).scalar_one()
340 subscribed_user_ids = [user.id for user in event.subscribers]
341 attending_user_ids = [user.user_id for user in occurrence.attendances]
343 for user_id in set(subscribed_user_ids + attending_user_ids):
344 if is_not_visible(session, user_id, updating_user.id): 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true
345 continue
346 context = make_background_user_context(user_id=user_id)
347 notify(
348 session,
349 user_id=user_id,
350 topic_action=NotificationTopicAction.event__update,
351 key=str(payload.occurrence_id),
352 data=notification_data_pb2.EventUpdate(
353 event=event_to_pb(session, occurrence, context),
354 updating_user=user_model_to_pb(updating_user, session, context),
355 updated_items=payload.updated_items,
356 ),
357 moderation_state_id=occurrence.moderation_state_id,
358 )
361def generate_event_cancel_notifications(payload: jobs_pb2.GenerateEventCancelNotificationsPayload) -> None:
362 with session_scope() as session:
363 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
365 cancelling_user = session.execute(select(User).where(User.id == payload.cancelling_user_id)).scalar_one()
367 subscribed_user_ids = [user.id for user in event.subscribers]
368 attending_user_ids = [user.user_id for user in occurrence.attendances]
370 for user_id in set(subscribed_user_ids + attending_user_ids):
371 if is_not_visible(session, user_id, cancelling_user.id): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 continue
373 context = make_background_user_context(user_id=user_id)
374 notify(
375 session,
376 user_id=user_id,
377 topic_action=NotificationTopicAction.event__cancel,
378 key=str(payload.occurrence_id),
379 data=notification_data_pb2.EventCancel(
380 event=event_to_pb(session, occurrence, context),
381 cancelling_user=user_model_to_pb(cancelling_user, session, context),
382 ),
383 moderation_state_id=occurrence.moderation_state_id,
384 )
387def generate_event_delete_notifications(payload: jobs_pb2.GenerateEventDeleteNotificationsPayload) -> None:
388 with session_scope() as session:
389 event, occurrence = _get_event_and_occurrence_one(
390 session, occurrence_id=payload.occurrence_id, include_deleted=True
391 )
393 subscribed_user_ids = [user.id for user in event.subscribers]
394 attending_user_ids = [user.user_id for user in occurrence.attendances]
396 for user_id in set(subscribed_user_ids + attending_user_ids):
397 context = make_background_user_context(user_id=user_id)
398 notify(
399 session,
400 user_id=user_id,
401 topic_action=NotificationTopicAction.event__delete,
402 key=str(payload.occurrence_id),
403 data=notification_data_pb2.EventDelete(
404 event=event_to_pb(session, occurrence, context),
405 ),
406 moderation_state_id=occurrence.moderation_state_id,
407 )
410class Events(events_pb2_grpc.EventsServicer):
411 def CreateEvent(
412 self, request: events_pb2.CreateEventReq, context: CouchersContext, session: Session
413 ) -> events_pb2.Event:
414 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
415 if not has_completed_profile(session, user):
416 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_event")
417 if not request.title:
418 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_title")
419 if not request.content:
420 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content")
421 if request.HasField("online_information"):
422 online = True
423 geom = None
424 address = None
425 if not request.online_information.link:
426 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "online_event_requires_link")
427 link = request.online_information.link
428 elif request.HasField("offline_information"): 428 ↛ 443line 428 didn't jump to line 443 because the condition on line 428 was always true
429 online = False
430 # As protobuf parses a missing value as 0.0, this is not a permitted event coordinate value
431 if not (
432 request.offline_information.address
433 and request.offline_information.lat
434 and request.offline_information.lng
435 ):
436 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location")
437 if request.offline_information.lat == 0 and request.offline_information.lng == 0: 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true
438 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
439 geom = create_coordinate(request.offline_information.lat, request.offline_information.lng)
440 address = request.offline_information.address
441 link = None
442 else:
443 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_location_or_link")
445 start_time = to_aware_datetime(request.start_time)
446 end_time = to_aware_datetime(request.end_time)
448 _check_occurrence_time_validity(start_time, end_time, context)
450 if request.parent_community_id:
451 parent_node = session.execute(
452 select(Node).where(Node.id == request.parent_community_id)
453 ).scalar_one_or_none()
455 if not parent_node or not parent_node.official_cluster.small_community_features_enabled: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "events_not_enabled")
457 else:
458 if online:
459 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "online_event_missing_parent_community")
460 # parent community computed from geom
461 parent_node = get_parent_node_at_location(session, not_none(geom))
463 if not parent_node: 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true
464 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "community_not_found")
466 if (
467 request.photo_key
468 and not session.execute(select(Upload).where(Upload.key == request.photo_key)).scalar_one_or_none()
469 ):
470 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found")
472 thread = Thread()
473 session.add(thread)
474 session.flush()
476 event = Event(
477 title=request.title,
478 parent_node_id=parent_node.id,
479 owner_user_id=context.user_id,
480 thread_id=thread.id,
481 creator_user_id=context.user_id,
482 )
483 session.add(event)
484 session.flush()
486 occurrence: EventOccurrence | None = None
488 def create_occurrence(moderation_state_id: int) -> int:
489 nonlocal occurrence
490 occurrence = EventOccurrence(
491 event_id=event.id,
492 content=request.content,
493 geom=geom,
494 address=address,
495 link=link,
496 photo_key=request.photo_key if request.photo_key != "" else None,
497 # timezone=timezone,
498 during=TimestamptzRange(start_time, end_time),
499 creator_user_id=context.user_id,
500 moderation_state_id=moderation_state_id,
501 )
502 session.add(occurrence)
503 session.flush()
504 return occurrence.id
506 create_moderation(
507 session=session,
508 object_type=ModerationObjectType.event_occurrence,
509 object_id=create_occurrence,
510 creator_user_id=context.user_id,
511 )
513 assert occurrence is not None
515 session.add(
516 EventOrganizer(
517 user_id=context.user_id,
518 event_id=event.id,
519 )
520 )
522 session.add(
523 EventSubscription(
524 user_id=context.user_id,
525 event_id=event.id,
526 )
527 )
529 session.add(
530 EventOccurrenceAttendee(
531 user_id=context.user_id,
532 occurrence_id=occurrence.id,
533 attendee_status=AttendeeStatus.going,
534 )
535 )
537 session.commit()
539 log_event(
540 context,
541 session,
542 "event.created",
543 {
544 "event_id": event.id,
545 "occurrence_id": occurrence.id,
546 "parent_community_id": parent_node.id,
547 "parent_community_name": parent_node.official_cluster.name,
548 "online": online,
549 },
550 )
552 if has_completed_profile(session, user): 552 ↛ 563line 552 didn't jump to line 563 because the condition on line 552 was always true
553 queue_job(
554 session,
555 job=generate_event_create_notifications,
556 payload=jobs_pb2.GenerateEventCreateNotificationsPayload(
557 inviting_user_id=user.id,
558 occurrence_id=occurrence.id,
559 approved=False,
560 ),
561 )
563 return event_to_pb(session, occurrence, context)
565 def ScheduleEvent(
566 self, request: events_pb2.ScheduleEventReq, context: CouchersContext, session: Session
567 ) -> events_pb2.Event:
568 if not request.content: 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true
569 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content")
570 if request.HasField("online_information"):
571 geom = None
572 address = None
573 link = request.online_information.link
574 elif request.HasField("offline_information"): 574 ↛ 587line 574 didn't jump to line 587 because the condition on line 574 was always true
575 if not ( 575 ↛ 580line 575 didn't jump to line 580 because the condition on line 575 was never true
576 request.offline_information.address
577 and request.offline_information.lat
578 and request.offline_information.lng
579 ):
580 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location")
581 if request.offline_information.lat == 0 and request.offline_information.lng == 0: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true
582 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
583 geom = create_coordinate(request.offline_information.lat, request.offline_information.lng)
584 address = request.offline_information.address
585 link = None
586 else:
587 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_location_or_link")
589 start_time = to_aware_datetime(request.start_time)
590 end_time = to_aware_datetime(request.end_time)
592 _check_occurrence_time_validity(start_time, end_time, context)
594 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
595 if not res: 595 ↛ 596line 595 didn't jump to line 596 because the condition on line 595 was never true
596 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
598 event, occurrence = res
600 if not _can_edit_event(session, event, context.user_id): 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true
601 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
603 if occurrence.is_cancelled: 603 ↛ 604line 603 didn't jump to line 604 because the condition on line 603 was never true
604 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
606 if ( 606 ↛ 610line 606 didn't jump to line 610 because the condition on line 606 was never true
607 request.photo_key
608 and not session.execute(select(Upload).where(Upload.key == request.photo_key)).scalar_one_or_none()
609 ):
610 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found")
612 during = TimestamptzRange(start_time, end_time)
614 # && is the overlap operator for ranges
615 if (
616 session.execute(
617 select(EventOccurrence.id)
618 .where(EventOccurrence.event_id == event.id)
619 .where(EventOccurrence.during.op("&&")(during))
620 .limit(1)
621 )
622 .scalars()
623 .one_or_none()
624 is not None
625 ):
626 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_overlap")
628 new_occurrence: EventOccurrence | None = None
630 def create_occurrence(moderation_state_id: int) -> int:
631 nonlocal new_occurrence
632 new_occurrence = EventOccurrence(
633 event_id=event.id,
634 content=request.content,
635 geom=geom,
636 address=address,
637 link=link,
638 photo_key=request.photo_key if request.photo_key != "" else None,
639 # timezone=timezone,
640 during=during,
641 creator_user_id=context.user_id,
642 moderation_state_id=moderation_state_id,
643 )
644 session.add(new_occurrence)
645 session.flush()
646 return new_occurrence.id
648 create_moderation(
649 session=session,
650 object_type=ModerationObjectType.event_occurrence,
651 object_id=create_occurrence,
652 creator_user_id=context.user_id,
653 )
655 assert new_occurrence is not None
657 session.add(
658 EventOccurrenceAttendee(
659 user_id=context.user_id,
660 occurrence_id=new_occurrence.id,
661 attendee_status=AttendeeStatus.going,
662 )
663 )
665 session.flush()
667 # TODO: notify
669 return event_to_pb(session, new_occurrence, context)
671 def UpdateEvent(
672 self, request: events_pb2.UpdateEventReq, context: CouchersContext, session: Session
673 ) -> events_pb2.Event:
674 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
675 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
676 if not res: 676 ↛ 677line 676 didn't jump to line 677 because the condition on line 676 was never true
677 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
679 event, occurrence = res
681 if not _can_edit_event(session, event, context.user_id): 681 ↛ 682line 681 didn't jump to line 682 because the condition on line 681 was never true
682 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
684 # the things that were updated and need to be notified about
685 notify_updated = []
687 if occurrence.is_cancelled:
688 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
690 occurrence_update: dict[str, Any] = {"last_edited": now()}
692 if request.HasField("title"):
693 notify_updated.append("title")
694 event.title = request.title.value
696 if request.HasField("content"):
697 notify_updated.append("content")
698 occurrence_update["content"] = request.content.value
700 if request.HasField("photo_key"): 700 ↛ 701line 700 didn't jump to line 701 because the condition on line 700 was never true
701 occurrence_update["photo_key"] = request.photo_key.value
703 if request.HasField("online_information"):
704 notify_updated.append("location")
705 if not request.online_information.link: 705 ↛ 706line 705 didn't jump to line 706 because the condition on line 705 was never true
706 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "online_event_requires_link")
707 occurrence_update["link"] = request.online_information.link
708 occurrence_update["geom"] = None
709 occurrence_update["address"] = None
710 elif request.HasField("offline_information"):
711 notify_updated.append("location")
712 occurrence_update["link"] = None
713 if request.offline_information.lat == 0 and request.offline_information.lng == 0: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true
714 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
715 occurrence_update["geom"] = create_coordinate(
716 request.offline_information.lat, request.offline_information.lng
717 )
718 occurrence_update["address"] = request.offline_information.address
720 if request.HasField("start_time") or request.HasField("end_time"):
721 if request.update_all_future: 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true
722 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_cant_update_all_times")
723 if request.HasField("start_time"): 723 ↛ 727line 723 didn't jump to line 727 because the condition on line 723 was always true
724 notify_updated.append("start time")
725 start_time = to_aware_datetime(request.start_time)
726 else:
727 start_time = occurrence.start_time
728 if request.HasField("end_time"):
729 notify_updated.append("end time")
730 end_time = to_aware_datetime(request.end_time)
731 else:
732 end_time = occurrence.end_time
734 _check_occurrence_time_validity(start_time, end_time, context)
736 during = TimestamptzRange(start_time, end_time)
738 # && is the overlap operator for ranges
739 if (
740 session.execute(
741 select(EventOccurrence.id)
742 .where(EventOccurrence.event_id == event.id)
743 .where(EventOccurrence.id != occurrence.id)
744 .where(EventOccurrence.during.op("&&")(during))
745 .limit(1)
746 )
747 .scalars()
748 .one_or_none()
749 is not None
750 ):
751 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_overlap")
753 occurrence_update["during"] = during
755 # TODO
756 # if request.HasField("timezone"):
757 # occurrence_update["timezone"] = request.timezone
759 # allow editing any event which hasn't ended more than 24 hours before now
760 # when editing all future events, we edit all which have not yet ended
762 cutoff_time = now() - timedelta(hours=24)
763 if request.update_all_future:
764 session.execute(
765 update(EventOccurrence)
766 .where(EventOccurrence.end_time >= cutoff_time)
767 .where(EventOccurrence.start_time >= occurrence.start_time)
768 .values(occurrence_update)
769 .execution_options(synchronize_session=False)
770 )
771 else:
772 if occurrence.end_time < cutoff_time: 772 ↛ 773line 772 didn't jump to line 773 because the condition on line 772 was never true
773 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
774 session.execute(
775 update(EventOccurrence)
776 .where(EventOccurrence.end_time >= cutoff_time)
777 .where(EventOccurrence.id == occurrence.id)
778 .values(occurrence_update)
779 .execution_options(synchronize_session=False)
780 )
782 session.flush()
784 if notify_updated:
785 if request.should_notify:
786 logger.info(f"Fields {','.join(notify_updated)} updated in event {event.id=}, notifying")
788 queue_job(
789 session,
790 job=generate_event_update_notifications,
791 payload=jobs_pb2.GenerateEventUpdateNotificationsPayload(
792 updating_user_id=user.id,
793 occurrence_id=occurrence.id,
794 updated_items=notify_updated,
795 ),
796 )
797 else:
798 logger.info(
799 f"Fields {','.join(notify_updated)} updated in event {event.id=}, but skipping notifications"
800 )
802 # since we have synchronize_session=False, we have to refresh the object
803 session.refresh(occurrence)
805 return event_to_pb(session, occurrence, context)
807 def GetEvent(self, request: events_pb2.GetEventReq, context: CouchersContext, session: Session) -> events_pb2.Event:
808 query = select(EventOccurrence).where(EventOccurrence.id == request.event_id).where(~EventOccurrence.is_deleted)
809 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=False)
810 occurrence = session.execute(query).scalar_one_or_none()
812 if not occurrence:
813 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
815 return event_to_pb(session, occurrence, context)
817 def CancelEvent(
818 self, request: events_pb2.CancelEventReq, context: CouchersContext, session: Session
819 ) -> empty_pb2.Empty:
820 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
821 if not res: 821 ↛ 822line 821 didn't jump to line 822 because the condition on line 821 was never true
822 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
824 event, occurrence = res
826 if not _can_edit_event(session, event, context.user_id): 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true
827 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
829 if occurrence.end_time < now() - timedelta(hours=24): 829 ↛ 830line 829 didn't jump to line 830 because the condition on line 829 was never true
830 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_cancel_old_event")
832 occurrence.is_cancelled = True
834 log_event(context, session, "event.cancelled", {"event_id": event.id, "occurrence_id": occurrence.id})
836 queue_job(
837 session,
838 job=generate_event_cancel_notifications,
839 payload=jobs_pb2.GenerateEventCancelNotificationsPayload(
840 cancelling_user_id=context.user_id,
841 occurrence_id=occurrence.id,
842 ),
843 )
845 return empty_pb2.Empty()
847 def RequestCommunityInvite(
848 self, request: events_pb2.RequestCommunityInviteReq, context: CouchersContext, session: Session
849 ) -> empty_pb2.Empty:
850 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
851 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
852 if not res: 852 ↛ 853line 852 didn't jump to line 853 because the condition on line 852 was never true
853 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
855 event, occurrence = res
857 if not _can_edit_event(session, event, context.user_id):
858 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
860 if occurrence.is_cancelled: 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true
861 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
863 if occurrence.end_time < now() - timedelta(hours=24): 863 ↛ 864line 863 didn't jump to line 864 because the condition on line 863 was never true
864 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
866 this_user_reqs = [req for req in occurrence.community_invite_requests if req.user_id == context.user_id]
868 if len(this_user_reqs) > 0:
869 context.abort_with_error_code(
870 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_requested"
871 )
873 approved_reqs = [req for req in occurrence.community_invite_requests if req.approved]
875 if len(approved_reqs) > 0:
876 context.abort_with_error_code(
877 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_approved"
878 )
880 req = EventCommunityInviteRequest(
881 occurrence_id=request.event_id,
882 user_id=context.user_id,
883 )
884 session.add(req)
885 session.flush()
887 send_event_community_invite_request_email(session, req)
889 return empty_pb2.Empty()
891 def ListEventOccurrences(
892 self, request: events_pb2.ListEventOccurrencesReq, context: CouchersContext, session: Session
893 ) -> events_pb2.ListEventOccurrencesRes:
894 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
895 # the page token is a unix timestamp of where we left off
896 page_token = dt_from_millis(int(request.page_token)) if request.page_token else now()
897 initial_query = (
898 select(EventOccurrence).where(EventOccurrence.id == request.event_id).where(~EventOccurrence.is_deleted)
899 )
900 initial_query = where_moderated_content_visible(
901 initial_query, context, EventOccurrence, is_list_operation=False
902 )
903 occurrence = session.execute(initial_query).scalar_one_or_none()
904 if not occurrence: 904 ↛ 905line 904 didn't jump to line 905 because the condition on line 904 was never true
905 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
907 query = (
908 select(EventOccurrence)
909 .where(EventOccurrence.event_id == occurrence.event_id)
910 .where(~EventOccurrence.is_deleted)
911 )
912 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
914 if not request.include_cancelled:
915 query = query.where(~EventOccurrence.is_cancelled)
917 if not request.past: 917 ↛ 921line 917 didn't jump to line 921 because the condition on line 917 was always true
918 cutoff = page_token - timedelta(seconds=1)
919 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc())
920 else:
921 cutoff = page_token + timedelta(seconds=1)
922 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc())
924 query = query.limit(page_size + 1)
925 occurrences = session.execute(query).scalars().all()
927 return events_pb2.ListEventOccurrencesRes(
928 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
929 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None,
930 )
932 def ListEventAttendees(
933 self, request: events_pb2.ListEventAttendeesReq, context: CouchersContext, session: Session
934 ) -> events_pb2.ListEventAttendeesRes:
935 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
936 next_user_id = int(request.page_token) if request.page_token else 0
937 occurrence = session.execute(
938 where_moderated_content_visible(
939 select(EventOccurrence)
940 .where(EventOccurrence.id == request.event_id)
941 .where(~EventOccurrence.is_deleted),
942 context,
943 EventOccurrence,
944 is_list_operation=False,
945 )
946 ).scalar_one_or_none()
947 if not occurrence: 947 ↛ 948line 947 didn't jump to line 948 because the condition on line 947 was never true
948 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
949 attendees = (
950 session.execute(
951 where_users_column_visible(
952 select(EventOccurrenceAttendee)
953 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
954 .where(EventOccurrenceAttendee.user_id >= next_user_id)
955 .order_by(EventOccurrenceAttendee.user_id)
956 .limit(page_size + 1),
957 context,
958 EventOccurrenceAttendee.user_id,
959 )
960 )
961 .scalars()
962 .all()
963 )
964 return events_pb2.ListEventAttendeesRes(
965 attendee_user_ids=[attendee.user_id for attendee in attendees[:page_size]],
966 next_page_token=str(attendees[-1].user_id) if len(attendees) > page_size else None,
967 )
969 def ListEventSubscribers(
970 self, request: events_pb2.ListEventSubscribersReq, context: CouchersContext, session: Session
971 ) -> events_pb2.ListEventSubscribersRes:
972 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
973 next_user_id = int(request.page_token) if request.page_token else 0
974 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
975 if not res: 975 ↛ 976line 975 didn't jump to line 976 because the condition on line 975 was never true
976 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
977 event, occurrence = res
978 subscribers = (
979 session.execute(
980 where_users_column_visible(
981 select(EventSubscription)
982 .where(EventSubscription.event_id == event.id)
983 .where(EventSubscription.user_id >= next_user_id)
984 .order_by(EventSubscription.user_id)
985 .limit(page_size + 1),
986 context,
987 EventSubscription.user_id,
988 )
989 )
990 .scalars()
991 .all()
992 )
993 return events_pb2.ListEventSubscribersRes(
994 subscriber_user_ids=[subscriber.user_id for subscriber in subscribers[:page_size]],
995 next_page_token=str(subscribers[-1].user_id) if len(subscribers) > page_size else None,
996 )
998 def ListEventOrganizers(
999 self, request: events_pb2.ListEventOrganizersReq, context: CouchersContext, session: Session
1000 ) -> events_pb2.ListEventOrganizersRes:
1001 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1002 next_user_id = int(request.page_token) if request.page_token else 0
1003 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1004 if not res: 1004 ↛ 1005line 1004 didn't jump to line 1005 because the condition on line 1004 was never true
1005 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1006 event, occurrence = res
1007 organizers = (
1008 session.execute(
1009 where_users_column_visible(
1010 select(EventOrganizer)
1011 .where(EventOrganizer.event_id == event.id)
1012 .where(EventOrganizer.user_id >= next_user_id)
1013 .order_by(EventOrganizer.user_id)
1014 .limit(page_size + 1),
1015 context,
1016 EventOrganizer.user_id,
1017 )
1018 )
1019 .scalars()
1020 .all()
1021 )
1022 return events_pb2.ListEventOrganizersRes(
1023 organizer_user_ids=[organizer.user_id for organizer in organizers[:page_size]],
1024 next_page_token=str(organizers[-1].user_id) if len(organizers) > page_size else None,
1025 )
1027 def TransferEvent(
1028 self, request: events_pb2.TransferEventReq, context: CouchersContext, session: Session
1029 ) -> events_pb2.Event:
1030 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1031 if not res: 1031 ↛ 1032line 1031 didn't jump to line 1032 because the condition on line 1031 was never true
1032 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1034 event, occurrence = res
1036 if not _can_edit_event(session, event, context.user_id):
1037 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_transfer_permission_denied")
1039 if occurrence.is_cancelled:
1040 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1042 if occurrence.end_time < now() - timedelta(hours=24): 1042 ↛ 1043line 1042 didn't jump to line 1043 because the condition on line 1042 was never true
1043 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1045 if request.WhichOneof("new_owner") == "new_owner_group_id":
1046 cluster = session.execute(
1047 select(Cluster).where(~Cluster.is_official_cluster).where(Cluster.id == request.new_owner_group_id)
1048 ).scalar_one_or_none()
1049 elif request.WhichOneof("new_owner") == "new_owner_community_id": 1049 ↛ 1056line 1049 didn't jump to line 1056 because the condition on line 1049 was always true
1050 cluster = session.execute(
1051 select(Cluster)
1052 .where(Cluster.parent_node_id == request.new_owner_community_id)
1053 .where(Cluster.is_official_cluster)
1054 ).scalar_one_or_none()
1056 if not cluster: 1056 ↛ 1057line 1056 didn't jump to line 1057 because the condition on line 1056 was never true
1057 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "group_or_community_not_found")
1059 event.owner_user = None
1060 event.owner_cluster = cluster
1062 session.commit()
1063 return event_to_pb(session, occurrence, context)
1065 def SetEventSubscription(
1066 self, request: events_pb2.SetEventSubscriptionReq, context: CouchersContext, session: Session
1067 ) -> events_pb2.Event:
1068 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1069 if not res: 1069 ↛ 1070line 1069 didn't jump to line 1070 because the condition on line 1069 was never true
1070 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1072 event, occurrence = res
1074 if occurrence.is_cancelled:
1075 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1077 if occurrence.end_time < now() - timedelta(hours=24): 1077 ↛ 1078line 1077 didn't jump to line 1078 because the condition on line 1077 was never true
1078 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1080 current_subscription = session.execute(
1081 select(EventSubscription)
1082 .where(EventSubscription.user_id == context.user_id)
1083 .where(EventSubscription.event_id == event.id)
1084 ).scalar_one_or_none()
1086 # if not subscribed, subscribe
1087 if request.subscribe and not current_subscription:
1088 session.add(EventSubscription(user_id=context.user_id, event_id=event.id))
1090 # if subscribed but unsubbing, remove subscription
1091 if not request.subscribe and current_subscription:
1092 session.delete(current_subscription)
1094 session.flush()
1096 log_event(
1097 context,
1098 session,
1099 "event.subscription_set",
1100 {"event_id": event.id, "occurrence_id": occurrence.id, "subscribed": request.subscribe},
1101 )
1103 return event_to_pb(session, occurrence, context)
1105 def SetEventAttendance(
1106 self, request: events_pb2.SetEventAttendanceReq, context: CouchersContext, session: Session
1107 ) -> events_pb2.Event:
1108 occurrence = session.execute(
1109 where_moderated_content_visible(
1110 select(EventOccurrence)
1111 .where(EventOccurrence.id == request.event_id)
1112 .where(~EventOccurrence.is_deleted),
1113 context,
1114 EventOccurrence,
1115 is_list_operation=False,
1116 )
1117 ).scalar_one_or_none()
1119 if not occurrence: 1119 ↛ 1120line 1119 didn't jump to line 1120 because the condition on line 1119 was never true
1120 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1122 if occurrence.is_cancelled:
1123 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1125 if occurrence.end_time < now() - timedelta(hours=24): 1125 ↛ 1126line 1125 didn't jump to line 1126 because the condition on line 1125 was never true
1126 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1128 current_attendance = session.execute(
1129 select(EventOccurrenceAttendee)
1130 .where(EventOccurrenceAttendee.user_id == context.user_id)
1131 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
1132 ).scalar_one_or_none()
1134 if request.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING:
1135 if current_attendance: 1135 ↛ 1150line 1135 didn't jump to line 1150 because the condition on line 1135 was always true
1136 session.delete(current_attendance)
1137 # if unset/not going, nothing to do!
1138 else:
1139 if current_attendance:
1140 current_attendance.attendee_status = attendancestate2sql[request.attendance_state] # type: ignore[assignment]
1141 else:
1142 # create new
1143 attendance = EventOccurrenceAttendee(
1144 user_id=context.user_id,
1145 occurrence_id=occurrence.id,
1146 attendee_status=not_none(attendancestate2sql[request.attendance_state]),
1147 )
1148 session.add(attendance)
1150 session.flush()
1152 log_event(
1153 context,
1154 session,
1155 "event.attendance_set",
1156 {"occurrence_id": occurrence.id, "attendance_state": request.attendance_state},
1157 )
1159 return event_to_pb(session, occurrence, context)
1161 def ListMyEvents(
1162 self, request: events_pb2.ListMyEventsReq, context: CouchersContext, session: Session
1163 ) -> events_pb2.ListMyEventsRes:
1164 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1165 # the page token is a unix timestamp of where we left off
1166 page_token = (
1167 dt_from_millis(int(request.page_token)) if request.page_token and not request.page_number else now()
1168 )
1169 # the page number is the page number we are on
1170 page_number = request.page_number or 1
1171 # Calculate the offset for pagination
1172 offset = (page_number - 1) * page_size
1173 query = (
1174 select(EventOccurrence).join(Event, Event.id == EventOccurrence.event_id).where(~EventOccurrence.is_deleted)
1175 )
1176 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
1178 include_all = not (request.subscribed or request.attending or request.organizing or request.my_communities)
1179 include_subscribed = request.subscribed or include_all
1180 include_organizing = request.organizing or include_all
1181 include_attending = request.attending or include_all
1182 include_my_communities = request.my_communities or include_all
1184 where_ = []
1186 if include_subscribed:
1187 query = query.outerjoin(
1188 EventSubscription,
1189 and_(EventSubscription.event_id == Event.id, EventSubscription.user_id == context.user_id),
1190 )
1191 where_.append(EventSubscription.user_id != None)
1192 if include_organizing:
1193 query = query.outerjoin(
1194 EventOrganizer, and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id)
1195 )
1196 where_.append(EventOrganizer.user_id != None)
1197 if include_attending:
1198 query = query.outerjoin(
1199 EventOccurrenceAttendee,
1200 and_(
1201 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id,
1202 EventOccurrenceAttendee.user_id == context.user_id,
1203 ),
1204 )
1205 where_.append(EventOccurrenceAttendee.user_id != None)
1206 if include_my_communities:
1207 my_communities = (
1208 session.execute(
1209 select(Node.id)
1210 .join(Cluster, Cluster.parent_node_id == Node.id)
1211 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id)
1212 .where(ClusterSubscription.user_id == context.user_id)
1213 .where(Cluster.is_official_cluster)
1214 .order_by(Node.id)
1215 .limit(100000)
1216 )
1217 .scalars()
1218 .all()
1219 )
1220 where_.append(Event.parent_node_id.in_(my_communities))
1222 query = query.where(or_(*where_))
1224 if request.my_communities_exclude_global:
1225 query = query.join(Node, Node.id == Event.parent_node_id).where(Node.node_type > NodeType.region)
1227 if not request.include_cancelled:
1228 query = query.where(~EventOccurrence.is_cancelled)
1230 if not request.past: 1230 ↛ 1234line 1230 didn't jump to line 1234 because the condition on line 1230 was always true
1231 cutoff = page_token - timedelta(seconds=1)
1232 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc())
1233 else:
1234 cutoff = page_token + timedelta(seconds=1)
1235 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc())
1236 # Count the total number of items for pagination
1237 total_items = session.execute(select(func.count()).select_from(query.subquery())).scalar()
1238 # Apply pagination by page number
1239 query = query.offset(offset).limit(page_size) if request.page_number else query.limit(page_size + 1)
1240 occurrences = session.execute(query).scalars().all()
1242 return events_pb2.ListMyEventsRes(
1243 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
1244 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None,
1245 total_items=total_items,
1246 )
1248 def ListAllEvents(
1249 self, request: events_pb2.ListAllEventsReq, context: CouchersContext, session: Session
1250 ) -> events_pb2.ListAllEventsRes:
1251 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1252 # the page token is a unix timestamp of where we left off
1253 page_token = dt_from_millis(int(request.page_token)) if request.page_token else now()
1255 query = select(EventOccurrence).where(~EventOccurrence.is_deleted)
1256 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
1258 if not request.include_cancelled: 1258 ↛ 1261line 1258 didn't jump to line 1261 because the condition on line 1258 was always true
1259 query = query.where(~EventOccurrence.is_cancelled)
1261 if not request.past:
1262 cutoff = page_token - timedelta(seconds=1)
1263 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc())
1264 else:
1265 cutoff = page_token + timedelta(seconds=1)
1266 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc())
1268 query = query.limit(page_size + 1)
1269 occurrences = session.execute(query).scalars().all()
1271 return events_pb2.ListAllEventsRes(
1272 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
1273 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None,
1274 )
1276 def InviteEventOrganizer(
1277 self, request: events_pb2.InviteEventOrganizerReq, context: CouchersContext, session: Session
1278 ) -> empty_pb2.Empty:
1279 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
1280 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1281 if not res: 1281 ↛ 1282line 1281 didn't jump to line 1282 because the condition on line 1281 was never true
1282 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1284 event, occurrence = res
1286 if not _can_edit_event(session, event, context.user_id):
1287 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
1289 if occurrence.is_cancelled:
1290 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1292 if occurrence.end_time < now() - timedelta(hours=24): 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.FAILED_PRECONDITION, "event_cant_update_old_event")
1295 if not session.execute( 1295 ↛ 1298line 1295 didn't jump to line 1298 because the condition on line 1295 was never true
1296 select(User).where(users_visible(context)).where(User.id == request.user_id)
1297 ).scalar_one_or_none():
1298 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
1300 session.add(
1301 EventOrganizer(
1302 user_id=request.user_id,
1303 event_id=event.id,
1304 )
1305 )
1306 session.flush()
1308 other_user_context = make_background_user_context(user_id=request.user_id)
1310 notify(
1311 session,
1312 user_id=request.user_id,
1313 topic_action=NotificationTopicAction.event__invite_organizer,
1314 key=str(event.id),
1315 data=notification_data_pb2.EventInviteOrganizer(
1316 event=event_to_pb(session, occurrence, other_user_context),
1317 inviting_user=user_model_to_pb(user, session, other_user_context),
1318 ),
1319 )
1321 return empty_pb2.Empty()
1323 def RemoveEventOrganizer(
1324 self, request: events_pb2.RemoveEventOrganizerReq, context: CouchersContext, session: Session
1325 ) -> empty_pb2.Empty:
1326 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1327 if not res: 1327 ↛ 1328line 1327 didn't jump to line 1328 because the condition on line 1327 was never true
1328 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1330 event, occurrence = res
1332 if occurrence.is_cancelled: 1332 ↛ 1333line 1332 didn't jump to line 1333 because the condition on line 1332 was never true
1333 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1335 if occurrence.end_time < now() - timedelta(hours=24): 1335 ↛ 1336line 1335 didn't jump to line 1336 because the condition on line 1335 was never true
1336 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1338 # Determine which user to remove
1339 user_id_to_remove = request.user_id.value if request.HasField("user_id") else context.user_id
1341 # Check if the target user is the event owner (only after permission check)
1342 if event.owner_user_id == user_id_to_remove:
1343 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_remove_owner_as_organizer")
1345 # Check permissions: either an organizer removing an organizer OR you're the event owner
1346 if not _can_edit_event(session, event, context.user_id):
1347 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_edit_permission_denied")
1349 # Find the organizer to remove
1350 organizer_to_remove = session.execute(
1351 select(EventOrganizer)
1352 .where(EventOrganizer.user_id == user_id_to_remove)
1353 .where(EventOrganizer.event_id == event.id)
1354 ).scalar_one_or_none()
1356 if not organizer_to_remove: 1356 ↛ 1357line 1356 didn't jump to line 1357 because the condition on line 1356 was never true
1357 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_not_an_organizer")
1359 session.delete(organizer_to_remove)
1361 return empty_pb2.Empty()