Coverage for app/backend/src/couchers/models/events.py: 100%
128 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1import enum
2from datetime import datetime
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 DateTime,
12 Enum,
13 ForeignKey,
14 Index,
15 String,
16 UniqueConstraint,
17 and_,
18 func,
19)
20from sqlalchemy.dialects.postgresql import TSTZRANGE, ExcludeConstraint
21from sqlalchemy.ext.hybrid import hybrid_property
22from sqlalchemy.orm import DynamicMapped, Mapped, backref, column_property, mapped_column, relationship
23from sqlalchemy.sql import expression
24from sqlalchemy.sql.elements import ColumnElement
26from couchers.models.base import Base, Geom, communities_seq
27from couchers.models.moderation import ModerationObjectType
28from couchers.utils import get_coordinates
30if TYPE_CHECKING:
31 from couchers.models import Cluster, Node, Thread, Upload, User
32 from couchers.models.moderation import ModerationState
35class ClusterEventAssociation(Base, kw_only=True):
36 """
37 events related to clusters
38 """
40 __tablename__ = "cluster_event_associations"
41 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),)
43 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
45 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
46 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
48 event: Mapped[Event] = relationship(init=False, backref="cluster_event_associations")
49 cluster: Mapped[Cluster] = relationship(init=False, backref="cluster_event_associations")
52class Event(Base, kw_only=True):
53 """
54 An event is composed of two parts:
56 * An event template (Event)
57 * An occurrence (EventOccurrence)
59 One-off events will have one of each; repeating events will have one Event,
60 multiple EventOccurrences, one for each time the event happens.
61 """
63 __tablename__ = "events"
65 id: Mapped[int] = mapped_column(
66 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
67 )
68 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
70 title: Mapped[str] = mapped_column(String)
72 slug: Mapped[str] = column_property(func.slugify(title))
74 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
75 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
76 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), index=True, default=None)
77 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), index=True, default=None)
78 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
80 parent_node: Mapped[Node] = relationship(
81 init=False, backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id"
82 )
83 thread: Mapped[Thread] = relationship(init=False, backref="event", uselist=False)
84 subscribers: DynamicMapped[User] = relationship(
85 init=False, backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True
86 )
87 organizers: DynamicMapped[User] = relationship(
88 init=False, backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True
89 )
90 creator_user: Mapped[User] = relationship(
91 init=False, backref="created_events", foreign_keys="Event.creator_user_id"
92 )
93 owner_user: Mapped[User | None] = relationship(
94 init=False, backref="owned_events", foreign_keys="Event.owner_user_id"
95 )
96 owner_cluster: Mapped[Cluster | None] = relationship(
97 init=False,
98 backref=backref("owned_events", lazy="dynamic"),
99 uselist=False,
100 foreign_keys="Event.owner_cluster_id",
101 )
102 occurrences: DynamicMapped[EventOccurrence] = relationship(init=False, lazy="dynamic")
104 __table_args__ = (
105 # Only one of owner_user and owner_cluster should be set
106 CheckConstraint(
107 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
108 name="one_owner",
109 ),
110 )
113class EventOccurrence(Base, kw_only=True):
114 __tablename__ = "event_occurrences"
115 __moderation_author_column__ = "creator_user_id"
116 __moderation_object_type__ = ModerationObjectType.event_occurrence
118 id: Mapped[int] = mapped_column(
119 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
120 )
121 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
122 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
124 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event)
125 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
126 content: Mapped[str] = mapped_column(String) # CommonMark without images
127 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), default=None)
129 is_cancelled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
130 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
132 # a null geom is an online-only event
133 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
134 # physical address, iff geom is not null
135 address: Mapped[str | None] = mapped_column(String, default=None)
136 # videoconferencing link, etc, must be specified if no geom, otherwise optional
137 link: Mapped[str | None] = mapped_column(String, default=None)
139 timezone = "Etc/UTC"
141 # time during which the event takes place; this is a range type (instead of separate start+end times) which
142 # simplifies database constraints, etc
143 during: Mapped[TimestamptzRange] = mapped_column(TSTZRANGE)
145 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
146 last_edited: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
148 creator_user: Mapped[User] = relationship(
149 init=False, backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id"
150 )
151 event: Mapped[Event] = relationship(
152 init=False,
153 back_populates="occurrences",
154 remote_side="Event.id",
155 foreign_keys="EventOccurrence.event_id",
156 )
158 photo: Mapped[Upload | None] = relationship(init=False)
159 attendances: DynamicMapped[EventOccurrenceAttendee] = relationship(
160 init=False, back_populates="occurrence", lazy="dynamic"
161 )
162 community_invite_requests: DynamicMapped[EventCommunityInviteRequest] = relationship(
163 init=False, back_populates="occurrence", lazy="dynamic"
164 )
165 moderation_state: Mapped[ModerationState] = relationship(init=False)
167 __table_args__ = (
168 # Geom and address go together
169 CheckConstraint(
170 # geom and address are either both null or neither of them are null
171 "(geom IS NULL) = (address IS NULL)",
172 name="geom_iff_address",
173 ),
174 # Online-only events need a link, note that online events may also have a link
175 CheckConstraint(
176 # exactly oen of geom or link is non-null
177 "(geom IS NULL) <> (link IS NULL)",
178 name="link_or_geom",
179 ),
180 # Can't have overlapping occurrences in the same Event
181 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"),
182 )
184 @property
185 def coordinates(self) -> tuple[float, float] | None:
186 # returns (lat, lng) or None
187 return get_coordinates(self.geom)
189 @hybrid_property
190 def start_time(self) -> datetime:
191 return cast(datetime, self.during.lower)
193 @start_time.inplace.expression
194 @classmethod
195 def _start_time_expression(cls) -> ColumnElement[datetime]:
196 return cast(ColumnElement[datetime], func.lower(cls.during))
198 @hybrid_property
199 def end_time(self) -> datetime:
200 return cast(datetime, self.during.upper)
202 @end_time.inplace.expression
203 @classmethod
204 def _end_time_expression(cls) -> ColumnElement[datetime]:
205 return cast(ColumnElement[datetime], func.upper(cls.during))
208class EventSubscription(Base, kw_only=True):
209 """
210 Users' subscriptions to events
211 """
213 __tablename__ = "event_subscriptions"
214 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
216 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
218 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
219 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
220 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
222 user: Mapped[User] = relationship(init=False)
223 event: Mapped[Event] = relationship(init=False)
226class EventOrganizer(Base, kw_only=True):
227 """
228 Organizers for events
229 """
231 __tablename__ = "event_organizers"
232 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
234 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
236 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
237 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
238 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
240 user: Mapped[User] = relationship(init=False)
241 event: Mapped[Event] = relationship(init=False)
244class AttendeeStatus(enum.Enum):
245 going = enum.auto()
248class EventOccurrenceAttendee(Base, kw_only=True):
249 """
250 Attendees for events
251 """
253 __tablename__ = "event_occurrence_attendees"
254 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),)
256 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
258 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
259 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
260 responded: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
261 attendee_status: Mapped[AttendeeStatus] = mapped_column(Enum(AttendeeStatus))
263 user: Mapped[User] = relationship(init=False)
264 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="attendances")
266 reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
269class EventCommunityInviteRequest(Base, kw_only=True):
270 """
271 Requests to send out invitation notifications/emails to the community for a given event occurrence
272 """
274 __tablename__ = "event_community_invite_requests"
276 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
278 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
279 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
281 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
283 decided: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
284 decided_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
285 approved: Mapped[bool | None] = mapped_column(Boolean, default=None)
287 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="community_invite_requests")
288 user: Mapped[User] = relationship(init=False, foreign_keys="EventCommunityInviteRequest.user_id")
290 __table_args__ = (
291 # each user can only request once
292 UniqueConstraint("occurrence_id", "user_id"),
293 # each event can only have one notification sent out
294 Index(
295 "ix_event_community_invite_requests_unique",
296 occurrence_id,
297 unique=True,
298 postgresql_where=and_(approved.is_not(None), approved == True),
299 ),
300 # decided and approved ought to be null simultaneously
301 CheckConstraint(
302 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \
303 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))",
304 name="decided_approved",
305 ),
306 )