Coverage for src/couchers/models/events.py: 100%
115 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
1import enum
3from geoalchemy2 import Geometry
4from sqlalchemy import (
5 BigInteger,
6 Boolean,
7 CheckConstraint,
8 Column,
9 DateTime,
10 Enum,
11 ForeignKey,
12 Index,
13 String,
14 UniqueConstraint,
15 and_,
16 func,
17)
18from sqlalchemy.dialects.postgresql import TSTZRANGE, ExcludeConstraint
19from sqlalchemy.ext.hybrid import hybrid_property
20from sqlalchemy.orm import backref, column_property, relationship
21from sqlalchemy.sql import expression
23from couchers.models.base import Base, communities_seq
24from couchers.utils import get_coordinates
27class ClusterEventAssociation(Base):
28 """
29 events related to clusters
30 """
32 __tablename__ = "cluster_event_associations"
33 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),)
35 id = Column(BigInteger, primary_key=True)
37 event_id = Column(ForeignKey("events.id"), nullable=False, index=True)
38 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
40 event = relationship("Event", backref="cluster_event_associations")
41 cluster = relationship("Cluster", backref="cluster_event_associations")
44class Event(Base):
45 """
46 An event is composed of two parts:
48 * An event template (Event)
49 * An occurrence (EventOccurrence)
51 One-off events will have one of each; repeating events will have one Event,
52 multiple EventOccurrences, one for each time the event happens.
53 """
55 __tablename__ = "events"
57 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
58 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True)
60 title = Column(String, nullable=False)
62 slug = column_property(func.slugify(title))
64 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
65 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
66 owner_user_id = Column(ForeignKey("users.id"), nullable=True, index=True)
67 owner_cluster_id = Column(ForeignKey("clusters.id"), nullable=True, index=True)
68 thread_id = Column(ForeignKey("threads.id"), nullable=False, unique=True)
70 parent_node = relationship(
71 "Node", backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id"
72 )
73 thread = relationship("Thread", backref="event", uselist=False)
74 subscribers = relationship(
75 "User", backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True
76 )
77 organizers = relationship(
78 "User", backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True
79 )
80 creator_user = relationship("User", backref="created_events", foreign_keys="Event.creator_user_id")
81 owner_user = relationship("User", backref="owned_events", foreign_keys="Event.owner_user_id")
82 owner_cluster = relationship(
83 "Cluster",
84 backref=backref("owned_events", lazy="dynamic"),
85 uselist=False,
86 foreign_keys="Event.owner_cluster_id",
87 )
89 __table_args__ = (
90 # Only one of owner_user and owner_cluster should be set
91 CheckConstraint(
92 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
93 name="one_owner",
94 ),
95 )
98class EventOccurrence(Base):
99 __tablename__ = "event_occurrences"
101 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
102 event_id = Column(ForeignKey("events.id"), nullable=False, index=True)
104 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event)
105 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
106 content = Column(String, nullable=False) # CommonMark without images
107 photo_key = Column(ForeignKey("uploads.key"), nullable=True)
109 is_cancelled = Column(Boolean, nullable=False, default=False, server_default=expression.false())
110 is_deleted = Column(Boolean, nullable=False, default=False, server_default=expression.false())
112 # a null geom is an online-only event
113 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
114 # physical address, iff geom is not null
115 address = Column(String, nullable=True)
116 # videoconferencing link, etc, must be specified if no geom, otherwise optional
117 link = Column(String, nullable=True)
119 timezone = "Etc/UTC"
121 # time during which the event takes place; this is a range type (instead of separate start+end times) which
122 # simplifies database constraints, etc
123 during = Column(TSTZRANGE, nullable=False)
125 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
126 last_edited = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
128 creator_user = relationship(
129 "User", backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id"
130 )
131 event = relationship(
132 "Event",
133 backref=backref("occurrences", lazy="dynamic"),
134 remote_side="Event.id",
135 foreign_keys="EventOccurrence.event_id",
136 )
138 photo = relationship("Upload")
140 __table_args__ = (
141 # Geom and address go together
142 CheckConstraint(
143 # geom and address are either both null or neither of them are null
144 "(geom IS NULL) = (address IS NULL)",
145 name="geom_iff_address",
146 ),
147 # Online-only events need a link, note that online events may also have a link
148 CheckConstraint(
149 # exactly oen of geom or link is non-null
150 "(geom IS NULL) <> (link IS NULL)",
151 name="link_or_geom",
152 ),
153 # Can't have overlapping occurrences in the same Event
154 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"),
155 )
157 @property
158 def coordinates(self):
159 # returns (lat, lng) or None
160 return get_coordinates(self.geom)
162 @hybrid_property
163 def start_time(self):
164 return self.during.lower
166 @start_time.expression
167 def start_time(cls):
168 return func.lower(cls.during)
170 @hybrid_property
171 def end_time(self):
172 return self.during.upper
174 @end_time.expression
175 def end_time(cls):
176 return func.upper(cls.during)
179class EventSubscription(Base):
180 """
181 Users' subscriptions to events
182 """
184 __tablename__ = "event_subscriptions"
185 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
187 id = Column(BigInteger, primary_key=True)
189 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
190 event_id = Column(ForeignKey("events.id"), nullable=False, index=True)
191 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
193 user = relationship("User")
194 event = relationship("Event")
197class EventOrganizer(Base):
198 """
199 Organizers for events
200 """
202 __tablename__ = "event_organizers"
203 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
205 id = Column(BigInteger, primary_key=True)
207 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
208 event_id = Column(ForeignKey("events.id"), nullable=False, index=True)
209 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
211 user = relationship("User")
212 event = relationship("Event")
215class AttendeeStatus(enum.Enum):
216 going = enum.auto()
217 maybe = enum.auto()
220class EventOccurrenceAttendee(Base):
221 """
222 Attendees for events
223 """
225 __tablename__ = "event_occurrence_attendees"
226 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),)
228 id = Column(BigInteger, primary_key=True)
230 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
231 occurrence_id = Column(ForeignKey("event_occurrences.id"), nullable=False, index=True)
232 responded = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
233 attendee_status = Column(Enum(AttendeeStatus), nullable=False)
235 user = relationship("User")
236 occurrence = relationship("EventOccurrence", backref=backref("attendances", lazy="dynamic"))
238 reminder_sent = Column(Boolean, nullable=False, default=False, server_default=expression.false())
241class EventCommunityInviteRequest(Base):
242 """
243 Requests to send out invitation notifications/emails to the community for a given event occurrence
244 """
246 __tablename__ = "event_community_invite_requests"
248 id = Column(BigInteger, primary_key=True)
250 occurrence_id = Column(ForeignKey("event_occurrences.id"), nullable=False, index=True)
251 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
253 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
255 decided = Column(DateTime(timezone=True), nullable=True)
256 decided_by_user_id = Column(ForeignKey("users.id"), nullable=True)
257 approved = Column(Boolean, nullable=True)
259 occurrence = relationship("EventOccurrence", backref=backref("community_invite_requests", lazy="dynamic"))
260 user = relationship("User", foreign_keys="EventCommunityInviteRequest.user_id")
262 __table_args__ = (
263 # each user can only request once
264 UniqueConstraint("occurrence_id", "user_id"),
265 # each event can only have one notification sent out
266 Index(
267 "ix_event_community_invite_requests_unique",
268 occurrence_id,
269 unique=True,
270 postgresql_where=and_(approved.is_not(None), approved == True),
271 ),
272 # decided and approved ought to be null simultaneously
273 CheckConstraint(
274 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \
275 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))",
276 name="decided_approved",
277 ),
278 )