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

1import enum 

2 

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 

22 

23from couchers.models.base import Base, communities_seq 

24from couchers.utils import get_coordinates 

25 

26 

27class ClusterEventAssociation(Base): 

28 """ 

29 events related to clusters 

30 """ 

31 

32 __tablename__ = "cluster_event_associations" 

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

34 

35 id = Column(BigInteger, primary_key=True) 

36 

37 event_id = Column(ForeignKey("events.id"), nullable=False, index=True) 

38 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True) 

39 

40 event = relationship("Event", backref="cluster_event_associations") 

41 cluster = relationship("Cluster", backref="cluster_event_associations") 

42 

43 

44class Event(Base): 

45 """ 

46 An event is composed of two parts: 

47 

48 * An event template (Event) 

49 * An occurrence (EventOccurrence) 

50 

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

54 

55 __tablename__ = "events" 

56 

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) 

59 

60 title = Column(String, nullable=False) 

61 

62 slug = column_property(func.slugify(title)) 

63 

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) 

69 

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 ) 

88 

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 ) 

96 

97 

98class EventOccurrence(Base): 

99 __tablename__ = "event_occurrences" 

100 

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) 

103 

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) 

108 

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

111 

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) 

118 

119 timezone = "Etc/UTC" 

120 

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) 

124 

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

127 

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 ) 

137 

138 photo = relationship("Upload") 

139 

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 ) 

156 

157 @property 

158 def coordinates(self): 

159 # returns (lat, lng) or None 

160 return get_coordinates(self.geom) 

161 

162 @hybrid_property 

163 def start_time(self): 

164 return self.during.lower 

165 

166 @start_time.expression 

167 def start_time(cls): 

168 return func.lower(cls.during) 

169 

170 @hybrid_property 

171 def end_time(self): 

172 return self.during.upper 

173 

174 @end_time.expression 

175 def end_time(cls): 

176 return func.upper(cls.during) 

177 

178 

179class EventSubscription(Base): 

180 """ 

181 Users' subscriptions to events 

182 """ 

183 

184 __tablename__ = "event_subscriptions" 

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

186 

187 id = Column(BigInteger, primary_key=True) 

188 

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

192 

193 user = relationship("User") 

194 event = relationship("Event") 

195 

196 

197class EventOrganizer(Base): 

198 """ 

199 Organizers for events 

200 """ 

201 

202 __tablename__ = "event_organizers" 

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

204 

205 id = Column(BigInteger, primary_key=True) 

206 

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

210 

211 user = relationship("User") 

212 event = relationship("Event") 

213 

214 

215class AttendeeStatus(enum.Enum): 

216 going = enum.auto() 

217 maybe = enum.auto() 

218 

219 

220class EventOccurrenceAttendee(Base): 

221 """ 

222 Attendees for events 

223 """ 

224 

225 __tablename__ = "event_occurrence_attendees" 

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

227 

228 id = Column(BigInteger, primary_key=True) 

229 

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) 

234 

235 user = relationship("User") 

236 occurrence = relationship("EventOccurrence", backref=backref("attendances", lazy="dynamic")) 

237 

238 reminder_sent = Column(Boolean, nullable=False, default=False, server_default=expression.false()) 

239 

240 

241class EventCommunityInviteRequest(Base): 

242 """ 

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

244 """ 

245 

246 __tablename__ = "event_community_invite_requests" 

247 

248 id = Column(BigInteger, primary_key=True) 

249 

250 occurrence_id = Column(ForeignKey("event_occurrences.id"), nullable=False, index=True) 

251 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

252 

253 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

254 

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) 

258 

259 occurrence = relationship("EventOccurrence", backref=backref("community_invite_requests", lazy="dynamic")) 

260 user = relationship("User", foreign_keys="EventCommunityInviteRequest.user_id") 

261 

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 )