149 lines
7.7 KiB
Python
149 lines
7.7 KiB
Python
"""Генерация .ics-приглашений на конференцию (VEVENT, `METHOD:REQUEST`).
|
||
|
||
Разовая (плановая) конференция — `DTSTART`/`DTEND` в переданной таймзоне
|
||
отображения (`display_timezone` настройки инстанса);
|
||
закреплённая с повторением — `DTSTART` берётся из первого вхождения
|
||
`expand_occurrences` (см. `services/recurrence.py`) в таймзоне самого правила
|
||
повторения (`rule.timezone`), а рецидив описывается `RRULE`.
|
||
|
||
`UID` стабилен (`{conference.id}@vidconf`) — календарные клиенты обновляют уже
|
||
принятое приглашение по нему же, ориентируясь на растущий `SEQUENCE`
|
||
(`conference.ics_sequence`, инкрементируется при правке расписания —
|
||
`services/conferences.py::ConferenceService.update`).
|
||
"""
|
||
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import cast
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from icalendar import Calendar, Event
|
||
|
||
from models.conference import Conference
|
||
from services.recurrence import RecurrenceRule, expand_occurrences
|
||
|
||
# Порядок ровно как в `date.weekday()`/`RecurrenceRule.weekdays` (0 — понедельник).
|
||
_WEEKDAY_CODES = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")
|
||
|
||
# Длительность разового вхождения по умолчанию, если у конференции не указан
|
||
# `duration_minutes` (совпадает с `services.conferences.DEFAULT_OCCURRENCE_DURATION_MINUTES`,
|
||
# не импортируется напрямую — `ics.py` намеренно не зависит от `conferences.py`).
|
||
_DEFAULT_ONE_OFF_DURATION_MINUTES = 60
|
||
|
||
# Горизонт поиска первого вхождения повторяющейся серии от `anchor_date`
|
||
# (совпадает с `services.conferences.NEXT_OCCURRENCE_HORIZON`) — с запасом
|
||
# покрывает самый частый шаг повторения (weekly/monthly).
|
||
_OCCURRENCE_SEARCH_HORIZON = timedelta(days=400)
|
||
|
||
_PRODID = "-//VidConf//vidconf.example//RU"
|
||
|
||
|
||
class ConferenceHasNoScheduleError(ValueError):
|
||
"""У конференции нет ни `scheduled_at`, ни `recurrence` — приглашение строить не из чего."""
|
||
|
||
|
||
def build_invite(
|
||
conference: Conference,
|
||
*,
|
||
organizer_email: str | None,
|
||
join_url: str,
|
||
display_timezone: str,
|
||
) -> bytes:
|
||
"""Собрать .ics-приглашение (`METHOD:REQUEST`) на конференцию и вернуть его байты.
|
||
|
||
`display_timezone` — таймзона отображения разовых конференций (настройка
|
||
инстанса); для повторяющихся используется таймзона самого правила
|
||
(`RecurrenceRule.timezone`) — она обязательна в правиле и корректнее
|
||
отражает намерение организатора серии.
|
||
"""
|
||
if conference.recurrence is not None:
|
||
rule = RecurrenceRule.model_validate(conference.recurrence)
|
||
tz = ZoneInfo(rule.timezone)
|
||
dtstart = _first_occurrence_utc(rule).astimezone(tz)
|
||
dtend = dtstart + timedelta(minutes=rule.duration_minutes)
|
||
rrule_value: str | None = _build_rrule(rule)
|
||
elif conference.scheduled_at is not None:
|
||
tz = ZoneInfo(display_timezone)
|
||
dtstart = conference.scheduled_at.astimezone(tz)
|
||
duration = conference.duration_minutes or _DEFAULT_ONE_OFF_DURATION_MINUTES
|
||
dtend = dtstart + timedelta(minutes=duration)
|
||
rrule_value = None
|
||
else:
|
||
raise ConferenceHasNoScheduleError(
|
||
f"у конференции {conference.id} нет ни scheduled_at, ни recurrence"
|
||
)
|
||
|
||
# `Component.__init__` (общий предок `Event`/`Calendar`) не типизирован в
|
||
# `icalendar` — `no-untyped-call` здесь неизбежен без переписывания
|
||
# конструктора сторонней библиотеки.
|
||
event = Event() # type: ignore[no-untyped-call]
|
||
event.add("uid", f"{conference.id}@vidconf")
|
||
event.add("sequence", conference.ics_sequence)
|
||
event.add("dtstamp", datetime.now(UTC))
|
||
event.add("summary", conference.title or f"Конференция {conference.number}")
|
||
event.add(
|
||
"description",
|
||
f"Ссылка для входа: {join_url}\nНомер конференции: {conference.number}",
|
||
)
|
||
event.add("dtstart", dtstart)
|
||
event.add("dtend", dtend)
|
||
if organizer_email:
|
||
event.add("organizer", f"mailto:{organizer_email}")
|
||
if rrule_value is not None:
|
||
event.add("rrule", rrule_value)
|
||
|
||
calendar = Calendar() # type: ignore[no-untyped-call]
|
||
calendar.add("prodid", _PRODID)
|
||
calendar.add("version", "2.0")
|
||
calendar.add("method", "REQUEST")
|
||
calendar.add_component(event)
|
||
calendar.add_missing_timezones()
|
||
|
||
return cast(bytes, calendar.to_ical())
|
||
|
||
|
||
def _first_occurrence_utc(rule: RecurrenceRule) -> datetime:
|
||
"""Найти первое вхождение серии от `rule.anchor_date` (UTC, aware)."""
|
||
horizon = _OCCURRENCE_SEARCH_HORIZON
|
||
if rule.type == "every_n_days" and rule.interval_days is not None:
|
||
horizon = max(horizon, timedelta(days=rule.interval_days + 2))
|
||
t_from = rule.local_datetime(rule.anchor_date).astimezone(UTC)
|
||
occurrences = expand_occurrences(rule, t_from, t_from + horizon)
|
||
if not occurrences:
|
||
raise ConferenceHasNoScheduleError(
|
||
"правило повторения не даёт ни одного вхождения в горизонте поиска"
|
||
)
|
||
return occurrences[0]
|
||
|
||
|
||
def _build_rrule(rule: RecurrenceRule) -> str:
|
||
"""Собрать значение `RRULE` из `RecurrenceRule` («.ics»)."""
|
||
if rule.type == "weekly":
|
||
return f"FREQ=WEEKLY;BYDAY={_byday(rule.weekdays)}"
|
||
if rule.type == "biweekly":
|
||
return f"FREQ=WEEKLY;INTERVAL=2;BYDAY={_byday(rule.weekdays)};WKST=MO"
|
||
if rule.type == "monthly":
|
||
assert rule.day_of_month is not None # гарантировано валидацией RecurrenceRule
|
||
return f"FREQ=MONTHLY;BYMONTHDAY={_bymonthday(rule.day_of_month)}"
|
||
if rule.type == "every_n_days":
|
||
assert rule.interval_days is not None # гарантировано валидацией RecurrenceRule
|
||
return f"FREQ=DAILY;INTERVAL={rule.interval_days}"
|
||
raise AssertionError(f"неизвестный тип повторения: {rule.type}")
|
||
|
||
|
||
def _byday(weekdays: list[int]) -> str:
|
||
"""Список дней недели в порядок `BYDAY` (`MO,TU,...`)."""
|
||
return ",".join(_WEEKDAY_CODES[weekday] for weekday in sorted(weekdays))
|
||
|
||
|
||
def _bymonthday(day_of_month: int) -> int:
|
||
"""Смаппить `day_of_month` правила в `BYMONTHDAY` .ics.
|
||
|
||
`31` не встречается ни в одном месяце короче — маппится в `-1`
|
||
(последний день месяца), что СОВПАДАЕТ с клэмпом `expand_occurrences`.
|
||
`29`/`30` НЕ клэмпаются здесь: известное задокументированное расхождение
|
||
с поведением `expand_occurrences` — календарный
|
||
клиент в короткие месяцы (например, февраль) вхождение пропустит, тогда
|
||
как `expand_occurrences` использует клэмп к последнему дню месяца.
|
||
"""
|
||
return -1 if day_of_month == 31 else day_of_month
|