Coverage for src/couchers/models/events.py: 100%
118 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
1import enum
2from datetime import datetime
3from typing import Any
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 Mapped, backref, column_property, mapped_column, relationship
23from sqlalchemy.sql import expression
25from couchers.models.base import Base, Geom, communities_seq
26from couchers.utils import get_coordinates
29class ClusterEventAssociation(Base):
30 """
31 events related to clusters
32 """
34 __tablename__ = "cluster_event_associations"
35 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),)
37 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
39 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
40 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
42 event = relationship("Event", backref="cluster_event_associations")
43 cluster = relationship("Cluster", backref="cluster_event_associations")
46class Event(Base):
47 """
48 An event is composed of two parts:
50 * An event template (Event)
51 * An occurrence (EventOccurrence)
53 One-off events will have one of each; repeating events will have one Event,
54 multiple EventOccurrences, one for each time the event happens.
55 """
57 __tablename__ = "events"
59 id: Mapped[int] = mapped_column(
60 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()
61 )
62 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
64 title: Mapped[str] = mapped_column(String)
66 slug = column_property(func.slugify(title))
68 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
69 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
70 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
71 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), nullable=True, index=True)
72 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
74 parent_node = relationship(
75 "Node", backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id"
76 )
77 thread = relationship("Thread", backref="event", uselist=False)
78 subscribers = relationship(
79 "User", backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True
80 )
81 organizers = relationship(
82 "User", backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True
83 )
84 creator_user = relationship("User", backref="created_events", foreign_keys="Event.creator_user_id")
85 owner_user = relationship("User", backref="owned_events", foreign_keys="Event.owner_user_id")
86 owner_cluster = relationship(
87 "Cluster",
88 backref=backref("owned_events", lazy="dynamic"),
89 uselist=False,
90 foreign_keys="Event.owner_cluster_id",
91 )
93 __table_args__ = (
94 # Only one of owner_user and owner_cluster should be set
95 CheckConstraint(
96 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
97 name="one_owner",
98 ),
99 )
102class EventOccurrence(Base):
103 __tablename__ = "event_occurrences"
105 id: Mapped[int] = mapped_column(
106 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()
107 )
108 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
110 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event)
111 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
112 content: Mapped[str] = mapped_column(String) # CommonMark without images
113 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), nullable=True)
115 is_cancelled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
116 is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
118 # a null geom is an online-only event
119 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
120 # physical address, iff geom is not null
121 address: Mapped[str | None] = mapped_column(String, nullable=True)
122 # videoconferencing link, etc, must be specified if no geom, otherwise optional
123 link: Mapped[str | None] = mapped_column(String, nullable=True)
125 timezone = "Etc/UTC"
127 # time during which the event takes place; this is a range type (instead of separate start+end times) which
128 # simplifies database constraints, etc
129 during: Mapped["DateTimeTZRange"] = mapped_column(TSTZRANGE)
131 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
132 last_edited: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
134 creator_user = relationship(
135 "User", backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id"
136 )
137 event = relationship(
138 "Event",
139 backref=backref("occurrences", lazy="dynamic"),
140 remote_side="Event.id",
141 foreign_keys="EventOccurrence.event_id",
142 )
144 photo = relationship("Upload")
146 __table_args__ = (
147 # Geom and address go together
148 CheckConstraint(
149 # geom and address are either both null or neither of them are null
150 "(geom IS NULL) = (address IS NULL)",
151 name="geom_iff_address",
152 ),
153 # Online-only events need a link, note that online events may also have a link
154 CheckConstraint(
155 # exactly oen of geom or link is non-null
156 "(geom IS NULL) <> (link IS NULL)",
157 name="link_or_geom",
158 ),
159 # Can't have overlapping occurrences in the same Event
160 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"), # type: ignore[no-untyped-call]
161 )
163 @property
164 def coordinates(self) -> tuple[float, float] | None:
165 # returns (lat, lng) or None
166 return get_coordinates(self.geom)
168 @hybrid_property
169 def start_time(self) -> Any:
170 return self.during.lower
172 @start_time.expression
173 def start_time(cls) -> Any: # noqa: ARG003,D102
174 return func.lower(cls.during)
176 @hybrid_property
177 def end_time(self) -> Any:
178 return self.during.upper
180 @end_time.expression
181 def end_time(cls) -> Any: # noqa: ARG003,D102
182 return func.upper(cls.during)
185class EventSubscription(Base):
186 """
187 Users' subscriptions to events
188 """
190 __tablename__ = "event_subscriptions"
191 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
193 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
195 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
196 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
197 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
199 user = relationship("User")
200 event = relationship("Event")
203class EventOrganizer(Base):
204 """
205 Organizers for events
206 """
208 __tablename__ = "event_organizers"
209 __table_args__ = (UniqueConstraint("event_id", "user_id"),)
211 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
213 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
214 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True)
215 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
217 user = relationship("User")
218 event = relationship("Event")
221class AttendeeStatus(enum.Enum):
222 going = enum.auto()
223 maybe = enum.auto()
226class EventOccurrenceAttendee(Base):
227 """
228 Attendees for events
229 """
231 __tablename__ = "event_occurrence_attendees"
232 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),)
234 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
236 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
237 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
238 responded: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
239 attendee_status: Mapped[AttendeeStatus] = mapped_column(Enum(AttendeeStatus))
241 user = relationship("User")
242 occurrence = relationship("EventOccurrence", backref=backref("attendances", lazy="dynamic"))
244 reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false())
247class EventCommunityInviteRequest(Base):
248 """
249 Requests to send out invitation notifications/emails to the community for a given event occurrence
250 """
252 __tablename__ = "event_community_invite_requests"
254 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
256 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True)
257 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
259 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
261 decided: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
262 decided_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
263 approved: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
265 occurrence = relationship("EventOccurrence", backref=backref("community_invite_requests", lazy="dynamic"))
266 user = relationship("User", foreign_keys="EventCommunityInviteRequest.user_id")
268 __table_args__ = (
269 # each user can only request once
270 UniqueConstraint("occurrence_id", "user_id"),
271 # each event can only have one notification sent out
272 Index(
273 "ix_event_community_invite_requests_unique",
274 occurrence_id,
275 unique=True,
276 postgresql_where=and_(approved.is_not(None), approved == True),
277 ),
278 # decided and approved ought to be null simultaneously
279 CheckConstraint(
280 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \
281 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))",
282 name="decided_approved",
283 ),
284 )