Files

149 lines
7.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Генерация .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