Coverage for app/backend/src/couchers/models/events.py: 99%
150 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1import enum
2from datetime import date, datetime, time
3from typing import TYPE_CHECKING, cast
5from geoalchemy2 import Geometry
6from psycopg.types.range import TimestamptzRange
7from sqlalchemy import (
8 BigInteger,
9 Boolean,
10 CheckConstraint,
11 Date,
12 DateTime,
13 Enum,
14 ForeignKey,
15 Index,
16 Integer,
17 String,
18 Time,
19 UniqueConstraint,
20 and_,
21 func,
22)
23from sqlalchemy.dialects.postgresql import TSTZRANGE, ExcludeConstraint
24from sqlalchemy.ext.hybrid import hybrid_property
25from sqlalchemy.orm import DynamicMapped, Mapped, backref, column_property, mapped_column, relationship
26from sqlalchemy.sql import expression
27from sqlalchemy.sql.elements import ColumnElement
29from couchers.models.base import Base, Geom, communities_seq
30from couchers.models.moderation import ModerationObjectType
31from couchers.utils import get_coordinates
33if TYPE_CHECKING:
34 from couchers.models import Cluster, Node, Thread, Upload, User
35 from couchers.models.moderation import ModerationState
38class ClusterEventAssociation(Base, kw_only=True):
39 """
40 events related to clusters
41 """
43 __tablename__ = "cluster_event_associations"
44 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),)
46 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
48 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
49 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
51 event: Mapped[Event] = relationship(init=False, backref="cluster_event_associations")
52 cluster: Mapped[Cluster] = relationship(init=False, backref="cluster_event_associations")
55class Event(Base, kw_only=True):
56 """
57 An event is composed of two parts:
59 * An event template (Event)
60 * An occurrence (EventOccurrence)
62 One-off events will have one of each; repeating events will have one Event,
63 multiple EventOccurrences, one for each time the event happens.
64 """
66 __tablename__ = "events"
68 id: Mapped[int] = mapped_column(
69 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
70 )
71 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
73 title: Mapped[str] = mapped_column(String)
75 slug: Mapped[str] = column_property(func.slugify(title))
77 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
78 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
79 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), index=True, default=None)
80 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), index=True, default=None)
82 parent_node: Mapped[Node] = relationship(
83 init=False, backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id"
84 )
85 subscribers: DynamicMapped[User] = relationship(
86 init=False, backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True
87 )
88 organizers: DynamicMapped[User] = relationship(
89 init=False, backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True
90 )
91 creator_user: Mapped[User] = relationship(
92 init=False, backref="created_events", foreign_keys="Event.creator_user_id"
93 )
94 owner_user: Mapped[User | None] = relationship(
95 init=False, backref="owned_events", foreign_keys="Event.owner_user_id"
96 )
97 owner_cluster: Mapped[Cluster | None] = relationship(
98 init=False,
99 backref=backref("owned_events", lazy="dynamic"),
100 uselist=False,
101 foreign_keys="Event.owner_cluster_id",
102 )
103 occurrences: DynamicMapped[EventOccurrence] = relationship(init=False, lazy="dynamic")
104 recurrence: Mapped[EventRecurrence | None] = relationship(init=False, back_populates="event", uselist=False)
106 __table_args__ = (
107 # Only one of owner_user and owner_cluster should be set
108 CheckConstraint(
109 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
110 name="one_owner",
111 ),
112 )
115class EventOccurrence(Base, kw_only=True):
116 __tablename__ = "event_occurrences"
117 __moderation_author_column__ = "creator_user_id"
118 __moderation_object_type__ = ModerationObjectType.event_occurrence
119 __moderation_has_own_visibility_mechanism__ = False
121 id: Mapped[int] = mapped_column(
122 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
123 )
124 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
125 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
126 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
128 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event)
129 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
130 content: Mapped[str] = mapped_column(String) # CommonMark without images
131 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), default=None)
133 is_cancelled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
134 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
136 # The GPS coordinates of the event location
137 geom: Mapped[Geom] = mapped_column(Geometry(geometry_type="POINT", srid=4326))
138 # The physical address string. Legacy online events have been migrated to put the link in here.
139 address: Mapped[str] = mapped_column(String)
141 # IANA timezone identifier of the event
142 timezone: Mapped[str] = mapped_column(String)
144 # time during which the event takes place; this is a range type (instead of separate start+end times) which
145 # simplifies database constraints, etc
146 during: Mapped[TimestamptzRange] = mapped_column(TSTZRANGE)
148 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
149 last_edited: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
151 creator_user: Mapped[User] = relationship(
152 init=False, backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id"
153 )
154 thread: Mapped[Thread] = relationship(init=False, backref="event_occurrence", uselist=False)
155 event: Mapped[Event] = relationship(
156 init=False,
157 back_populates="occurrences",
158 remote_side="Event.id",
159 foreign_keys="EventOccurrence.event_id",
160 )
162 photo: Mapped[Upload | None] = relationship(init=False)
163 attendances: DynamicMapped[EventOccurrenceAttendee] = relationship(
164 init=False, back_populates="occurrence", lazy="dynamic"
165 )
166 community_invite_requests: DynamicMapped[EventCommunityInviteRequest] = relationship(
167 init=False, back_populates="occurrence", lazy="dynamic"
168 )
169 moderation_state: Mapped[ModerationState] = relationship(init=False)
171 __table_args__ = (
172 # Can't have overlapping occurrences in the same Event
173 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"),
174 )
176 @property
177 def coordinates(self) -> tuple[float, float]:
178 # returns (lat, lng) or None
179 return get_coordinates(self.geom)
181 @hybrid_property
182 def start_time(self) -> datetime:
183 return cast(datetime, self.during.lower)
185 @start_time.inplace.expression
186 @classmethod
187 def _start_time_expression(cls) -> ColumnElement[datetime]:
188 return cast(ColumnElement[datetime], func.lower(cls.during))
190 @hybrid_property
191 def end_time(self) -> datetime:
192 return cast(datetime, self.during.upper)
194 @end_time.inplace.expression
195 @classmethod
196 def _end_time_expression(cls) -> ColumnElement[datetime]:
197 return cast(ColumnElement[datetime], func.upper(cls.during))
200class EventRecurrence(Base, kw_only=True):
201 """
202 Defines a template for future event occurrences and a recurrence rule for its parent Event.
204 The recurrence rule is based on a subset of the time-tested iCalendar RRULE standard (RFC 5545).
205 Docs: https://icalendar.org/iCalendar-RFC-5545/3-3-10-recurrence-rule.html
206 Online demo: https://jkbrzt.github.io/rrule/
208 We use RRULEs for generating dates only (DTSTART is at 00:00), so they are timezone-agnostic.
209 We separately apply a start + end time to generated occurrence dates.
211 We currently support weekly and biweekly events using the following properties:
212 - DTSTART (date-only): For specifying the seed day for weekly events.
213 It replaces RRULE.BYDAY for events that occur once weekly,
214 and allows disambiguating which week is skipped for biweekly events.
215 - RRULE.FREQ: Implicitly WEEKLY, so not stored
216 - RRULE.INTERVAL: 1 for weekly, 2 for biweekly
218 Future rule extensibility:
219 - Multiple days a week: Add RRULE.BYDAY supporting multiple values (e.g. "MO,TH")
220 - nth <weekday> of month: Store RRULE.FREQ to support MONTHLY, add RRULE.BYDAY with "1MO" (first Monday)
221 """
223 __tablename__ = "event_recurrences"
225 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
226 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), unique=True)
228 # Properties acting as templates for future EventOccurrence's
229 # TODO(#9845): Add photo_key and photo. Implies adding admin infra for tracking uses.
230 content: Mapped[str] = mapped_column(String) # CommonMark without images
231 geom: Mapped[Geom] = mapped_column(Geometry(geometry_type="POINT", srid=4326))
232 address: Mapped[str] = mapped_column(String)
233 timezone: Mapped[str] = mapped_column(String)
235 # Date part of the DTSTART in local time based on timezone.
236 # Seeds the RRULE and disambiguates which week to skip for biweekly events.
237 # Typically the date of latest occurrence where the RRULE.INTERVAL was changed.
238 dtstart_date: Mapped[date] = mapped_column(Date)
240 # Start time of the event in local time based on timezone.
241 start_time: Mapped[time] = mapped_column(Time)
243 # Number of days to the end of the event (typically zero) and local end time on that day.
244 # This representation maintains the same end time for occurrences crossing a DST change.
245 day_delta: Mapped[int] = mapped_column(Integer)
246 end_time: Mapped[time] = mapped_column(Time)
248 # RRULE.INTERVAL: 1 for weekly, 2 for biweekly (FREQ=WEEKLY is implicit)
249 rrule_interval: Mapped[int] = mapped_column(Integer)
251 # Last day on which an occurrence was scheduled, in local time based on timezone.
252 # New occurrences are only scheduled for dates strictly after this.
253 # Updated whenever an occurrence is scheduled to ensure idempotence.
254 last_scheduled_date: Mapped[date] = mapped_column(Date)
256 # Last day on which an occurrence can be scheduled, in local time based on timezone.
257 # Prevents unbounded recurrence. Feeds into the RRULE UNTIL property.
258 ends_on_date: Mapped[date] = mapped_column(Date)
260 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
262 event: Mapped[Event] = relationship(init=False, back_populates="recurrence")
264 __table_args__ = (
265 # Only weekly (1) and biweekly (2) are supported for now; see the class docstring.
266 CheckConstraint("rrule_interval IN (1, 2)", name="rrule_interval_weekly_or_biweekly"),
267 )
269 @property
270 def coordinates(self) -> tuple[float, float]:
271 # returns (lat, lng) or None
272 return get_coordinates(self.geom)
275class EventSubscription(Base, kw_only=True):
276 """
277 Users' subscriptions to events
278 """
280 __tablename__ = "event_subscriptions"
281 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
283 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
285 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
286 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
287 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
289 user: Mapped[User] = relationship(init=False)
290 event: Mapped[Event] = relationship(init=False)
293class EventOrganizer(Base, kw_only=True):
294 """
295 Organizers for events
296 """
298 __tablename__ = "event_organizers"
299 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
301 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
303 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
304 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
305 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
307 user: Mapped[User] = relationship(init=False)
308 event: Mapped[Event] = relationship(init=False)
311class AttendeeStatus(enum.Enum):
312 going = enum.auto()
315class EventOccurrenceAttendee(Base, kw_only=True):
316 """
317 Attendees for events
318 """
320 __tablename__ = "event_occurrence_attendees"
321 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),)
323 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
325 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
326 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
327 responded: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
328 attendee_status: Mapped[AttendeeStatus] = mapped_column(Enum(AttendeeStatus))
330 user: Mapped[User] = relationship(init=False)
331 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="attendances")
333 reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
336class EventCommunityInviteRequest(Base, kw_only=True):
337 """
338 Requests to send out invitation notifications/emails to the community for a given event occurrence
339 """
341 __tablename__ = "event_community_invite_requests"
343 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
345 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
346 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
348 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
350 decided: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
351 decided_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
352 approved: Mapped[bool | None] = mapped_column(Boolean, default=None)
354 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="community_invite_requests")
355 user: Mapped[User] = relationship(init=False, foreign_keys="EventCommunityInviteRequest.user_id")
357 __table_args__ = (
358 # each user can only request once
359 UniqueConstraint("occurrence_id", "user_id"),
360 # each event can only have one notification sent out
361 Index(
362 "ix_event_community_invite_requests_unique",
363 occurrence_id,
364 unique=True,
365 postgresql_where=and_(approved.is_not(None), approved == True),
366 ),
367 # decided and approved ought to be null simultaneously
368 CheckConstraint(
369 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \
370 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))",
371 name="decided_approved",
372 ),
373 )