Coverage for app / backend / src / couchers / models / events.py: 100%
127 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1import enum
2from datetime import datetime
3from typing import TYPE_CHECKING, cast
5from geoalchemy2 import Geometry
6from psycopg2.extras import DateTimeTZRange
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.utils import get_coordinates
29if TYPE_CHECKING:
30 from couchers.models import Cluster, Node, Thread, Upload, User
31 from couchers.models.moderation import ModerationState
34class ClusterEventAssociation(Base, kw_only=True):
35 """
36 events related to clusters
37 """
39 __tablename__ = "cluster_event_associations"
40 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),)
42 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
44 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
45 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
47 event: Mapped[Event] = relationship(init=False, backref="cluster_event_associations")
48 cluster: Mapped[Cluster] = relationship(init=False, backref="cluster_event_associations")
51class Event(Base, kw_only=True):
52 """
53 An event is composed of two parts:
55 * An event template (Event)
56 * An occurrence (EventOccurrence)
58 One-off events will have one of each; repeating events will have one Event,
59 multiple EventOccurrences, one for each time the event happens.
60 """
62 __tablename__ = "events"
64 id: Mapped[int] = mapped_column(
65 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
66 )
67 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
69 title: Mapped[str] = mapped_column(String)
71 slug: Mapped[str] = column_property(func.slugify(title))
73 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
74 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
75 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), index=True, default=None)
76 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), index=True, default=None)
77 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
79 parent_node: Mapped[Node] = relationship(
80 init=False, backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id"
81 )
82 thread: Mapped[Thread] = relationship(init=False, backref="event", uselist=False)
83 subscribers: DynamicMapped[User] = relationship(
84 init=False, backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True
85 )
86 organizers: DynamicMapped[User] = relationship(
87 init=False, backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True
88 )
89 creator_user: Mapped[User] = relationship(
90 init=False, backref="created_events", foreign_keys="Event.creator_user_id"
91 )
92 owner_user: Mapped[User | None] = relationship(
93 init=False, backref="owned_events", foreign_keys="Event.owner_user_id"
94 )
95 owner_cluster: Mapped[Cluster | None] = relationship(
96 init=False,
97 backref=backref("owned_events", lazy="dynamic"),
98 uselist=False,
99 foreign_keys="Event.owner_cluster_id",
100 )
101 occurrences: DynamicMapped[EventOccurrence] = relationship(init=False, lazy="dynamic")
103 __table_args__ = (
104 # Only one of owner_user and owner_cluster should be set
105 CheckConstraint(
106 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
107 name="one_owner",
108 ),
109 )
112class EventOccurrence(Base, kw_only=True):
113 __tablename__ = "event_occurrences"
114 __moderation_author_column__ = "creator_user_id"
116 id: Mapped[int] = mapped_column(
117 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
118 )
119 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
120 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
122 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event)
123 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
124 content: Mapped[str] = mapped_column(String) # CommonMark without images
125 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), default=None)
127 is_cancelled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
128 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
130 # a null geom is an online-only event
131 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
132 # physical address, iff geom is not null
133 address: Mapped[str | None] = mapped_column(String, default=None)
134 # videoconferencing link, etc, must be specified if no geom, otherwise optional
135 link: Mapped[str | None] = mapped_column(String, default=None)
137 timezone = "Etc/UTC"
139 # time during which the event takes place; this is a range type (instead of separate start+end times) which
140 # simplifies database constraints, etc
141 during: Mapped[DateTimeTZRange] = mapped_column(TSTZRANGE)
143 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
144 last_edited: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
146 creator_user: Mapped[User] = relationship(
147 init=False, backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id"
148 )
149 event: Mapped[Event] = relationship(
150 init=False,
151 back_populates="occurrences",
152 remote_side="Event.id",
153 foreign_keys="EventOccurrence.event_id",
154 )
156 photo: Mapped[Upload | None] = relationship(init=False)
157 attendances: DynamicMapped[EventOccurrenceAttendee] = relationship(
158 init=False, back_populates="occurrence", lazy="dynamic"
159 )
160 community_invite_requests: DynamicMapped[EventCommunityInviteRequest] = relationship(
161 init=False, back_populates="occurrence", lazy="dynamic"
162 )
163 moderation_state: Mapped[ModerationState] = relationship(init=False)
165 __table_args__ = (
166 # Geom and address go together
167 CheckConstraint(
168 # geom and address are either both null or neither of them are null
169 "(geom IS NULL) = (address IS NULL)",
170 name="geom_iff_address",
171 ),
172 # Online-only events need a link, note that online events may also have a link
173 CheckConstraint(
174 # exactly oen of geom or link is non-null
175 "(geom IS NULL) <> (link IS NULL)",
176 name="link_or_geom",
177 ),
178 # Can't have overlapping occurrences in the same Event
179 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"),
180 )
182 @property
183 def coordinates(self) -> tuple[float, float] | None:
184 # returns (lat, lng) or None
185 return get_coordinates(self.geom)
187 @hybrid_property
188 def start_time(self) -> datetime:
189 return cast(datetime, self.during.lower)
191 @start_time.inplace.expression
192 @classmethod
193 def _start_time_expression(cls) -> ColumnElement[datetime]:
194 return cast(ColumnElement[datetime], func.lower(cls.during))
196 @hybrid_property
197 def end_time(self) -> datetime:
198 return cast(datetime, self.during.upper)
200 @end_time.inplace.expression
201 @classmethod
202 def _end_time_expression(cls) -> ColumnElement[datetime]:
203 return cast(ColumnElement[datetime], func.upper(cls.during))
206class EventSubscription(Base, kw_only=True):
207 """
208 Users' subscriptions to events
209 """
211 __tablename__ = "event_subscriptions"
212 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
214 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
216 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
217 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
218 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
220 user: Mapped[User] = relationship(init=False)
221 event: Mapped[Event] = relationship(init=False)
224class EventOrganizer(Base, kw_only=True):
225 """
226 Organizers for events
227 """
229 __tablename__ = "event_organizers"
230 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
232 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
234 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
235 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
236 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
238 user: Mapped[User] = relationship(init=False)
239 event: Mapped[Event] = relationship(init=False)
242class AttendeeStatus(enum.Enum):
243 going = enum.auto()
244 maybe = enum.auto()
247class EventOccurrenceAttendee(Base, kw_only=True):
248 """
249 Attendees for events
250 """
252 __tablename__ = "event_occurrence_attendees"
253 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),)
255 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
257 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
258 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
259 responded: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
260 attendee_status: Mapped[AttendeeStatus] = mapped_column(Enum(AttendeeStatus))
262 user: Mapped[User] = relationship(init=False)
263 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="attendances")
265 reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
268class EventCommunityInviteRequest(Base, kw_only=True):
269 """
270 Requests to send out invitation notifications/emails to the community for a given event occurrence
271 """
273 __tablename__ = "event_community_invite_requests"
275 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
277 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
278 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
280 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
282 decided: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
283 decided_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
284 approved: Mapped[bool | None] = mapped_column(Boolean, default=None)
286 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="community_invite_requests")
287 user: Mapped[User] = relationship(init=False, foreign_keys="EventCommunityInviteRequest.user_id")
289 __table_args__ = (
290 # each user can only request once
291 UniqueConstraint("occurrence_id", "user_id"),
292 # each event can only have one notification sent out
293 Index(
294 "ix_event_community_invite_requests_unique",
295 occurrence_id,
296 unique=True,
297 postgresql_where=and_(approved.is_not(None), approved == True),
298 ),
299 # decided and approved ought to be null simultaneously
300 CheckConstraint(
301 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \
302 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))",
303 name="decided_approved",
304 ),
305 )