Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
"""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