Skip to content

Commit 5a2581e

Browse files
committed
feat: enhance scheduled event recurrence with validation and serialization tests
1 parent 7b2c31f commit 5a2581e

File tree

3 files changed

+171
-29
lines changed

3 files changed

+171
-29
lines changed

discord/enums.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,9 @@ class ScheduledEventEntityType(Enum):
966966
voice = 2
967967
external = 3
968968

969+
def __int__(self):
970+
return self.value
971+
969972

970973
class ScheduledEventRecurrenceFrequency(Enum):
971974
"""Scheduled event recurrence frequency"""
@@ -975,6 +978,9 @@ class ScheduledEventRecurrenceFrequency(Enum):
975978
weekly = 2
976979
daily = 3
977980

981+
def __int__(self):
982+
return self.value
983+
978984

979985
class ScheduledEventRecurrenceWeekday(Enum):
980986
"""Scheduled event recurrence weekday"""
@@ -987,6 +993,9 @@ class ScheduledEventRecurrenceWeekday(Enum):
987993
saturday = 5
988994
sunday = 6
989995

996+
def __int__(self):
997+
return self.value
998+
990999

9911000
class ScheduledEventRecurrenceMonth(Enum):
9921001
"""Scheduled event recurrence month"""
@@ -1004,6 +1013,9 @@ class ScheduledEventRecurrenceMonth(Enum):
10041013
november = 11
10051014
december = 12
10061015

1016+
def __int__(self):
1017+
return self.value
1018+
10071019

10081020
class AutoModTriggerType(Enum):
10091021
"""Automod trigger type"""

discord/guild.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4285,7 +4285,7 @@ async def create_scheduled_event(
42854285
"name": name,
42864286
"scheduled_start_time": scheduled_start_time.isoformat(),
42874287
"privacy_level": int(privacy_level),
4288-
"entity_type": int(entity_type.value),
4288+
"entity_type": int(entity_type),
42894289
}
42904290

42914291
if scheduled_end_time is not MISSING:

discord/scheduled_events.py

Lines changed: 158 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,14 @@ class ScheduledEventRecurrenceNWeekday:
118118
def __init__(self, *, n: int, day: ScheduledEventRecurrenceWeekday | int) -> None:
119119
self.n: int = n
120120
self.day: ScheduledEventRecurrenceWeekday = try_enum(
121-
ScheduledEventRecurrenceWeekday, day
121+
ScheduledEventRecurrenceWeekday, int(day)
122122
)
123123

124124
def __repr__(self) -> str:
125125
return f"<ScheduledEventRecurrenceNWeekday n={self.n} day={self.day}>"
126126

127127
def to_payload(self) -> dict[str, int]:
128-
return {"n": int(self.n), "day": int(self.day.value)}
128+
return {"n": int(self.n), "day": int(self.day)}
129129

130130

131131
class ScheduledEventRecurrenceRule:
@@ -174,38 +174,31 @@ def __init__(
174174
self,
175175
*,
176176
start: datetime.datetime,
177-
frequency: ScheduledEventRecurrenceFrequency,
177+
frequency: ScheduledEventRecurrenceFrequency | int,
178178
interval: int,
179179
end: datetime.datetime | None = None,
180180
by_weekday: list[ScheduledEventRecurrenceWeekday | int] | None = None,
181-
by_n_weekday: list[ScheduledEventRecurrenceNWeekday | dict[str, int]]
182-
| None = None,
181+
by_n_weekday: list[ScheduledEventRecurrenceNWeekday] | None = None,
183182
by_month: list[ScheduledEventRecurrenceMonth | int] | None = None,
184183
by_month_day: list[int] | None = None,
185184
by_year_day: list[int] | None = None,
186185
count: int | None = None,
187186
) -> None:
188187
self.start: datetime.datetime = start
189188
self.end: datetime.datetime | None = end
190-
self.frequency: ScheduledEventRecurrenceFrequency = frequency
189+
self.frequency: ScheduledEventRecurrenceFrequency = try_enum(
190+
ScheduledEventRecurrenceFrequency, int(frequency)
191+
)
191192
self.interval: int = interval
192193
self.by_weekday: list[ScheduledEventRecurrenceWeekday] | None = (
193-
[try_enum(ScheduledEventRecurrenceWeekday, day) for day in by_weekday]
194-
if by_weekday is not None
194+
[try_enum(ScheduledEventRecurrenceWeekday, int(day)) for day in by_weekday]
195+
if by_weekday
195196
else None
196197
)
197-
if by_n_weekday is not None:
198-
self.by_n_weekday = [
199-
entry
200-
if isinstance(entry, ScheduledEventRecurrenceNWeekday)
201-
else ScheduledEventRecurrenceNWeekday(**entry)
202-
for entry in by_n_weekday
203-
]
204-
else:
205-
self.by_n_weekday = None
198+
self.by_n_weekday: list[ScheduledEventRecurrenceNWeekday] | None = by_n_weekday
206199
self.by_month: list[ScheduledEventRecurrenceMonth] | None = (
207-
[try_enum(ScheduledEventRecurrenceMonth, month) for month in by_month]
208-
if by_month is not None
200+
[try_enum(ScheduledEventRecurrenceMonth, int(month)) for month in by_month]
201+
if by_month
209202
else None
210203
)
211204
self.by_month_day: list[int] | None = by_month_day
@@ -224,7 +217,13 @@ def from_data(
224217
) -> ScheduledEventRecurrenceRule:
225218
start = utils.parse_time(data["start"])
226219
end = utils.parse_time(data.get("end"))
227-
by_weekday = data.get("by_weekday")
220+
221+
raw_by_weekday = data.get("by_weekday")
222+
by_weekday = (
223+
[try_enum(ScheduledEventRecurrenceWeekday, day) for day in raw_by_weekday]
224+
if raw_by_weekday
225+
else None
226+
)
228227

229228
raw_by_n_weekday = data.get("by_n_weekday")
230229
by_n_weekday = (
@@ -233,39 +232,60 @@ def from_data(
233232
else None
234233
)
235234

235+
raw_by_month = data.get("by_month")
236+
by_month = (
237+
[try_enum(ScheduledEventRecurrenceMonth, month) for month in raw_by_month]
238+
if raw_by_month
239+
else None
240+
)
241+
236242
return cls(
237243
start=start,
238244
end=end,
239-
frequency=data["frequency"],
245+
frequency=try_enum(ScheduledEventRecurrenceFrequency, data["frequency"]),
240246
interval=data["interval"],
241247
by_weekday=by_weekday,
242248
by_n_weekday=by_n_weekday,
243-
by_month=data.get("by_month"),
249+
by_month=by_month,
244250
by_month_day=data.get("by_month_day"),
245251
by_year_day=data.get("by_year_day"),
246252
count=data.get("count"),
247253
)
248254

249255
def to_payload(self) -> dict[str, Any]:
256+
"""Convert the recurrence rule to an API payload.
257+
258+
Raises
259+
------
260+
ValidationError
261+
If the recurrence rule violates Discord's system limitations.
262+
263+
Returns
264+
-------
265+
dict[str, Any]
266+
The recurrence rule as a dictionary suitable for the Discord API.
267+
"""
268+
self.validate()
269+
250270
payload: dict[str, Any] = {
251271
"start": self.start.isoformat(),
252-
"frequency": int(self.frequency.value),
272+
"frequency": self.frequency.value,
253273
"interval": int(self.interval),
254274
}
255275

256276
if self.end is not None:
257277
payload["end"] = self.end.isoformat()
258278

259279
if self.by_weekday is not None:
260-
payload["by_weekday"] = [int(day.value) for day in self.by_weekday]
280+
payload["by_weekday"] = [int(day) for day in self.by_weekday]
261281

262282
if self.by_n_weekday is not None:
263283
payload["by_n_weekday"] = [
264284
entry.to_payload() for entry in self.by_n_weekday
265285
]
266286

267287
if self.by_month is not None:
268-
payload["by_month"] = [int(month.value) for month in self.by_month]
288+
payload["by_month"] = [int(month) for month in self.by_month]
269289

270290
if self.by_month_day is not None:
271291
payload["by_month_day"] = self.by_month_day
@@ -278,6 +298,116 @@ def to_payload(self) -> dict[str, Any]:
278298

279299
return payload
280300

301+
def validate(self) -> None:
302+
"""Validate the recurrence rule against Discord's system limitations.
303+
304+
Raises
305+
------
306+
ValidationError
307+
If the recurrence rule violates any system limitations.
308+
"""
309+
# Mutually exclusive combinations
310+
has_by_weekday = self.by_weekday is not None
311+
has_by_n_weekday = self.by_n_weekday is not None
312+
has_by_month = self.by_month is not None
313+
has_by_month_day = self.by_month_day is not None
314+
315+
if has_by_weekday and has_by_n_weekday:
316+
raise ValidationError("by_weekday and by_n_weekday are mutually exclusive")
317+
318+
if has_by_month and has_by_n_weekday:
319+
raise ValidationError("by_month and by_n_weekday are mutually exclusive")
320+
321+
if has_by_month != has_by_month_day:
322+
raise ValidationError(
323+
"by_month and by_month_day must both be provided together"
324+
)
325+
326+
# Daily frequency (0) constraints
327+
if self.frequency == ScheduledEventRecurrenceFrequency.yearly:
328+
if has_by_weekday:
329+
raise ValidationError("by_weekday is not valid for yearly events")
330+
331+
# Weekly frequency (2) constraints
332+
if self.frequency == ScheduledEventRecurrenceFrequency.weekly:
333+
if has_by_weekday:
334+
if len(self.by_weekday) != 1:
335+
raise ValidationError(
336+
"by_weekday must have exactly 1 day for weekly events"
337+
)
338+
339+
if has_by_n_weekday:
340+
raise ValidationError("by_n_weekday is not valid for weekly events")
341+
342+
if has_by_month or has_by_month_day:
343+
raise ValidationError(
344+
"by_month and by_month_day are not valid for weekly events"
345+
)
346+
347+
# interval can only be 2 (every-other week) or 1 (weekly)
348+
if self.interval not in (1, 2):
349+
raise ValidationError("interval for weekly events can only be 1 or 2")
350+
351+
# Daily frequency (3) constraints
352+
if self.frequency == ScheduledEventRecurrenceFrequency.daily:
353+
if has_by_n_weekday:
354+
raise ValidationError("by_n_weekday is not valid for daily events")
355+
356+
if has_by_month or has_by_month_day:
357+
raise ValidationError(
358+
"by_month and by_month_day are not valid for daily events"
359+
)
360+
361+
if has_by_weekday:
362+
# Validate known sets of weekdays for daily events
363+
allowed_sets = [
364+
[0, 1, 2, 3, 4], # Monday - Friday
365+
[1, 2, 3, 4, 5], # Tuesday - Saturday
366+
[6, 0, 1, 2, 3], # Sunday - Thursday
367+
[4, 5], # Friday & Saturday
368+
[5, 6], # Saturday & Sunday
369+
[6, 0], # Sunday & Monday
370+
]
371+
weekday_values = [day.value for day in self.by_weekday]
372+
weekday_values.sort()
373+
374+
if weekday_values not in allowed_sets:
375+
raise ValidationError(
376+
"by_weekday for daily events must be one of the allowed sets: "
377+
"[0,1,2,3,4], [1,2,3,4,5], [6,0,1,2,3], [4,5], [5,6], [6,0]"
378+
)
379+
380+
# Monthly frequency (1) constraints
381+
if self.frequency == ScheduledEventRecurrenceFrequency.monthly:
382+
if has_by_n_weekday:
383+
if len(self.by_n_weekday) != 1:
384+
raise ValidationError(
385+
"by_n_weekday must have exactly 1 entry for monthly events"
386+
)
387+
388+
if has_by_weekday:
389+
raise ValidationError("by_weekday is not valid for monthly events")
390+
391+
if has_by_month or has_by_month_day:
392+
raise ValidationError(
393+
"by_month and by_month_day are not valid for monthly events"
394+
)
395+
396+
# Yearly frequency (0) constraints
397+
if self.frequency == ScheduledEventRecurrenceFrequency.yearly:
398+
if has_by_n_weekday:
399+
raise ValidationError("by_n_weekday is not valid for yearly events")
400+
401+
if not (has_by_month and has_by_month_day):
402+
raise ValidationError(
403+
"by_month and by_month_day must both be provided for yearly events"
404+
)
405+
406+
if len(self.by_month) != 1 or len(self.by_month_day) != 1:
407+
raise ValidationError(
408+
"by_month and by_month_day must each have exactly 1 entry for yearly events"
409+
)
410+
281411

282412
class ScheduledEvent(Hashable):
283413
"""Represents a Discord Guild Scheduled Event.
@@ -554,13 +684,13 @@ async def edit(
554684
payload["description"] = description
555685

556686
if status is not MISSING:
557-
payload["status"] = int(status.value)
687+
payload["status"] = int(status)
558688

559689
if entity_type is not MISSING:
560-
payload["entity_type"] = int(entity_type.value)
690+
payload["entity_type"] = int(entity_type)
561691

562692
if privacy_level is not MISSING:
563-
payload["privacy_level"] = int(privacy_level.value)
693+
payload["privacy_level"] = int(privacy_level)
564694

565695
if entity_metadata is not MISSING:
566696
if entity_metadata is None:

0 commit comments

Comments
 (0)