Coverage for app/backend/src/couchers/models/events.py: 100%

128 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +0000

1import enum 

2from datetime import datetime 

3from typing import TYPE_CHECKING, cast 

4 

5from geoalchemy2 import Geometry 

6from psycopg.types.range import TimestamptzRange 

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.models.moderation import ModerationObjectType 

28from couchers.utils import get_coordinates 

29 

30if TYPE_CHECKING: 

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

32 from couchers.models.moderation import ModerationState 

33 

34 

35class ClusterEventAssociation(Base, kw_only=True): 

36 """ 

37 events related to clusters 

38 """ 

39 

40 __tablename__ = "cluster_event_associations" 

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

42 

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

44 

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

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

47 

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

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

50 

51 

52class Event(Base, kw_only=True): 

53 """ 

54 An event is composed of two parts: 

55 

56 * An event template (Event) 

57 * An occurrence (EventOccurrence) 

58 

59 One-off events will have one of each; repeating events will have one Event, 

60 multiple EventOccurrences, one for each time the event happens. 

61 """ 

62 

63 __tablename__ = "events" 

64 

65 id: Mapped[int] = mapped_column( 

66 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False 

67 ) 

68 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True) 

69 

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

71 

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

73 

74 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

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

76 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), index=True, default=None) 

77 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), index=True, default=None) 

78 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True) 

79 

80 parent_node: Mapped[Node] = relationship( 

81 init=False, backref="child_events", remote_side="Node.id", foreign_keys="Event.parent_node_id" 

82 ) 

83 thread: Mapped[Thread] = relationship(init=False, backref="event", uselist=False) 

84 subscribers: DynamicMapped[User] = relationship( 

85 init=False, backref="subscribed_events", secondary="event_subscriptions", lazy="dynamic", viewonly=True 

86 ) 

87 organizers: DynamicMapped[User] = relationship( 

88 init=False, backref="organized_events", secondary="event_organizers", lazy="dynamic", viewonly=True 

89 ) 

90 creator_user: Mapped[User] = relationship( 

91 init=False, backref="created_events", foreign_keys="Event.creator_user_id" 

92 ) 

93 owner_user: Mapped[User | None] = relationship( 

94 init=False, backref="owned_events", foreign_keys="Event.owner_user_id" 

95 ) 

96 owner_cluster: Mapped[Cluster | None] = relationship( 

97 init=False, 

98 backref=backref("owned_events", lazy="dynamic"), 

99 uselist=False, 

100 foreign_keys="Event.owner_cluster_id", 

101 ) 

102 occurrences: DynamicMapped[EventOccurrence] = relationship(init=False, lazy="dynamic") 

103 

104 __table_args__ = ( 

105 # Only one of owner_user and owner_cluster should be set 

106 CheckConstraint( 

107 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)", 

108 name="one_owner", 

109 ), 

110 ) 

111 

112 

113class EventOccurrence(Base, kw_only=True): 

114 __tablename__ = "event_occurrences" 

115 __moderation_author_column__ = "creator_user_id" 

116 __moderation_object_type__ = ModerationObjectType.event_occurrence 

117 

118 id: Mapped[int] = mapped_column( 

119 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False 

120 ) 

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

122 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True) 

123 

124 # the user that created this particular occurrence of a repeating event (same as event.creator_user_id if single event) 

125 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

126 content: Mapped[str] = mapped_column(String) # CommonMark without images 

127 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), default=None) 

128 

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

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

131 

132 # a null geom is an online-only event 

133 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None) 

134 # physical address, iff geom is not null 

135 address: Mapped[str | None] = mapped_column(String, default=None) 

136 # videoconferencing link, etc, must be specified if no geom, otherwise optional 

137 link: Mapped[str | None] = mapped_column(String, default=None) 

138 

139 timezone = "Etc/UTC" 

140 

141 # time during which the event takes place; this is a range type (instead of separate start+end times) which 

142 # simplifies database constraints, etc 

143 during: Mapped[TimestamptzRange] = mapped_column(TSTZRANGE) 

144 

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

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

147 

148 creator_user: Mapped[User] = relationship( 

149 init=False, backref="created_event_occurrences", foreign_keys="EventOccurrence.creator_user_id" 

150 ) 

151 event: Mapped[Event] = relationship( 

152 init=False, 

153 back_populates="occurrences", 

154 remote_side="Event.id", 

155 foreign_keys="EventOccurrence.event_id", 

156 ) 

157 

158 photo: Mapped[Upload | None] = relationship(init=False) 

159 attendances: DynamicMapped[EventOccurrenceAttendee] = relationship( 

160 init=False, back_populates="occurrence", lazy="dynamic" 

161 ) 

162 community_invite_requests: DynamicMapped[EventCommunityInviteRequest] = relationship( 

163 init=False, back_populates="occurrence", lazy="dynamic" 

164 ) 

165 moderation_state: Mapped[ModerationState] = relationship(init=False) 

166 

167 __table_args__ = ( 

168 # Geom and address go together 

169 CheckConstraint( 

170 # geom and address are either both null or neither of them are null 

171 "(geom IS NULL) = (address IS NULL)", 

172 name="geom_iff_address", 

173 ), 

174 # Online-only events need a link, note that online events may also have a link 

175 CheckConstraint( 

176 # exactly oen of geom or link is non-null 

177 "(geom IS NULL) <> (link IS NULL)", 

178 name="link_or_geom", 

179 ), 

180 # Can't have overlapping occurrences in the same Event 

181 ExcludeConstraint(("event_id", "="), ("during", "&&"), name="event_occurrences_event_id_during_excl"), 

182 ) 

183 

184 @property 

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

186 # returns (lat, lng) or None 

187 return get_coordinates(self.geom) 

188 

189 @hybrid_property 

190 def start_time(self) -> datetime: 

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

192 

193 @start_time.inplace.expression 

194 @classmethod 

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

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

197 

198 @hybrid_property 

199 def end_time(self) -> datetime: 

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

201 

202 @end_time.inplace.expression 

203 @classmethod 

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

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

206 

207 

208class EventSubscription(Base, kw_only=True): 

209 """ 

210 Users' subscriptions to events 

211 """ 

212 

213 __tablename__ = "event_subscriptions" 

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

215 

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

217 

218 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

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

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

221 

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

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

224 

225 

226class EventOrganizer(Base, kw_only=True): 

227 """ 

228 Organizers for events 

229 """ 

230 

231 __tablename__ = "event_organizers" 

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

233 

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

235 

236 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

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

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

239 

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

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

242 

243 

244class AttendeeStatus(enum.Enum): 

245 going = enum.auto() 

246 

247 

248class EventOccurrenceAttendee(Base, kw_only=True): 

249 """ 

250 Attendees for events 

251 """ 

252 

253 __tablename__ = "event_occurrence_attendees" 

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

255 

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

257 

258 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

259 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True) 

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

261 attendee_status: Mapped[AttendeeStatus] = mapped_column(Enum(AttendeeStatus)) 

262 

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

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

265 

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

267 

268 

269class EventCommunityInviteRequest(Base, kw_only=True): 

270 """ 

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

272 """ 

273 

274 __tablename__ = "event_community_invite_requests" 

275 

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

277 

278 occurrence_id: Mapped[int] = mapped_column(ForeignKey("event_occurrences.id"), index=True) 

279 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

280 

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

282 

283 decided: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

284 decided_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None) 

285 approved: Mapped[bool | None] = mapped_column(Boolean, default=None) 

286 

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

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

289 

290 __table_args__ = ( 

291 # each user can only request once 

292 UniqueConstraint("occurrence_id", "user_id"), 

293 # each event can only have one notification sent out 

294 Index( 

295 "ix_event_community_invite_requests_unique", 

296 occurrence_id, 

297 unique=True, 

298 postgresql_where=and_(approved.is_not(None), approved == True), 

299 ), 

300 # decided and approved ought to be null simultaneously 

301 CheckConstraint( 

302 "((decided IS NULL) AND (decided_by_user_id IS NULL) AND (approved IS NULL)) OR \ 

303 ((decided IS NOT NULL) AND (decided_by_user_id IS NOT NULL) AND (approved IS NOT NULL))", 

304 name="decided_approved", 

305 ), 

306 )