Coverage for app/backend/src/couchers/servicers/events.py: 86%
562 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
1import logging
2from collections.abc import Sequence
3from datetime import datetime, timedelta
4from typing import Any, cast
5from zoneinfo import ZoneInfo
7import grpc
8from geoalchemy2 import WKBElement
9from google.protobuf import empty_pb2
10from psycopg.types.range import TimestamptzRange
11from sqlalchemy import Select, func, select
12from sqlalchemy.orm import Session, aliased
13from sqlalchemy.sql import and_, func, or_, tuple_, update
15from couchers.context import CouchersContext, make_notification_user_context
16from couchers.db import can_moderate_node, get_parent_node_at_location, session_scope
17from couchers.email.calendar_events import create_event_ics_calendar
18from couchers.event_log import log_event
19from couchers.helpers.completed_profile import has_completed_profile
20from couchers.jobs.enqueue import queue_job
21from couchers.models import (
22 AttendeeStatus,
23 Cluster,
24 ClusterSubscription,
25 Event,
26 EventCommunityInviteRequest,
27 EventOccurrence,
28 EventOccurrenceAttendee,
29 EventOrganizer,
30 EventSubscription,
31 ModerationObjectType,
32 Node,
33 NodeType,
34 Thread,
35 Upload,
36 User,
37)
38from couchers.models.notifications import NotificationTopicAction
39from couchers.models.static import TimezoneArea
40from couchers.moderation.utils import create_moderation
41from couchers.notifications.notify import notify
42from couchers.proto import events_pb2, events_pb2_grpc, notification_data_pb2
43from couchers.proto.google.api import httpbody_pb2
44from couchers.proto.internal import jobs_pb2
45from couchers.servicers.api import user_model_to_pb
46from couchers.servicers.blocking import is_not_visible
47from couchers.servicers.threads import thread_to_pb
48from couchers.sql import (
49 users_visible,
50 users_visible_to_each_other,
51 where_moderated_content_visible,
52 where_users_column_visible,
53)
54from couchers.tasks import send_event_community_invite_request_email
55from couchers.utils import (
56 Timestamp_from_datetime,
57 create_coordinate,
58 datetime_to_iso8601_local,
59 dt_id_from_page_token,
60 dt_id_to_page_token,
61 not_none,
62 now,
63)
65logger = logging.getLogger(__name__)
67attendancestate2sql = {
68 events_pb2.AttendanceState.ATTENDANCE_STATE_NOT_GOING: None,
69 events_pb2.AttendanceState.ATTENDANCE_STATE_GOING: AttendeeStatus.going,
70}
72attendancestate2api = {
73 None: events_pb2.AttendanceState.ATTENDANCE_STATE_NOT_GOING,
74 AttendeeStatus.going: events_pb2.AttendanceState.ATTENDANCE_STATE_GOING,
75}
77MAX_PAGINATION_LENGTH = 25
80def _is_event_owner(event: Event, user_id: int) -> bool:
81 """
82 Checks whether the user can act as an owner of the event
83 """
84 if event.owner_user:
85 return event.owner_user_id == user_id
86 # otherwise owned by a cluster
87 return not_none(event.owner_cluster).admins.where(User.id == user_id).one_or_none() is not None
90def _is_event_organizer(event: Event, user_id: int) -> bool:
91 """
92 Checks whether the user is as an organizer of the event
93 """
94 return event.organizers.where(EventOrganizer.user_id == user_id).one_or_none() is not None
97def _can_moderate_event(session: Session, event: Event, user_id: int) -> bool:
98 # if the event is owned by a cluster, then any moderator of that cluster can moderate this event
99 if event.owner_cluster is not None and can_moderate_node(session, user_id, event.owner_cluster.parent_node_id):
100 return True
102 # finally check if the user can moderate the parent node of the cluster
103 return can_moderate_node(session, user_id, event.parent_node_id)
106def _can_edit_event(session: Session, event: Event, user_id: int) -> bool:
107 return (
108 _is_event_owner(event, user_id)
109 or _is_event_organizer(event, user_id)
110 or _can_moderate_event(session, event, user_id)
111 )
114def event_to_pb(session: Session, occurrence: EventOccurrence, context: CouchersContext) -> events_pb2.Event:
115 event = occurrence.event
117 next_occurrence = (
118 event.occurrences.where(EventOccurrence.end_time >= now())
119 .order_by(EventOccurrence.end_time.asc())
120 .limit(1)
121 .one_or_none()
122 )
124 owner_community_id = None
125 owner_group_id = None
126 if event.owner_cluster:
127 if event.owner_cluster.is_official_cluster:
128 owner_community_id = event.owner_cluster.parent_node_id
129 else:
130 owner_group_id = event.owner_cluster.id
132 attendance = occurrence.attendances.where(EventOccurrenceAttendee.user_id == context.user_id).one_or_none()
133 attendance_state = attendance.attendee_status if attendance else None
135 can_moderate = _can_moderate_event(session, event, context.user_id)
136 can_edit = _can_edit_event(session, event, context.user_id)
138 going_count = session.execute(
139 where_users_column_visible(
140 select(func.count())
141 .select_from(EventOccurrenceAttendee)
142 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
143 .where(EventOccurrenceAttendee.attendee_status == AttendeeStatus.going),
144 context,
145 EventOccurrenceAttendee.user_id,
146 )
147 ).scalar_one()
148 organizer_count = session.execute(
149 where_users_column_visible(
150 select(func.count()).select_from(EventOrganizer).where(EventOrganizer.event_id == event.id),
151 context,
152 EventOrganizer.user_id,
153 )
154 ).scalar_one()
155 subscriber_count = session.execute(
156 where_users_column_visible(
157 select(func.count()).select_from(EventSubscription).where(EventSubscription.event_id == event.id),
158 context,
159 EventSubscription.user_id,
160 )
161 ).scalar_one()
163 return events_pb2.Event(
164 event_id=occurrence.id,
165 is_next=False if not next_occurrence else occurrence.id == next_occurrence.id,
166 is_cancelled=occurrence.is_cancelled,
167 is_deleted=occurrence.is_deleted,
168 title=event.title,
169 slug=event.slug,
170 content=occurrence.content,
171 photo_url=occurrence.photo.full_url if occurrence.photo else None,
172 photo_key=occurrence.photo_key or "",
173 location=events_pb2.EventLocation(
174 lat=occurrence.coordinates[0], lng=occurrence.coordinates[1], address=occurrence.address
175 ),
176 created=Timestamp_from_datetime(occurrence.created),
177 last_edited=Timestamp_from_datetime(occurrence.last_edited),
178 creator_user_id=occurrence.creator_user_id,
179 start_time=Timestamp_from_datetime(occurrence.start_time),
180 end_time=Timestamp_from_datetime(occurrence.end_time),
181 timezone=occurrence.timezone,
182 attendance_state=attendancestate2api[attendance_state],
183 organizer=event.organizers.where(EventOrganizer.user_id == context.user_id).one_or_none() is not None,
184 subscriber=event.subscribers.where(EventSubscription.user_id == context.user_id).one_or_none() is not None,
185 going_count=going_count,
186 organizer_count=organizer_count,
187 subscriber_count=subscriber_count,
188 owner_user_id=event.owner_user_id,
189 owner_community_id=owner_community_id,
190 owner_group_id=owner_group_id,
191 thread=thread_to_pb(session, context, event.thread_id),
192 can_edit=can_edit,
193 can_moderate=can_moderate,
194 )
197def _get_event_and_occurrence_query(
198 occurrence_id: int,
199 include_deleted: bool,
200 context: CouchersContext | None = None,
201) -> Select[tuple[Event, EventOccurrence]]:
202 query = (
203 select(Event, EventOccurrence)
204 .where(EventOccurrence.id == occurrence_id)
205 .where(EventOccurrence.event_id == Event.id)
206 )
208 if not include_deleted: 208 ↛ 211line 208 didn't jump to line 211 because the condition on line 208 was always true
209 query = query.where(~EventOccurrence.is_deleted)
211 if context is not None:
212 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=False)
214 return query
217def _get_event_and_occurrence_one(
218 session: Session, occurrence_id: int, include_deleted: bool = False
219) -> tuple[Event, EventOccurrence]:
220 """For background jobs only - no visibility filtering."""
221 result = session.execute(_get_event_and_occurrence_query(occurrence_id, include_deleted)).one()
222 return result._tuple()
225def _get_event_and_occurrence_one_or_none(
226 session: Session, occurrence_id: int, context: CouchersContext, include_deleted: bool = False
227) -> tuple[Event, EventOccurrence] | None:
228 result = session.execute(
229 _get_event_and_occurrence_query(occurrence_id, include_deleted, context=context)
230 ).one_or_none()
231 return result._tuple() if result else None
234def _check_location(location: events_pb2.EventLocation | None, context: CouchersContext) -> tuple[WKBElement, str]:
235 # As protobuf parses a missing value as 0.0, this is not a permitted event coordinate value
236 if not location or not location.address:
237 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_address_or_location")
238 if location.lat == 0 and location.lng == 0:
239 # No events allowed on Null Island
240 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
242 geom = create_coordinate(location.lat, location.lng)
243 return (geom, location.address)
246def _check_timezone_at(geom: WKBElement, context: CouchersContext, session: Session) -> ZoneInfo:
247 timezone_id = session.execute(
248 select(TimezoneArea.tzid).where(func.ST_Contains(TimezoneArea.geom, func.ST_PointOnSurface(geom))).limit(1)
249 ).scalar_one_or_none()
250 if not timezone_id: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_timezone_not_found")
253 return ZoneInfo(timezone_id)
256def _check_iso8601_local_datetime(value: str, timezone: ZoneInfo, context: CouchersContext) -> datetime:
257 if not value: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_start_end_datetime")
260 try:
261 naive_datetime = datetime.fromisoformat(value)
262 except ValueError:
263 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_event_start_end_datetime")
265 if naive_datetime.tzinfo is not None: 265 ↛ 267line 265 didn't jump to line 267 because the condition on line 265 was never true
266 # Expected a local datetime, otherwise we have two sources of timezones.
267 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_event_start_end_datetime")
269 return naive_datetime.replace(tzinfo=timezone).replace(second=0, microsecond=0)
272def _update_datetime(
273 new_iso8601_local: str | None,
274 new_timezone: ZoneInfo,
275 old_datetime: datetime,
276 old_timezone: ZoneInfo,
277 context: CouchersContext,
278) -> datetime:
279 if new_iso8601_local is None and new_timezone != old_timezone:
280 # Local time wasn't updated, but the timezone changed so the effective datetime/timestamp may have changed.
281 new_iso8601_local = datetime_to_iso8601_local(old_datetime.astimezone(old_timezone))
282 if new_iso8601_local is None:
283 return old_datetime # No change
284 # New effective datetime/timestamp
285 return _check_iso8601_local_datetime(new_iso8601_local, new_timezone, context)
288def _check_occurrence_time_validity(start_time: datetime, end_time: datetime, context: CouchersContext) -> None:
289 if start_time < now():
290 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_in_past")
291 if end_time < start_time:
292 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_ends_before_starts")
293 if end_time - start_time > timedelta(days=7):
294 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_too_long")
295 if start_time - now() > timedelta(days=365):
296 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_too_far_in_future")
299def apply_occurrence_pagination(
300 query: Select[tuple[EventOccurrence]], page_token: str, past: bool
301) -> Select[tuple[EventOccurrence]]:
302 """
303 Restricts to upcoming (not yet ended) or past occurrences, orders by start time (with id as
304 tiebreaker), and seeks to the (start_time, id) cursor from the page token, if given.
305 """
306 if not past:
307 query = query.where(EventOccurrence.end_time > now()).order_by(
308 EventOccurrence.start_time.asc(), EventOccurrence.id.asc()
309 )
310 if page_token:
311 start_time, occurrence_id = dt_id_from_page_token(page_token)
312 query = query.where(tuple_(EventOccurrence.start_time, EventOccurrence.id) >= (start_time, occurrence_id))
313 else:
314 query = query.where(EventOccurrence.end_time < now()).order_by(
315 EventOccurrence.start_time.desc(), EventOccurrence.id.desc()
316 )
317 if page_token:
318 start_time, occurrence_id = dt_id_from_page_token(page_token)
319 query = query.where(tuple_(EventOccurrence.start_time, EventOccurrence.id) <= (start_time, occurrence_id))
320 return query
323def occurrences_next_page_token(occurrences: Sequence[EventOccurrence], page_size: int) -> str | None:
324 if len(occurrences) <= page_size:
325 return None
326 next_occurrence = occurrences[page_size]
327 return dt_id_to_page_token(next_occurrence.start_time, next_occurrence.id)
330def get_users_to_notify_for_new_event(session: Session, occurrence: EventOccurrence) -> tuple[list[User], int | None]:
331 """
332 Returns the users to notify, as well as the community id that is being notified (None if based on geo search)
333 """
334 # people already attending or organizing the event don't need an invite to it
335 not_already_involved = User.id.not_in(
336 select(EventOccurrenceAttendee.user_id)
337 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
338 .union(select(EventOrganizer.user_id).where(EventOrganizer.event_id == occurrence.event_id))
339 )
341 cluster = occurrence.event.parent_node.official_cluster
342 creator = aliased(User)
343 if occurrence.event.parent_node.node_type.value <= NodeType.region.value:
344 logger.info("Global, macroregion, and region communities are too big for email notifications.")
345 return [], occurrence.event.parent_node_id
346 elif occurrence.creator_user in cluster.admins or cluster.is_leaf: 346 ↛ 361line 346 didn't jump to line 361 because the condition on line 346 was always true
347 members = (
348 session.execute(
349 select(User)
350 .join(ClusterSubscription, ClusterSubscription.user_id == User.id)
351 .join_from(User, creator, creator.id == occurrence.creator_user_id)
352 .where(ClusterSubscription.cluster_id == cluster.id)
353 .where(users_visible_to_each_other(self_user=User, other_user=creator))
354 .where(not_already_involved)
355 )
356 .scalars()
357 .all()
358 )
359 return list(members), occurrence.event.parent_node_id
360 else:
361 max_radius = 20000 # m
362 users = (
363 session.execute(
364 select(User)
365 .join(ClusterSubscription, ClusterSubscription.user_id == User.id)
366 .join_from(User, creator, creator.id == occurrence.creator_user_id)
367 .where(users_visible_to_each_other(self_user=User, other_user=creator))
368 .where(ClusterSubscription.cluster_id == cluster.id)
369 .where(func.ST_DWithin(User.geom, occurrence.geom, max_radius / 111111))
370 .where(not_already_involved)
371 )
372 .scalars()
373 .all()
374 )
375 return cast(tuple[list[User], int | None], (users, None))
378def generate_event_create_notifications(payload: jobs_pb2.GenerateEventCreateNotificationsPayload) -> None:
379 """
380 Background job to generated/fan out event notifications
381 """
382 # Import here to avoid circular dependency
383 from couchers.servicers.communities import community_to_pb # noqa: PLC0415
385 logger.info(f"Fanning out notifications for event occurrence id = {payload.occurrence_id}")
387 with session_scope() as session:
388 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
390 users, node_id = get_users_to_notify_for_new_event(session, occurrence)
392 inviting_user = session.execute(select(User).where(User.id == payload.inviting_user_id)).scalar_one_or_none()
394 if not inviting_user: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true
395 logger.error(f"Inviting user {payload.inviting_user_id} is gone while trying to send event notification?")
396 return
398 for user in users:
399 context = make_notification_user_context(user_id=user.id)
400 topic_action = (
401 NotificationTopicAction.event__create_approved
402 if payload.approved
403 else NotificationTopicAction.event__create_any
404 )
405 notify(
406 session,
407 user_id=user.id,
408 topic_action=topic_action,
409 key=str(payload.occurrence_id),
410 data=notification_data_pb2.EventCreate(
411 event=event_to_pb(session, occurrence, context),
412 inviting_user=user_model_to_pb(inviting_user, session, context),
413 nearby=True if node_id is None else None,
414 in_community=community_to_pb(session, event.parent_node, context) if node_id is not None else None,
415 ),
416 moderation_state_id=occurrence.moderation_state_id,
417 )
420def generate_event_update_notifications(payload: jobs_pb2.GenerateEventUpdateNotificationsPayload) -> None:
421 with session_scope() as session:
422 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
424 updating_user = session.execute(select(User).where(User.id == payload.updating_user_id)).scalar_one()
426 subscribed_user_ids = [user.id for user in event.subscribers]
427 attending_user_ids = [user.user_id for user in occurrence.attendances]
429 for user_id in set(subscribed_user_ids + attending_user_ids) - {updating_user.id}:
430 if is_not_visible(session, user_id, updating_user.id): 430 ↛ 431line 430 didn't jump to line 431 because the condition on line 430 was never true
431 continue
432 context = make_notification_user_context(user_id=user_id)
433 notify(
434 session,
435 user_id=user_id,
436 topic_action=NotificationTopicAction.event__update,
437 key=str(payload.occurrence_id),
438 data=notification_data_pb2.EventUpdate(
439 event=event_to_pb(session, occurrence, context),
440 updating_user=user_model_to_pb(updating_user, session, context),
441 updated_enum_items=(
442 notification_data_pb2.EventUpdateItem.ValueType(value) for value in payload.updated_enum_items
443 ),
444 ),
445 moderation_state_id=occurrence.moderation_state_id,
446 )
449def generate_event_cancel_notifications(payload: jobs_pb2.GenerateEventCancelNotificationsPayload) -> None:
450 with session_scope() as session:
451 event, occurrence = _get_event_and_occurrence_one(session, occurrence_id=payload.occurrence_id)
453 cancelling_user = session.execute(select(User).where(User.id == payload.cancelling_user_id)).scalar_one()
455 subscribed_user_ids = [user.id for user in event.subscribers]
456 attending_user_ids = [user.user_id for user in occurrence.attendances]
458 for user_id in set(subscribed_user_ids + attending_user_ids) - {cancelling_user.id}:
459 if is_not_visible(session, user_id, cancelling_user.id): 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true
460 continue
461 context = make_notification_user_context(user_id=user_id)
462 notify(
463 session,
464 user_id=user_id,
465 topic_action=NotificationTopicAction.event__cancel,
466 key=str(payload.occurrence_id),
467 data=notification_data_pb2.EventCancel(
468 event=event_to_pb(session, occurrence, context),
469 cancelling_user=user_model_to_pb(cancelling_user, session, context),
470 ),
471 moderation_state_id=occurrence.moderation_state_id,
472 )
475def generate_event_delete_notifications(payload: jobs_pb2.GenerateEventDeleteNotificationsPayload) -> None:
476 with session_scope() as session:
477 event, occurrence = _get_event_and_occurrence_one(
478 session, occurrence_id=payload.occurrence_id, include_deleted=True
479 )
481 subscribed_user_ids = [user.id for user in event.subscribers]
482 attending_user_ids = [user.user_id for user in occurrence.attendances]
484 for user_id in set(subscribed_user_ids + attending_user_ids):
485 context = make_notification_user_context(user_id=user_id)
486 notify(
487 session,
488 user_id=user_id,
489 topic_action=NotificationTopicAction.event__delete,
490 key=str(payload.occurrence_id),
491 data=notification_data_pb2.EventDelete(
492 event=event_to_pb(session, occurrence, context),
493 ),
494 moderation_state_id=occurrence.moderation_state_id,
495 )
498class Events(events_pb2_grpc.EventsServicer):
499 def CreateEvent(
500 self, request: events_pb2.CreateEventReq, context: CouchersContext, session: Session
501 ) -> events_pb2.Event:
502 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
503 if not has_completed_profile(session, user):
504 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_event")
505 if not request.title:
506 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_title")
507 if not request.content:
508 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content")
510 geom, address = _check_location(request.location if request.HasField("location") else None, context)
511 timezone = _check_timezone_at(geom, context, session)
512 start_datetime = _check_iso8601_local_datetime(request.start_datetime_iso8601_local, timezone, context)
513 end_datetime = _check_iso8601_local_datetime(request.end_datetime_iso8601_local, timezone, context)
514 _check_occurrence_time_validity(start_datetime, end_datetime, context)
516 if request.parent_community_id:
517 parent_node = session.execute(
518 select(Node).where(Node.id == request.parent_community_id)
519 ).scalar_one_or_none()
521 if not parent_node or not parent_node.official_cluster.small_community_features_enabled: 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true
522 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "events_not_enabled")
523 else:
524 # parent community computed from geom
525 parent_node = get_parent_node_at_location(session, not_none(geom))
527 if not parent_node: 527 ↛ 528line 527 didn't jump to line 528 because the condition on line 527 was never true
528 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "community_not_found")
530 if (
531 request.photo_key
532 and not session.execute(select(Upload).where(Upload.key == request.photo_key)).scalar_one_or_none()
533 ):
534 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found")
536 thread = Thread()
537 session.add(thread)
538 session.flush()
540 event = Event(
541 title=request.title,
542 parent_node_id=parent_node.id,
543 owner_user_id=context.user_id,
544 thread_id=thread.id,
545 creator_user_id=context.user_id,
546 )
547 session.add(event)
548 session.flush()
550 occurrence: EventOccurrence | None = None
552 def create_occurrence(moderation_state_id: int) -> int:
553 nonlocal occurrence
554 occurrence = EventOccurrence(
555 event_id=event.id,
556 content=request.content,
557 geom=geom,
558 address=address,
559 timezone=timezone.key,
560 photo_key=request.photo_key if request.photo_key != "" else None,
561 during=TimestamptzRange(start_datetime, end_datetime),
562 creator_user_id=context.user_id,
563 moderation_state_id=moderation_state_id,
564 )
565 session.add(occurrence)
566 session.flush()
567 return occurrence.id
569 create_moderation(
570 session=session,
571 object_type=ModerationObjectType.event_occurrence,
572 object_id=create_occurrence,
573 creator_user_id=context.user_id,
574 )
576 assert occurrence is not None
578 session.add(
579 EventOrganizer(
580 user_id=context.user_id,
581 event_id=event.id,
582 )
583 )
585 session.add(
586 EventSubscription(
587 user_id=context.user_id,
588 event_id=event.id,
589 )
590 )
592 session.add(
593 EventOccurrenceAttendee(
594 user_id=context.user_id,
595 occurrence_id=occurrence.id,
596 attendee_status=AttendeeStatus.going,
597 )
598 )
600 session.commit()
602 log_event(
603 context,
604 session,
605 "event.created",
606 {
607 "event_id": event.id,
608 "occurrence_id": occurrence.id,
609 "parent_community_id": parent_node.id,
610 "parent_community_name": parent_node.official_cluster.name,
611 },
612 )
614 if has_completed_profile(session, user): 614 ↛ 625line 614 didn't jump to line 625 because the condition on line 614 was always true
615 queue_job(
616 session,
617 job=generate_event_create_notifications,
618 payload=jobs_pb2.GenerateEventCreateNotificationsPayload(
619 inviting_user_id=user.id,
620 occurrence_id=occurrence.id,
621 approved=False,
622 ),
623 )
625 return event_to_pb(session, occurrence, context)
627 def ScheduleEvent(
628 self, request: events_pb2.ScheduleEventReq, context: CouchersContext, session: Session
629 ) -> events_pb2.Event:
630 if not request.content: 630 ↛ 631line 630 didn't jump to line 631 because the condition on line 630 was never true
631 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_event_content")
633 geom, address = _check_location(request.location if request.HasField("location") else None, context)
634 timezone = _check_timezone_at(geom, context, session)
635 start_datetime = _check_iso8601_local_datetime(request.start_datetime_iso8601_local, timezone, context)
636 end_datetime = _check_iso8601_local_datetime(request.end_datetime_iso8601_local, timezone, context)
637 _check_occurrence_time_validity(start_datetime, end_datetime, context)
639 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
640 if not res: 640 ↛ 641line 640 didn't jump to line 641 because the condition on line 640 was never true
641 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
643 event, occurrence = res
645 if not _can_edit_event(session, event, context.user_id): 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true
646 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
648 if occurrence.is_cancelled: 648 ↛ 649line 648 didn't jump to line 649 because the condition on line 648 was never true
649 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
651 if ( 651 ↛ 655line 651 didn't jump to line 655 because the condition on line 651 was never true
652 request.photo_key
653 and not session.execute(select(Upload).where(Upload.key == request.photo_key)).scalar_one_or_none()
654 ):
655 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "photo_not_found")
657 during = TimestamptzRange(start_datetime, end_datetime)
659 # && is the overlap operator for ranges
660 if (
661 session.execute(
662 select(EventOccurrence.id)
663 .where(EventOccurrence.event_id == event.id)
664 .where(EventOccurrence.during.op("&&")(during))
665 .limit(1)
666 )
667 .scalars()
668 .one_or_none()
669 is not None
670 ):
671 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_overlap")
673 new_occurrence: EventOccurrence | None = None
675 def create_occurrence(moderation_state_id: int) -> int:
676 nonlocal new_occurrence
677 new_occurrence = EventOccurrence(
678 event_id=event.id,
679 content=request.content,
680 geom=geom,
681 address=address,
682 timezone=timezone.key,
683 photo_key=request.photo_key if request.photo_key != "" else None,
684 during=during,
685 creator_user_id=context.user_id,
686 moderation_state_id=moderation_state_id,
687 )
688 session.add(new_occurrence)
689 session.flush()
690 return new_occurrence.id
692 create_moderation(
693 session=session,
694 object_type=ModerationObjectType.event_occurrence,
695 object_id=create_occurrence,
696 creator_user_id=context.user_id,
697 )
699 assert new_occurrence is not None
701 session.add(
702 EventOccurrenceAttendee(
703 user_id=context.user_id,
704 occurrence_id=new_occurrence.id,
705 attendee_status=AttendeeStatus.going,
706 )
707 )
709 session.flush()
711 # TODO: notify
713 return event_to_pb(session, new_occurrence, context)
715 def UpdateEvent(
716 self, request: events_pb2.UpdateEventReq, context: CouchersContext, session: Session
717 ) -> events_pb2.Event:
718 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
719 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
720 if not res: 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.NOT_FOUND, "event_not_found")
723 event, occurrence = res
725 if not _can_edit_event(session, event, context.user_id): 725 ↛ 726line 725 didn't jump to line 726 because the condition on line 725 was never true
726 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
728 # the things that were updated and need to be notified about
729 notify_updated: list[notification_data_pb2.EventUpdateItem.ValueType] = []
731 if occurrence.is_cancelled:
732 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
734 occurrence_update: dict[str, Any] = {"last_edited": now()}
736 if request.HasField("title"):
737 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE)
738 event.title = request.title.value
740 if request.HasField("content"):
741 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT)
742 occurrence_update["content"] = request.content.value
744 if request.HasField("photo_key"): 744 ↛ 745line 744 didn't jump to line 745 because the condition on line 744 was never true
745 occurrence_update["photo_key"] = request.photo_key.value
747 old_timezone = ZoneInfo(occurrence.timezone)
748 timezone: ZoneInfo = old_timezone
749 if request.HasField("location"):
750 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION)
751 geom, address = _check_location(request.location, context)
752 timezone = _check_timezone_at(geom, context, session)
753 occurrence_update["geom"] = geom
754 occurrence_update["address"] = address
755 occurrence_update["timezone"] = timezone.key
757 if timezone != old_timezone and request.update_all_future: 757 ↛ 759line 757 didn't jump to line 759 because the condition on line 757 was never true
758 # Not implemented: We'd need to change and recheck the datetimes on all existing occurrences
759 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "event_cant_update_all_times")
761 # Determine the new start/end datetimes, which may have changed explicitly or because of a timezone change
762 start_datetime = _update_datetime(
763 request.start_datetime_iso8601_local.value if request.HasField("start_datetime_iso8601_local") else None,
764 timezone,
765 old_datetime=occurrence.start_time,
766 old_timezone=old_timezone,
767 context=context,
768 )
769 if start_datetime != occurrence.start_time:
770 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME)
772 end_datetime = _update_datetime(
773 request.end_datetime_iso8601_local.value if request.HasField("end_datetime_iso8601_local") else None,
774 timezone,
775 old_datetime=occurrence.end_time,
776 old_timezone=old_timezone,
777 context=context,
778 )
779 if end_datetime != occurrence.end_time:
780 notify_updated.append(notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME)
782 if start_datetime != occurrence.start_time or end_datetime != occurrence.end_time:
783 _check_occurrence_time_validity(start_datetime, end_datetime, context)
785 during = TimestamptzRange(start_datetime, end_datetime)
787 # && is the overlap operator for ranges
788 if (
789 session.execute(
790 select(EventOccurrence.id)
791 .where(EventOccurrence.event_id == event.id)
792 .where(EventOccurrence.id != occurrence.id)
793 .where(EventOccurrence.during.op("&&")(during))
794 .limit(1)
795 )
796 .scalars()
797 .one_or_none()
798 is not None
799 ):
800 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_overlap")
802 occurrence_update["during"] = during
804 # allow editing any event which hasn't ended more than 24 hours before now
805 # when editing all future events, we edit all which have not yet ended
807 cutoff_time = now() - timedelta(hours=24)
808 if request.update_all_future:
809 session.execute(
810 update(EventOccurrence)
811 .where(EventOccurrence.end_time >= cutoff_time)
812 .where(EventOccurrence.start_time >= occurrence.start_time)
813 .values(occurrence_update)
814 .execution_options(synchronize_session=False)
815 )
816 else:
817 if occurrence.end_time < cutoff_time: 817 ↛ 818line 817 didn't jump to line 818 because the condition on line 817 was never true
818 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
819 session.execute(
820 update(EventOccurrence)
821 .where(EventOccurrence.end_time >= cutoff_time)
822 .where(EventOccurrence.id == occurrence.id)
823 .values(occurrence_update)
824 .execution_options(synchronize_session=False)
825 )
827 session.flush()
829 if notify_updated:
830 items_str = ",".join(notification_data_pb2.EventUpdateItem.Name(item) for item in notify_updated)
831 if request.should_notify:
832 logger.info(f"Items {items_str} updated in event {event.id=}, notifying")
834 queue_job(
835 session,
836 job=generate_event_update_notifications,
837 payload=jobs_pb2.GenerateEventUpdateNotificationsPayload(
838 updating_user_id=user.id,
839 occurrence_id=occurrence.id,
840 updated_enum_items=notify_updated,
841 ),
842 )
843 else:
844 logger.info(f"Items {items_str} updated in event {event.id=}, but skipping notifications")
846 # since we have synchronize_session=False, we have to refresh the object
847 session.refresh(occurrence)
849 return event_to_pb(session, occurrence, context)
851 def GetEvent(self, request: events_pb2.GetEventReq, context: CouchersContext, session: Session) -> events_pb2.Event:
852 query = select(EventOccurrence).where(EventOccurrence.id == request.event_id).where(~EventOccurrence.is_deleted)
853 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=False)
854 occurrence = session.execute(query).scalar_one_or_none()
856 if not occurrence:
857 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
859 return event_to_pb(session, occurrence, context)
861 def CancelEvent(
862 self, request: events_pb2.CancelEventReq, context: CouchersContext, session: Session
863 ) -> empty_pb2.Empty:
864 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
865 if not res: 865 ↛ 866line 865 didn't jump to line 866 because the condition on line 865 was never true
866 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
868 event, occurrence = res
870 if not _can_edit_event(session, event, context.user_id): 870 ↛ 871line 870 didn't jump to line 871 because the condition on line 870 was never true
871 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
873 if occurrence.end_time < now() - timedelta(hours=24): 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_cancel_old_event")
876 occurrence.is_cancelled = True
878 log_event(context, session, "event.cancelled", {"event_id": event.id, "occurrence_id": occurrence.id})
880 queue_job(
881 session,
882 job=generate_event_cancel_notifications,
883 payload=jobs_pb2.GenerateEventCancelNotificationsPayload(
884 cancelling_user_id=context.user_id,
885 occurrence_id=occurrence.id,
886 ),
887 )
889 return empty_pb2.Empty()
891 def RequestCommunityInvite(
892 self, request: events_pb2.RequestCommunityInviteReq, context: CouchersContext, session: Session
893 ) -> empty_pb2.Empty:
894 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
895 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
896 if not res: 896 ↛ 897line 896 didn't jump to line 897 because the condition on line 896 was never true
897 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
899 event, occurrence = res
901 if not _can_edit_event(session, event, context.user_id):
902 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
904 if occurrence.is_cancelled: 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.PERMISSION_DENIED, "event_cant_update_cancelled_event")
907 if occurrence.end_time < now() - timedelta(hours=24): 907 ↛ 908line 907 didn't jump to line 908 because the condition on line 907 was never true
908 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
910 this_user_reqs = [req for req in occurrence.community_invite_requests if req.user_id == context.user_id]
912 if len(this_user_reqs) > 0:
913 context.abort_with_error_code(
914 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_requested"
915 )
917 approved_reqs = [req for req in occurrence.community_invite_requests if req.approved]
919 if len(approved_reqs) > 0:
920 context.abort_with_error_code(
921 grpc.StatusCode.FAILED_PRECONDITION, "event_community_invite_already_approved"
922 )
924 req = EventCommunityInviteRequest(
925 occurrence_id=request.event_id,
926 user_id=context.user_id,
927 )
928 session.add(req)
929 session.flush()
931 send_event_community_invite_request_email(session, req)
933 return empty_pb2.Empty()
935 def ListEventOccurrences(
936 self, request: events_pb2.ListEventOccurrencesReq, context: CouchersContext, session: Session
937 ) -> events_pb2.ListEventOccurrencesRes:
938 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
939 initial_query = (
940 select(EventOccurrence).where(EventOccurrence.id == request.event_id).where(~EventOccurrence.is_deleted)
941 )
942 initial_query = where_moderated_content_visible(
943 initial_query, context, EventOccurrence, is_list_operation=False
944 )
945 occurrence = session.execute(initial_query).scalar_one_or_none()
946 if not occurrence: 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true
947 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
949 query = (
950 select(EventOccurrence)
951 .where(EventOccurrence.event_id == occurrence.event_id)
952 .where(~EventOccurrence.is_deleted)
953 )
954 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
956 if not request.include_cancelled:
957 query = query.where(~EventOccurrence.is_cancelled)
959 query = apply_occurrence_pagination(query, request.page_token, request.past)
961 query = query.limit(page_size + 1)
962 occurrences = session.execute(query).scalars().all()
964 return events_pb2.ListEventOccurrencesRes(
965 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
966 next_page_token=occurrences_next_page_token(occurrences, page_size),
967 )
969 def ListEventAttendees(
970 self, request: events_pb2.ListEventAttendeesReq, context: CouchersContext, session: Session
971 ) -> events_pb2.ListEventAttendeesRes:
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 occurrence = session.execute(
975 where_moderated_content_visible(
976 select(EventOccurrence)
977 .where(EventOccurrence.id == request.event_id)
978 .where(~EventOccurrence.is_deleted),
979 context,
980 EventOccurrence,
981 is_list_operation=False,
982 )
983 ).scalar_one_or_none()
984 if not occurrence: 984 ↛ 985line 984 didn't jump to line 985 because the condition on line 984 was never true
985 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
986 attendees = (
987 session.execute(
988 where_users_column_visible(
989 select(EventOccurrenceAttendee)
990 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
991 .where(EventOccurrenceAttendee.user_id >= next_user_id)
992 .order_by(EventOccurrenceAttendee.user_id)
993 .limit(page_size + 1),
994 context,
995 EventOccurrenceAttendee.user_id,
996 )
997 )
998 .scalars()
999 .all()
1000 )
1001 return events_pb2.ListEventAttendeesRes(
1002 attendee_user_ids=[attendee.user_id for attendee in attendees[:page_size]],
1003 next_page_token=str(attendees[-1].user_id) if len(attendees) > page_size else None,
1004 )
1006 def ListEventSubscribers(
1007 self, request: events_pb2.ListEventSubscribersReq, context: CouchersContext, session: Session
1008 ) -> events_pb2.ListEventSubscribersRes:
1009 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1010 next_user_id = int(request.page_token) if request.page_token else 0
1011 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1012 if not res: 1012 ↛ 1013line 1012 didn't jump to line 1013 because the condition on line 1012 was never true
1013 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1014 event, occurrence = res
1015 subscribers = (
1016 session.execute(
1017 where_users_column_visible(
1018 select(EventSubscription)
1019 .where(EventSubscription.event_id == event.id)
1020 .where(EventSubscription.user_id >= next_user_id)
1021 .order_by(EventSubscription.user_id)
1022 .limit(page_size + 1),
1023 context,
1024 EventSubscription.user_id,
1025 )
1026 )
1027 .scalars()
1028 .all()
1029 )
1030 return events_pb2.ListEventSubscribersRes(
1031 subscriber_user_ids=[subscriber.user_id for subscriber in subscribers[:page_size]],
1032 next_page_token=str(subscribers[-1].user_id) if len(subscribers) > page_size else None,
1033 )
1035 def ListEventOrganizers(
1036 self, request: events_pb2.ListEventOrganizersReq, context: CouchersContext, session: Session
1037 ) -> events_pb2.ListEventOrganizersRes:
1038 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1039 next_user_id = int(request.page_token) if request.page_token else 0
1040 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1041 if not res: 1041 ↛ 1042line 1041 didn't jump to line 1042 because the condition on line 1041 was never true
1042 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1043 event, occurrence = res
1044 organizers = (
1045 session.execute(
1046 where_users_column_visible(
1047 select(EventOrganizer)
1048 .where(EventOrganizer.event_id == event.id)
1049 .where(EventOrganizer.user_id >= next_user_id)
1050 .order_by(EventOrganizer.user_id)
1051 .limit(page_size + 1),
1052 context,
1053 EventOrganizer.user_id,
1054 )
1055 )
1056 .scalars()
1057 .all()
1058 )
1059 return events_pb2.ListEventOrganizersRes(
1060 organizer_user_ids=[organizer.user_id for organizer in organizers[:page_size]],
1061 next_page_token=str(organizers[-1].user_id) if len(organizers) > page_size else None,
1062 )
1064 def TransferEvent(
1065 self, request: events_pb2.TransferEventReq, context: CouchersContext, session: Session
1066 ) -> events_pb2.Event:
1067 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1068 if not res: 1068 ↛ 1069line 1068 didn't jump to line 1069 because the condition on line 1068 was never true
1069 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1071 event, occurrence = res
1073 if not _can_edit_event(session, event, context.user_id):
1074 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_transfer_permission_denied")
1076 if occurrence.is_cancelled:
1077 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1079 if occurrence.end_time < now() - timedelta(hours=24): 1079 ↛ 1080line 1079 didn't jump to line 1080 because the condition on line 1079 was never true
1080 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1082 if request.WhichOneof("new_owner") == "new_owner_group_id":
1083 cluster = session.execute(
1084 select(Cluster).where(~Cluster.is_official_cluster).where(Cluster.id == request.new_owner_group_id)
1085 ).scalar_one_or_none()
1086 elif request.WhichOneof("new_owner") == "new_owner_community_id": 1086 ↛ 1093line 1086 didn't jump to line 1093 because the condition on line 1086 was always true
1087 cluster = session.execute(
1088 select(Cluster)
1089 .where(Cluster.parent_node_id == request.new_owner_community_id)
1090 .where(Cluster.is_official_cluster)
1091 ).scalar_one_or_none()
1093 if not cluster: 1093 ↛ 1094line 1093 didn't jump to line 1094 because the condition on line 1093 was never true
1094 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "group_or_community_not_found")
1096 event.owner_user = None
1097 event.owner_cluster = cluster
1099 session.commit()
1100 return event_to_pb(session, occurrence, context)
1102 def SetEventSubscription(
1103 self, request: events_pb2.SetEventSubscriptionReq, context: CouchersContext, session: Session
1104 ) -> events_pb2.Event:
1105 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1106 if not res: 1106 ↛ 1107line 1106 didn't jump to line 1107 because the condition on line 1106 was never true
1107 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1109 event, occurrence = res
1111 if occurrence.is_cancelled:
1112 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1114 if occurrence.end_time < now() - timedelta(hours=24): 1114 ↛ 1115line 1114 didn't jump to line 1115 because the condition on line 1114 was never true
1115 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1117 current_subscription = session.execute(
1118 select(EventSubscription)
1119 .where(EventSubscription.user_id == context.user_id)
1120 .where(EventSubscription.event_id == event.id)
1121 ).scalar_one_or_none()
1123 # if not subscribed, subscribe
1124 if request.subscribe and not current_subscription:
1125 session.add(EventSubscription(user_id=context.user_id, event_id=event.id))
1127 # if subscribed but unsubbing, remove subscription
1128 if not request.subscribe and current_subscription:
1129 session.delete(current_subscription)
1131 session.flush()
1133 log_event(
1134 context,
1135 session,
1136 "event.subscription_set",
1137 {"event_id": event.id, "occurrence_id": occurrence.id, "subscribed": request.subscribe},
1138 )
1140 return event_to_pb(session, occurrence, context)
1142 def SetEventAttendance(
1143 self, request: events_pb2.SetEventAttendanceReq, context: CouchersContext, session: Session
1144 ) -> events_pb2.Event:
1145 occurrence = session.execute(
1146 where_moderated_content_visible(
1147 select(EventOccurrence)
1148 .where(EventOccurrence.id == request.event_id)
1149 .where(~EventOccurrence.is_deleted),
1150 context,
1151 EventOccurrence,
1152 is_list_operation=False,
1153 )
1154 ).scalar_one_or_none()
1156 if not occurrence: 1156 ↛ 1157line 1156 didn't jump to line 1157 because the condition on line 1156 was never true
1157 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1159 if occurrence.is_cancelled:
1160 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1162 if occurrence.end_time < now() - timedelta(hours=24): 1162 ↛ 1163line 1162 didn't jump to line 1163 because the condition on line 1162 was never true
1163 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1165 current_attendance = session.execute(
1166 select(EventOccurrenceAttendee)
1167 .where(EventOccurrenceAttendee.user_id == context.user_id)
1168 .where(EventOccurrenceAttendee.occurrence_id == occurrence.id)
1169 ).scalar_one_or_none()
1171 if request.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING:
1172 if current_attendance: 1172 ↛ 1187line 1172 didn't jump to line 1187 because the condition on line 1172 was always true
1173 session.delete(current_attendance)
1174 # if unset/not going, nothing to do!
1175 else:
1176 if current_attendance: 1176 ↛ 1177line 1176 didn't jump to line 1177 because the condition on line 1176 was never true
1177 current_attendance.attendee_status = attendancestate2sql[request.attendance_state] # type: ignore[assignment]
1178 else:
1179 # create new
1180 attendance = EventOccurrenceAttendee(
1181 user_id=context.user_id,
1182 occurrence_id=occurrence.id,
1183 attendee_status=not_none(attendancestate2sql[request.attendance_state]),
1184 )
1185 session.add(attendance)
1187 session.flush()
1189 log_event(
1190 context,
1191 session,
1192 "event.attendance_set",
1193 {"occurrence_id": occurrence.id, "attendance_state": request.attendance_state},
1194 )
1196 return event_to_pb(session, occurrence, context)
1198 def ListMyEvents(
1199 self, request: events_pb2.ListMyEventsReq, context: CouchersContext, session: Session
1200 ) -> events_pb2.ListMyEventsRes:
1201 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1202 # the page token is ignored when a page number is given
1203 page_token = request.page_token if not request.page_number else ""
1204 # the page number is the page number we are on
1205 page_number = request.page_number or 1
1206 # Calculate the offset for pagination
1207 offset = (page_number - 1) * page_size
1208 query = (
1209 select(EventOccurrence).join(Event, Event.id == EventOccurrence.event_id).where(~EventOccurrence.is_deleted)
1210 )
1211 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
1213 include_all = not (request.subscribed or request.attending or request.organizing or request.my_communities)
1214 include_subscribed = request.subscribed or include_all
1215 include_organizing = request.organizing or include_all
1216 include_attending = request.attending or include_all
1217 include_my_communities = request.my_communities or include_all
1219 if include_attending and request.exclude_attending:
1220 context.abort_with_error_code(
1221 grpc.StatusCode.INVALID_ARGUMENT, "cannot_combine_attending_and_exclude_attending"
1222 )
1224 where_ = []
1226 if include_subscribed:
1227 query = query.outerjoin(
1228 EventSubscription,
1229 and_(EventSubscription.event_id == Event.id, EventSubscription.user_id == context.user_id),
1230 )
1231 where_.append(EventSubscription.user_id != None)
1232 if include_organizing:
1233 query = query.outerjoin(
1234 EventOrganizer, and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id)
1235 )
1236 where_.append(EventOrganizer.user_id != None)
1237 if include_attending or request.exclude_attending:
1238 query = query.outerjoin(
1239 EventOccurrenceAttendee,
1240 and_(
1241 EventOccurrenceAttendee.occurrence_id == EventOccurrence.id,
1242 EventOccurrenceAttendee.user_id == context.user_id,
1243 ),
1244 )
1245 if include_attending:
1246 where_.append(EventOccurrenceAttendee.user_id != None)
1247 elif request.exclude_attending: 1247 ↛ 1254line 1247 didn't jump to line 1254 because the condition on line 1247 was always true
1248 if not include_organizing: 1248 ↛ 1253line 1248 didn't jump to line 1253 because the condition on line 1248 was always true
1249 query = query.outerjoin(
1250 EventOrganizer,
1251 and_(EventOrganizer.event_id == Event.id, EventOrganizer.user_id == context.user_id),
1252 )
1253 query = query.where(EventOccurrenceAttendee.user_id == None, EventOrganizer.user_id == None)
1254 if include_my_communities:
1255 my_communities = (
1256 session.execute(
1257 select(Node.id)
1258 .join(Cluster, Cluster.parent_node_id == Node.id)
1259 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id)
1260 .where(ClusterSubscription.user_id == context.user_id)
1261 .where(Cluster.is_official_cluster)
1262 .order_by(Node.id)
1263 .limit(100000)
1264 )
1265 .scalars()
1266 .all()
1267 )
1268 where_.append(Event.parent_node_id.in_(my_communities))
1270 query = query.where(or_(*where_))
1272 if request.my_communities_exclude_global:
1273 query = query.join(Node, Node.id == Event.parent_node_id).where(Node.node_type > NodeType.region)
1275 if not request.include_cancelled:
1276 query = query.where(~EventOccurrence.is_cancelled)
1278 query = apply_occurrence_pagination(query, page_token, request.past)
1279 # Count the total number of items for pagination
1280 total_items = session.execute(select(func.count()).select_from(query.subquery())).scalar()
1281 # Apply pagination by page number
1282 query = query.offset(offset).limit(page_size) if request.page_number else query.limit(page_size + 1)
1283 occurrences = session.execute(query).scalars().all()
1285 return events_pb2.ListMyEventsRes(
1286 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
1287 next_page_token=occurrences_next_page_token(occurrences, page_size),
1288 total_items=total_items,
1289 )
1291 def ListAllEvents(
1292 self, request: events_pb2.ListAllEventsReq, context: CouchersContext, session: Session
1293 ) -> events_pb2.ListAllEventsRes:
1294 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
1296 query = select(EventOccurrence).where(~EventOccurrence.is_deleted)
1297 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
1299 if not request.include_cancelled: 1299 ↛ 1302line 1299 didn't jump to line 1302 because the condition on line 1299 was always true
1300 query = query.where(~EventOccurrence.is_cancelled)
1302 query = apply_occurrence_pagination(query, request.page_token, request.past)
1304 query = query.limit(page_size + 1)
1305 occurrences = session.execute(query).scalars().all()
1307 return events_pb2.ListAllEventsRes(
1308 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
1309 next_page_token=occurrences_next_page_token(occurrences, page_size),
1310 )
1312 def InviteEventOrganizer(
1313 self, request: events_pb2.InviteEventOrganizerReq, context: CouchersContext, session: Session
1314 ) -> empty_pb2.Empty:
1315 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
1316 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1317 if not res: 1317 ↛ 1318line 1317 didn't jump to line 1318 because the condition on line 1317 was never true
1318 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1320 event, occurrence = res
1322 if not _can_edit_event(session, event, context.user_id):
1323 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_edit_permission_denied")
1325 if occurrence.is_cancelled:
1326 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1328 if occurrence.end_time < now() - timedelta(hours=24): 1328 ↛ 1329line 1328 didn't jump to line 1329 because the condition on line 1328 was never true
1329 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1331 if not session.execute( 1331 ↛ 1334line 1331 didn't jump to line 1334 because the condition on line 1331 was never true
1332 select(User).where(users_visible(context)).where(User.id == request.user_id)
1333 ).scalar_one_or_none():
1334 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
1336 session.add(
1337 EventOrganizer(
1338 user_id=request.user_id,
1339 event_id=event.id,
1340 )
1341 )
1342 session.flush()
1344 other_user_context = make_notification_user_context(user_id=request.user_id)
1346 notify(
1347 session,
1348 user_id=request.user_id,
1349 topic_action=NotificationTopicAction.event__invite_organizer,
1350 key=str(event.id),
1351 data=notification_data_pb2.EventInviteOrganizer(
1352 event=event_to_pb(session, occurrence, other_user_context),
1353 inviting_user=user_model_to_pb(user, session, other_user_context),
1354 ),
1355 )
1357 return empty_pb2.Empty()
1359 def RemoveEventOrganizer(
1360 self, request: events_pb2.RemoveEventOrganizerReq, context: CouchersContext, session: Session
1361 ) -> empty_pb2.Empty:
1362 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1363 if not res: 1363 ↛ 1364line 1363 didn't jump to line 1364 because the condition on line 1363 was never true
1364 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1366 event, occurrence = res
1368 if occurrence.is_cancelled: 1368 ↛ 1369line 1368 didn't jump to line 1369 because the condition on line 1368 was never true
1369 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "event_cant_update_cancelled_event")
1371 if occurrence.end_time < now() - timedelta(hours=24): 1371 ↛ 1372line 1371 didn't jump to line 1372 because the condition on line 1371 was never true
1372 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_update_old_event")
1374 # Determine which user to remove
1375 user_id_to_remove = request.user_id.value if request.HasField("user_id") else context.user_id
1377 # Check if the target user is the event owner (only after permission check)
1378 if event.owner_user_id == user_id_to_remove:
1379 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_cant_remove_owner_as_organizer")
1381 # Check permissions: either an organizer removing an organizer OR you're the event owner
1382 if not _can_edit_event(session, event, context.user_id):
1383 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_edit_permission_denied")
1385 # Find the organizer to remove
1386 organizer_to_remove = session.execute(
1387 select(EventOrganizer)
1388 .where(EventOrganizer.user_id == user_id_to_remove)
1389 .where(EventOrganizer.event_id == event.id)
1390 ).scalar_one_or_none()
1392 if not organizer_to_remove: 1392 ↛ 1393line 1392 didn't jump to line 1393 because the condition on line 1392 was never true
1393 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "event_not_an_organizer")
1395 session.delete(organizer_to_remove)
1397 return empty_pb2.Empty()
1399 def GetEventCalendarFile(
1400 self, request: events_pb2.GetEventCalendarFileReq, context: CouchersContext, session: Session
1401 ) -> httpbody_pb2.HttpBody:
1402 res = _get_event_and_occurrence_one_or_none(session, occurrence_id=request.event_id, context=context)
1403 if not res: 1403 ↛ 1404line 1403 didn't jump to line 1404 because the condition on line 1403 was never true
1404 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found")
1406 _, occurrence_db = res
1408 event_pb = event_to_pb(session, occurrence_db, context)
1409 ics_data = create_event_ics_calendar(event_pb, context.localization).serialize().encode("utf-8")
1410 return httpbody_pb2.HttpBody(content_type="text/calendar", data=ics_data)