Coverage for app / backend / src / couchers / models / events.py: 100%
124 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +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
33class ClusterEventAssociation(Base, kw_only=True):
34 """
35 events related to clusters
36 """
38 __tablename__ = "cluster_event_associations"
39 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),)
41 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
43 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
44 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
46 event: Mapped[Event] = relationship(init=False, backref="cluster_event_associations")
47 cluster: Mapped[Cluster] = relationship(init=False, backref="cluster_event_associations")
50class Event(Base, kw_only=True):
51 """
52 An event is composed of two parts:
54 * An event template (Event)
55 * An occurrence (EventOccurrence)
57 One-off events will have one of each; repeating events will have one Event,
58 multiple EventOccurrences, one for each time the event happens.
59 """
61 __tablename__ = "events"
63 id: Mapped[int] = mapped_column(
64 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
65 )
66 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
68 title: Mapped[str] = mapped_column(String)
70 slug: Mapped[str] = column_property(func.slugify(title))
72 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
73 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
74 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), index=True, default=None)
75 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), index=True, default=None)
76 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
78 parent_node: Mapped[Node] = relationship(
79 init=False, backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id"
80 )
81 thread: Mapped[Thread] = relationship(init=False, backref="event", uselist=False)
82 subscribers: DynamicMapped[User] = relationship(
83 init=False, backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True
84 )
85 organizers: DynamicMapped[User] = relationship(
86 init=False, backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True
87 )
88 creator_user: Mapped[User] = relationship(
89 init=False, backref="created_events", foreign_keys="Event.creator_user_id"
90 )
91 owner_user: Mapped[User | None] = relationship(
92 init=False, backref="owned_events", foreign_keys="Event.owner_user_id"
93 )
94 owner_cluster: Mapped[Cluster | None] = relationship(
95 init=False,
96 backref=backref("owned_events", lazy="dynamic"),
97 uselist=False,
98 foreign_keys="Event.owner_cluster_id",
99 )
100 occurrences: DynamicMapped[EventOccurrence] = relationship(init=False, lazy="dynamic")
102 __table_args__ = (
103 # Only one of owner_user and owner_cluster should be set
104 CheckConstraint(
105 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
106 name="one_owner",
107 ),
108 )
111class EventOccurrence(Base, kw_only=True):
112 __tablename__ = "event_occurrences"
114 id: Mapped[int] = mapped_column(
115 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
116 )
117 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
119 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event)
120 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
121 content: Mapped[str] = mapped_column(String) # CommonMark without images
122 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), default=None)
124 is_cancelled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
125 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
127 # a null geom is an online-only event
128 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
129 # physical address, iff geom is not null
130 address: Mapped[str | None] = mapped_column(String, default=None)
131 # videoconferencing link, etc, must be specified if no geom, otherwise optional
132 link: Mapped[str | None] = mapped_column(String, default=None)
134 timezone = "Etc/UTC"
136 # time during which the event takes place; this is a range type (instead of separate start+end times) which
137 # simplifies database constraints, etc
138 during: Mapped[DateTimeTZRange] = mapped_column(TSTZRANGE)
140 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
141 last_edited: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
143 creator_user: Mapped[User] = relationship(
144 init=False, backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id"
145 )
146 event: Mapped[Event] = relationship(
147 init=False,
148 back_populates="occurrences",
149 remote_side="Event.id",
150 foreign_keys="EventOccurrence.event_id",
151 )
153 photo: Mapped[Upload | None] = relationship(init=False)
154 attendances: DynamicMapped[EventOccurrenceAttendee] = relationship(
155 init=False, back_populates="occurrence", lazy="dynamic"
156 )
157 community_invite_requests: DynamicMapped[EventCommunityInviteRequest] = relationship(
158 init=False, back_populates="occurrence", lazy="dynamic"
159 )
161 __table_args__ = (
162 # Geom and address go together
163 CheckConstraint(
164 # geom and address are either both null or neither of them are null
165 "(geom IS NULL) = (address IS NULL)",
166 name="geom_iff_address",
167 ),
168 # Online-only events need a link, note that online events may also have a link
169 CheckConstraint(
170 # exactly oen of geom or link is non-null
171 "(geom IS NULL) <> (link IS NULL)",
172 name="link_or_geom",
173 ),
174 # Can't have overlapping occurrences in the same Event
175 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"),
176 )
178 @property
179 def coordinates(self) -> tuple[float, float] | None:
180 # returns (lat, lng) or None
181 return get_coordinates(self.geom)
183 @hybrid_property
184 def start_time(self) -> datetime:
185 return cast(datetime, self.during.lower)
187 @start_time.inplace.expression
188 @classmethod
189 def _start_time_expression(cls) -> ColumnElement[datetime]:
190 return cast(ColumnElement[datetime], func.lower(cls.during))
192 @hybrid_property
193 def end_time(self) -> datetime:
194 return cast(datetime, self.during.upper)
196 @end_time.inplace.expression
197 @classmethod
198 def _end_time_expression(cls) -> ColumnElement[datetime]:
199 return cast(ColumnElement[datetime], func.upper(cls.during))
202class EventSubscription(Base, kw_only=True):
203 """
204 Users' subscriptions to events
205 """
207 __tablename__ = "event_subscriptions"
208 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
210 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
212 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
213 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
214 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
216 user: Mapped[User] = relationship(init=False)
217 event: Mapped[Event] = relationship(init=False)
220class EventOrganizer(Base, kw_only=True):
221 """
222 Organizers for events
223 """
225 __tablename__ = "event_organizers"
226 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
228 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
230 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
231 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
232 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
234 user: Mapped[User] = relationship(init=False)
235 event: Mapped[Event] = relationship(init=False)
238class AttendeeStatus(enum.Enum):
239 going = enum.auto()
240 maybe = enum.auto()
243class EventOccurrenceAttendee(Base, kw_only=True):
244 """
245 Attendees for events
246 """
248 __tablename__ = "event_occurrence_attendees"
249 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),)
251 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
253 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
254 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
255 responded: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
256 attendee_status: Mapped[AttendeeStatus] = mapped_column(Enum(AttendeeStatus))
258 user: Mapped[User] = relationship(init=False)
259 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="attendances")
261 reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
264class EventCommunityInviteRequest(Base, kw_only=True):
265 """
266 Requests to send out invitation notifications/emails to the community for a given event occurrence
267 """
269 __tablename__ = "event_community_invite_requests"
271 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
273 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
274 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
276 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
278 decided: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
279 decided_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
280 approved: Mapped[bool | None] = mapped_column(Boolean, default=None)
282 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="community_invite_requests")
283 user: Mapped[User] = relationship(init=False, foreign_keys="EventCommunityInviteRequest.user_id")
285 __table_args__ = (
286 # each user can only request once
287 UniqueConstraint("occurrence_id", "user_id"),
288 # each event can only have one notification sent out
289 Index(
290 "ix_event_community_invite_requests_unique",
291 occurrence_id,
292 unique=True,
293 postgresql_where=and_(approved.is_not(None), approved == True),
294 ),
295 # decided and approved ought to be null simultaneously
296 CheckConstraint(
297 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \
298 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))",
299 name="decided_approved",
300 ),
301 )