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

150 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1import enum 

2from datetime import date, datetime, time 

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 Date, 

12 DateTime, 

13 Enum, 

14 ForeignKey, 

15 Index, 

16 Integer, 

17 String, 

18 Time, 

19 UniqueConstraint, 

20 and_, 

21 func, 

22) 

23from sqlalchemy.dialects.postgresql import TSTZRANGE, ExcludeConstraint 

24from sqlalchemy.ext.hybrid import hybrid_property 

25from sqlalchemy.orm import DynamicMapped, Mapped, backref, column_property, mapped_column, relationship 

26from sqlalchemy.sql import expression 

27from sqlalchemy.sql.elements import ColumnElement 

28 

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

30from couchers.models.moderation import ModerationObjectType 

31from couchers.utils import get_coordinates 

32 

33if TYPE_CHECKING: 

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

35 from couchers.models.moderation import ModerationState 

36 

37 

38class ClusterEventAssociation(Base, kw_only=True): 

39 """ 

40 events related to clusters 

41 """ 

42 

43 __tablename__ = "cluster_event_associations" 

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

45 

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

47 

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

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

50 

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

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

53 

54 

55class Event(Base, kw_only=True): 

56 """ 

57 An event is composed of two parts: 

58 

59 * An event template (Event) 

60 * An occurrence (EventOccurrence) 

61 

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

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

64 """ 

65 

66 __tablename__ = "events" 

67 

68 id: Mapped[int] = mapped_column( 

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

70 ) 

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

72 

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

74 

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

76 

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

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

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

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

81 

82 parent_node: Mapped[Node] = relationship( 

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

84 ) 

85 subscribers: DynamicMapped[User] = relationship( 

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

87 ) 

88 organizers: DynamicMapped[User] = relationship( 

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

90 ) 

91 creator_user: Mapped[User] = relationship( 

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

93 ) 

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

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

96 ) 

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

98 init=False, 

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

100 uselist=False, 

101 foreign_keys="Event.owner_cluster_id", 

102 ) 

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

104 recurrence: Mapped[EventRecurrence | None] = relationship(init=False, back_populates="event", uselist=False) 

105 

106 __table_args__ = ( 

107 # Only one of owner_user and owner_cluster should be set 

108 CheckConstraint( 

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

110 name="one_owner", 

111 ), 

112 ) 

113 

114 

115class EventOccurrence(Base, kw_only=True): 

116 __tablename__ = "event_occurrences" 

117 __moderation_author_column__ = "creator_user_id" 

118 __moderation_object_type__ = ModerationObjectType.event_occurrence 

119 __moderation_has_own_visibility_mechanism__ = False 

120 

121 id: Mapped[int] = mapped_column( 

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

123 ) 

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

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

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

127 

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

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

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

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

132 

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

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

135 

136 # The GPS coordinates of the event location 

137 geom: Mapped[Geom] = mapped_column(Geometry(geometry_type="POINT", srid=4326)) 

138 # The physical address string. Legacy online events have been migrated to put the link in here. 

139 address: Mapped[str] = mapped_column(String) 

140 

141 # IANA timezone identifier of the event 

142 timezone: Mapped[str] = mapped_column(String) 

143 

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

145 # simplifies database constraints, etc 

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

147 

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

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

150 

151 creator_user: Mapped[User] = relationship( 

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

153 ) 

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

155 event: Mapped[Event] = relationship( 

156 init=False, 

157 back_populates="occurrences", 

158 remote_side="Event.id", 

159 foreign_keys="EventOccurrence.event_id", 

160 ) 

161 

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

163 attendances: DynamicMapped[EventOccurrenceAttendee] = relationship( 

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

165 ) 

166 community_invite_requests: DynamicMapped[EventCommunityInviteRequest] = relationship( 

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

168 ) 

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

170 

171 __table_args__ = ( 

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

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

174 ) 

175 

176 @property 

177 def coordinates(self) -> tuple[float, float]: 

178 # returns (lat, lng) or None 

179 return get_coordinates(self.geom) 

180 

181 @hybrid_property 

182 def start_time(self) -> datetime: 

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

184 

185 @start_time.inplace.expression 

186 @classmethod 

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

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

189 

190 @hybrid_property 

191 def end_time(self) -> datetime: 

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

193 

194 @end_time.inplace.expression 

195 @classmethod 

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

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

198 

199 

200class EventRecurrence(Base, kw_only=True): 

201 """ 

202 Defines a template for future event occurrences and a recurrence rule for its parent Event. 

203 

204 The recurrence rule is based on a subset of the time-tested iCalendar RRULE standard (RFC 5545). 

205 Docs: https://icalendar.org/iCalendar-RFC-5545/3-3-10-recurrence-rule.html 

206 Online demo: https://jkbrzt.github.io/rrule/ 

207 

208 We use RRULEs for generating dates only (DTSTART is at 00:00), so they are timezone-agnostic. 

209 We separately apply a start + end time to generated occurrence dates. 

210 

211 We currently support weekly and biweekly events using the following properties: 

212 - DTSTART (date-only): For specifying the seed day for weekly events. 

213 It replaces RRULE.BYDAY for events that occur once weekly, 

214 and allows disambiguating which week is skipped for biweekly events. 

215 - RRULE.FREQ: Implicitly WEEKLY, so not stored 

216 - RRULE.INTERVAL: 1 for weekly, 2 for biweekly 

217 

218 Future rule extensibility: 

219 - Multiple days a week: Add RRULE.BYDAY supporting multiple values (e.g. "MO,TH") 

220 - nth <weekday> of month: Store RRULE.FREQ to support MONTHLY, add RRULE.BYDAY with "1MO" (first Monday) 

221 """ 

222 

223 __tablename__ = "event_recurrences" 

224 

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

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

227 

228 # Properties acting as templates for future EventOccurrence's 

229 # TODO(#9845): Add photo_key and photo. Implies adding admin infra for tracking uses. 

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

231 geom: Mapped[Geom] = mapped_column(Geometry(geometry_type="POINT", srid=4326)) 

232 address: Mapped[str] = mapped_column(String) 

233 timezone: Mapped[str] = mapped_column(String) 

234 

235 # Date part of the DTSTART in local time based on timezone. 

236 # Seeds the RRULE and disambiguates which week to skip for biweekly events. 

237 # Typically the date of latest occurrence where the RRULE.INTERVAL was changed. 

238 dtstart_date: Mapped[date] = mapped_column(Date) 

239 

240 # Start time of the event in local time based on timezone. 

241 start_time: Mapped[time] = mapped_column(Time) 

242 

243 # Number of days to the end of the event (typically zero) and local end time on that day. 

244 # This representation maintains the same end time for occurrences crossing a DST change. 

245 day_delta: Mapped[int] = mapped_column(Integer) 

246 end_time: Mapped[time] = mapped_column(Time) 

247 

248 # RRULE.INTERVAL: 1 for weekly, 2 for biweekly (FREQ=WEEKLY is implicit) 

249 rrule_interval: Mapped[int] = mapped_column(Integer) 

250 

251 # Last day on which an occurrence was scheduled, in local time based on timezone. 

252 # New occurrences are only scheduled for dates strictly after this. 

253 # Updated whenever an occurrence is scheduled to ensure idempotence. 

254 last_scheduled_date: Mapped[date] = mapped_column(Date) 

255 

256 # Last day on which an occurrence can be scheduled, in local time based on timezone. 

257 # Prevents unbounded recurrence. Feeds into the RRULE UNTIL property. 

258 ends_on_date: Mapped[date] = mapped_column(Date) 

259 

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

261 

262 event: Mapped[Event] = relationship(init=False, back_populates="recurrence") 

263 

264 __table_args__ = ( 

265 # Only weekly (1) and biweekly (2) are supported for now; see the class docstring. 

266 CheckConstraint("rrule_interval IN (1, 2)", name="rrule_interval_weekly_or_biweekly"), 

267 ) 

268 

269 @property 

270 def coordinates(self) -> tuple[float, float]: 

271 # returns (lat, lng) or None 

272 return get_coordinates(self.geom) 

273 

274 

275class EventSubscription(Base, kw_only=True): 

276 """ 

277 Users' subscriptions to events 

278 """ 

279 

280 __tablename__ = "event_subscriptions" 

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

282 

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

284 

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

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

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

288 

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

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

291 

292 

293class EventOrganizer(Base, kw_only=True): 

294 """ 

295 Organizers for events 

296 """ 

297 

298 __tablename__ = "event_organizers" 

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

300 

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

302 

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

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

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

306 

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

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

309 

310 

311class AttendeeStatus(enum.Enum): 

312 going = enum.auto() 

313 

314 

315class EventOccurrenceAttendee(Base, kw_only=True): 

316 """ 

317 Attendees for events 

318 """ 

319 

320 __tablename__ = "event_occurrence_attendees" 

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

322 

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

324 

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

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

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

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

329 

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

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

332 

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

334 

335 

336class EventCommunityInviteRequest(Base, kw_only=True): 

337 """ 

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

339 """ 

340 

341 __tablename__ = "event_community_invite_requests" 

342 

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

344 

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

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

347 

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

349 

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

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

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

353 

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

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

356 

357 __table_args__ = ( 

358 # each user can only request once 

359 UniqueConstraint("occurrence_id", "user_id"), 

360 # each event can only have one notification sent out 

361 Index( 

362 "ix_event_community_invite_requests_unique", 

363 occurrence_id, 

364 unique=True, 

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

366 ), 

367 # decided and approved ought to be null simultaneously 

368 CheckConstraint( 

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

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

371 name="decided_approved", 

372 ), 

373 )