"""Recurrence-ядро для закреплённых конференций (ADR-001, п.3). Собственная (не RRULE) модель повторения: ровно 4 типа, отображающихся на форму UI 1:1. Правило хранит локальное время суток и IANA-таймзону — чтобы корректно учитывать смещение UTC (переход на летнее/зимнее время в тех зонах, где он есть); развёртка `expand_occurrences` всегда возвращает timezone-aware метки в UTC (в БД — только UTC). """ import calendar from datetime import UTC, date, datetime, time, timedelta from typing import Literal, Self from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import BaseModel, Field, field_validator, model_validator RecurrenceType = Literal["weekly", "biweekly", "monthly", "every_n_days"] class RecurrenceRule(BaseModel): """Правило повторения закреплённой конференции. Поля, специфичные для типа (`weekdays` для weekly/biweekly, `day_of_month` для monthly, `interval_days` для every_n_days), валидируются в `_validate_type_specific_fields` — обязательность зависит от `type`. """ type: RecurrenceType weekdays: list[int] = Field(default_factory=list) day_of_month: int | None = None interval_days: int | None = None anchor_date: date time_local: str timezone: str duration_minutes: int @field_validator("weekdays") @classmethod def _validate_weekdays(cls, value: list[int]) -> list[int]: """Дни недели — 0 (понедельник) .. 6 (воскресенье), как в `date.weekday()`.""" for weekday in value: if not 0 <= weekday <= 6: raise ValueError("weekday должен быть в диапазоне 0..6") return value @field_validator("day_of_month") @classmethod def _validate_day_of_month(cls, value: int | None) -> int | None: if value is not None and not 1 <= value <= 31: raise ValueError("day_of_month должен быть в диапазоне 1..31") return value @field_validator("interval_days") @classmethod def _validate_interval_days(cls, value: int | None) -> int | None: if value is not None and value < 1: raise ValueError("interval_days должен быть >= 1") return value @field_validator("time_local") @classmethod def _validate_time_local(cls, value: str) -> str: try: datetime.strptime(value, "%H:%M") except ValueError as exc: raise ValueError("time_local должен быть в формате HH:MM") from exc return value @field_validator("timezone") @classmethod def _validate_timezone(cls, value: str) -> str: try: ZoneInfo(value) except (ZoneInfoNotFoundError, ValueError) as exc: raise ValueError(f"неизвестная IANA-таймзона: {value}") from exc return value @model_validator(mode="after") def _validate_type_specific_fields(self) -> Self: """Проверить, что поля, обязательные для конкретного `type`, заполнены.""" if self.type in ("weekly", "biweekly"): if not self.weekdays: raise ValueError(f"{self.type} требует непустой список weekdays") elif self.type == "monthly": if self.day_of_month is None: raise ValueError("monthly требует day_of_month") elif self.type == "every_n_days": if self.interval_days is None: raise ValueError("every_n_days требует interval_days") return self def time_of_day(self) -> time: """Разобрать `time_local` в объект `time`.""" hours, minutes = self.time_local.split(":") return time(int(hours), int(minutes)) def local_datetime(self, on_date: date) -> datetime: """Собрать локальный aware datetime для конкретной календарной даты.""" return datetime.combine(on_date, self.time_of_day(), tzinfo=ZoneInfo(self.timezone)) def _clamped_day_of_month(year: int, month: int, day_of_month: int) -> int: """Клэмпнуть `day_of_month` к последнему дню месяца, если тот короче (например, 31 в апреле).""" last_day = calendar.monthrange(year, month)[1] return min(day_of_month, last_day) def _week_start(on_date: date) -> date: """Начало недели (понедельник), содержащей `on_date`.""" return on_date - timedelta(days=on_date.weekday()) def _matches(rule: RecurrenceRule, on_date: date) -> bool: """Проверить, попадает ли календарная дата `on_date` в правило повторения.""" if rule.type == "weekly": return on_date.weekday() in rule.weekdays if rule.type == "biweekly": if on_date.weekday() not in rule.weekdays: return False weeks_diff = (_week_start(on_date) - _week_start(rule.anchor_date)).days // 7 return weeks_diff % 2 == 0 if rule.type == "monthly": assert rule.day_of_month is not None # гарантировано валидацией модели return on_date.day == _clamped_day_of_month(on_date.year, on_date.month, rule.day_of_month) if rule.type == "every_n_days": assert rule.interval_days is not None # гарантировано валидацией модели # anchor_date — первое вхождение серии: даты раньше него не считаются # (иначе `%` на отрицательной разнице даст даты «до начала» серии). if on_date < rule.anchor_date: return False return (on_date - rule.anchor_date).days % rule.interval_days == 0 raise AssertionError(f"неизвестный тип повторения: {rule.type}") def expand_occurrences(rule: RecurrenceRule, t_from: datetime, t_to: datetime) -> list[datetime]: """Развернуть правило повторения в список моментов начала вхождений (UTC, aware). Диапазон `[t_from, t_to]` включителен с обеих сторон. `t_from`/`t_to` обязаны быть timezone-aware. При `t_from > t_to` возвращается пустой список. Перебор идёт по календарным датам в локальной таймзоне правила с суточным запасом с каждой стороны — компенсирует случаи, когда смещение локальной зоны отличается от зоны границ диапазона настолько, что вхождение попадает в диапазон, а его календарная дата — нет. """ if t_from.tzinfo is None or t_to.tzinfo is None: raise ValueError("t_from и t_to должны быть timezone-aware") if t_from > t_to: return [] tz = ZoneInfo(rule.timezone) start_date = (t_from.astimezone(tz) - timedelta(days=1)).date() end_date = (t_to.astimezone(tz) + timedelta(days=1)).date() occurrences: list[datetime] = [] current = start_date while current <= end_date: if _matches(rule, current): candidate = rule.local_datetime(current).astimezone(UTC) if t_from <= candidate <= t_to: occurrences.append(candidate) current += timedelta(days=1) occurrences.sort() return occurrences