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

1import enum 

2from datetime import datetime 

3from typing import TYPE_CHECKING, cast 

4 

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 

25 

26from couchers.models.base import Base, Geom, communities_seq 

27from couchers.utils import get_coordinates 

28 

29if TYPE_CHECKING: 

30 from couchers.models import Cluster, Node, Thread, Upload, User 

31 from couchers.models.moderation import ModerationState 

32 

33 

34class ClusterEventAssociation(Base, kw_only=True): 

35 """ 

36 events related to clusters 

37 """ 

38 

39 __tablename__ = "cluster_event_associations" 

40 __table_args__ = (UniqueConstraint("event_id", "cluster_id"),) 

41 

42 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

43 

44 event_id: Mapped[int] = mapped_column(ForeignKey("events.id"), index=True) 

45 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True) 

46 

47 event: Mapped[Event] = relationship(init=False, backref="cluster_event_associations") 

48 cluster: Mapped[Cluster] = relationship(init=False, backref="cluster_event_associations") 

49 

50 

51class Event(Base, kw_only=True): 

52 """ 

53 An event is composed of two parts: 

54 

55 * An event template (Event) 

56 * An occurrence (EventOccurrence) 

57 

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 """ 

61 

62 __tablename__ = "events" 

63 

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) 

68 

69 title: Mapped[str] = mapped_column(String) 

70 

71 slug: Mapped[str] = column_property(func.slugify(title)) 

72 

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) 

78 

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") 

102 

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 ) 

110 

111 

112class EventOccurrence(Base, kw_only=True): 

113 __tablename__ = "event_occurrences" 

114 __moderation_author_column__ = "creator_user_id" 

115 

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) 

121 

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) 

126 

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()) 

129 

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) 

136 

137 timezone = "Etc/UTC" 

138 

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) 

142 

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) 

145 

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 ) 

155 

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) 

164 

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 ) 

181 

182 @property 

183 def coordinates(self) -> tuple[float, float] | None: 

184 # returns (lat, lng) or None 

185 return get_coordinates(self.geom) 

186 

187 @hybrid_property 

188 def start_time(self) -> datetime: 

189 return cast(datetime, self.during.lower) 

190 

191 @start_time.inplace.expression 

192 @classmethod 

193 def _start_time_expression(cls) -> ColumnElement[datetime]: 

194 return cast(ColumnElement[datetime], func.lower(cls.during)) 

195 

196 @hybrid_property 

197 def end_time(self) -> datetime: 

198 return cast(datetime, self.during.upper) 

199 

200 @end_time.inplace.expression 

201 @classmethod 

202 def _end_time_expression(cls) -> ColumnElement[datetime]: 

203 return cast(ColumnElement[datetime], func.upper(cls.during)) 

204 

205 

206class EventSubscription(Base, kw_only=True): 

207 """ 

208 Users' subscriptions to events 

209 """ 

210 

211 __tablename__ = "event_subscriptions" 

212 __table_args__ = (UniqueConstraint("event_id", "user_id"),) 

213 

214 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

215 

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) 

219 

220 user: Mapped[User] = relationship(init=False) 

221 event: Mapped[Event] = relationship(init=False) 

222 

223 

224class EventOrganizer(Base, kw_only=True): 

225 """ 

226 Organizers for events 

227 """ 

228 

229 __tablename__ = "event_organizers" 

230 __table_args__ = (UniqueConstraint("event_id", "user_id"),) 

231 

232 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

233 

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) 

237 

238 user: Mapped[User] = relationship(init=False) 

239 event: Mapped[Event] = relationship(init=False) 

240 

241 

242class AttendeeStatus(enum.Enum): 

243 going = enum.auto() 

244 maybe = enum.auto() 

245 

246 

247class EventOccurrenceAttendee(Base, kw_only=True): 

248 """ 

249 Attendees for events 

250 """ 

251 

252 __tablename__ = "event_occurrence_attendees" 

253 __table_args__ = (UniqueConstraint("occurrence_id", "user_id"),) 

254 

255 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

256 

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)) 

261 

262 user: Mapped[User] = relationship(init=False) 

263 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="attendances") 

264 

265 reminder_sent: Mapped[bool] = mapped_column(Boolean, default=False, server_default=expression.false()) 

266 

267 

268class EventCommunityInviteRequest(Base, kw_only=True): 

269 """ 

270 Requests to send out invitation notifications/emails to the community for a given event occurrence 

271 """ 

272 

273 __tablename__ = "event_community_invite_requests" 

274 

275 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

276 

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) 

279 

280 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

281 

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) 

285 

286 occurrence: Mapped[EventOccurrence] = relationship(init=False, back_populates="community_invite_requests") 

287 user: Mapped[User] = relationship(init=False, foreign_keys="EventCommunityInviteRequest.user_id") 

288 

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 )