Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (hourly/daily/weekly/monthly/annual), материализацию вхождений в списке, навели порядок с правами отделов и обновили фронтовой шаблон. Покрыли API pytest-тестами и починили мелкие баги.
### Ключевые изменения
**Модель**
* Заменили `CompanyEvent` и `DepartmentEvent` на единую **`CalendarEvent`**:
* `department` (nullable FK) — если задан, событие отдела; если `NULL`, событие компании.
* Даты/время: `start_date`, `end_date`, `start_time`, `end_time`, `all_day`.
* Повторяемость: `recurrence` ∈ {`one_time`, `hourly`, `daily`, `weekly`, `monthly`, `annual`}.
* Параметры повторов: `recurrence_interval` (int>0), `recurrence_until` (date), `recurrence_count` (int), `weekdays_mask` (bitmask для weekly).
* Индексы под выборки и сортировку; check-constraint `cal_event_date_time_range_valid` (валидность диапазонов даты/времени; для одного дня `start_time <= end_time` при наличии обоих).
* Поля оформления: `title`, `description`, `color`, `location`, `created_by`, `created_at`.
* Правка по умолчаниям: `weekdays_mask` теперь с безопасным default=0 (исправляет `NOT NULL` в SQLite).
**Сервисы**
* Добавлен сервис `expand_event_occurrences(event, range_start, range_end, max_instances=1000)`:
* Материализует все вхождения события в окне `[range_start, range_end)`.
* Корректно обрабатывает: leap day (29 Feb), месяцы с «дырками» (31-е), еженедельные маски, ежечасные интервалы.
* Выдаёт ровные границы дат/датавремени для FullCalendar.
**Сериализаторы**
* `CalendarEventWriteSerializer` — строгая валидация:
* Для hourly требуется `start_time` и `end_time`.
* Для weekly — пустой/непустой `weekdays` валидируется и мапится в `weekdays_mask`.
* `recurrence_interval` > 0; взаимоисключение `recurrence_until` и `recurrence_count`.
* `CalendarEventSerializer` — read-вариант с плоскими полями для фронта.
* Доп. сериализатор вьюхи для списка не нужен — материализуем в контроллере (см. ниже).
**ViewSet / API**
* Новый единый роут: **`/api/v1/calendar/events/`** (`ModelViewSet`):
* `list`: возвращает **материализованные** вхождения в окне `[start, end)` (query-параметры `YYYY-MM-DD`). Если передан `department_id` — фильтрует события отдела, иначе — компании.
* `retrieve/create/update/partial_update/destroy`: операции над самим `CalendarEvent`.
* `get_queryset`: для `list` фильтрует по department/company, для detail — полный QS.
* При `create`: подставляет `department_id` либо из контекста/параметров, либо из body, проставляет `created_by`, чистит кеш.
* **Права**:
* Используется комбинированный пермишен **`ManageCalendarPerm(AdminOrDeptAllowed)`**.
* Для компании делегирует на `AdminOrActionOrModelPerms`.
* Для отдела требует **строковый код**: `"manage_department_events"`.
* Обновляет инстанс пользователя из БД (сбрасывает кэши), чтобы в одном тесте «выдали право → сразу работает».
* `get_permissions()` → `[IsAuthenticated(), ManageCalendarPerm()]`.
* Починки:
* Подключены `parse_date`/`status` где нужно.
* Исправлен конфликт дат/датавремени в материализации.
* Возврат полей в `list`: `start`, `end`, `allDay`, `color`, `recurrence`, `department_id` (ID родителя можно оставить как `event.id`).
**Signals**
* Добавлен сигнал на изменение `date_of_birth` сотрудника:
* Создаёт/обновляет/удаляет соответствующее ежегодное событие в **глобальном** календаре.
* Идентификация через `source="employee_birthday"` + `source_ref=<employee_id>`.
**Админка**
* `CalendarEventAdmin`:
* Поиск по названию/описанию/локации.
* Фильтры по отделу, типу повторения, all\_day, наличию времени, «company vs department».
* Инлайнов нет; list-представление упрощено (исправлены ошибки `admin.E116` — фильтры теперь валидные `SimpleListFilter`/поля).
**Фронт (шаблон правой колонки)**
* Переписан `<script>` под единый эндпоинт:
* Список: GET `/api/v1/calendar/events/?[department_id=...]&start=YYYY-MM-DD&end=YYYY-MM-DD`.
* Создание:
* Компания: POST `/api/v1/calendar/events/` с `{title, start_date, [end_date], all_day}`.
* Отдел: POST туда же, но с `department_id` (в query или в body).
* Если указано время — **оба** (`start_time`, `end_time`) обязательны; иначе сохраняем как all-day.
* Нормализация дат в локали, аккуратные конвертации для FullCalendar.
* Заголовки авторизации: `Authorization: Bearer <token>` из `<meta name="api-access">`/`localStorage.api.access`. Добавлен fallback на CSRF-куку при отсутствии токена (для SessionAuth).
* Улучшен UI выбора отдела (`/api/v1/departments/my-departments/`), подписи, недельная лента.
**Тесты**
* Добавлены/актуализированы pytest-тесты API:
* Роуты и методы (CRUD).
* Позитив/негатив создания (валидация дат/времени/повторов).
* Изменение повторяемости (weekly → daily и пр.).
* Материализация (hourly alignment, weekly mask, monthly edge-cases, leap day).
* Права: отдел без/с `manage_department_events` (фикс за счёт строкового кода).
* Сигналы: апсерт/удаление дней рождения.
* Исправлены прежние падения: `NOT NULL weekdays_mask`, `NameError(parse_date/status)`, сравнения date/datetime, несоответствие `all_day` vs `allDay`, и др.
### Совместимость и заметки по деплою
* **BREAKING**: единый эндпоинт `/api/v1/calendar/events/` заменяет старые `/company-events/` и `/department-events/`. Если есть сторонние клиенты — переориентировать на новый.
* Миграции обязательны (новая таблица, индексы, дефолты).
* Если где-то ещё использовались константы прав — убедиться, что везде строка `"manage_department_events"` (тесты ориентируются на неё).
* Для фронта: убедиться, что реально прокидывается access-токен в `<meta name="api-access">` **или** есть валидная CSRF-кука при SessionAuth. Иначе POST вернёт 401.
### Почему так
* Единая модель/роут упрощают код и устраняют разъезд схем компании/отделов.
* Материализация в контроллере избавляет фронт от логики повторов и держит поведение единообразным.
* Явная строка кода права синхронизирована с тестовой выдачей пермишена и реальным хранилищем прав отделов.
* Валидация сериализатора закрывает большую часть негативных кейсов на уровне API.
262 lines
9.5 KiB
Python
262 lines
9.5 KiB
Python
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, time, timedelta
|
||
from typing import List, Optional, Union
|
||
|
||
from calendar_app.models import CalendarEvent, Recurrence
|
||
|
||
|
||
@dataclass
|
||
class Occurrence:
|
||
"""Материализованное вхождение события.
|
||
|
||
Attributes:
|
||
start (datetime|date): Начало вхождения.
|
||
end (datetime|date): Конец вхождения (для дат — конец дня, для datetime — точный момент).
|
||
all_day (bool): Признак события на весь день.
|
||
|
||
Optional Meta (можно не задавать при создании):
|
||
id (int|None)
|
||
title (str|None)
|
||
color (str|None)
|
||
recurrence (str|None)
|
||
department_id (int|None)
|
||
"""
|
||
start: Union[datetime, date]
|
||
end: Union[datetime, date]
|
||
all_day: bool
|
||
|
||
id: Optional[int] = None
|
||
title: Optional[str] = None
|
||
color: Optional[str] = None
|
||
recurrence: Optional[str] = None
|
||
department_id: Optional[int] = None
|
||
|
||
@property
|
||
def allDay(self) -> bool:
|
||
"""Legacy-алиас для совместимости с camelCase."""
|
||
return self.all_day
|
||
|
||
|
||
# ===== вспомогательные функции работы с датами/временем =====
|
||
|
||
def _clamp_day(year: int, month: int, day: int) -> date:
|
||
"""Безопасно подбирает день месяца (31→30→29→28)."""
|
||
for d in (day, 30, 29, 28):
|
||
try:
|
||
return date(year, month, min(d, day))
|
||
except ValueError:
|
||
continue
|
||
return date(year, month, 28)
|
||
|
||
|
||
def _add_months(d: date, months: int) -> date:
|
||
y = d.year + (d.month - 1 + months) // 12
|
||
m = (d.month - 1 + months) % 12 + 1
|
||
return _clamp_day(y, m, d.day)
|
||
|
||
|
||
def _add_years(d: date, years: int) -> date:
|
||
y = d.year + years
|
||
m = d.month
|
||
dd = d.day
|
||
if m == 2 and dd == 29:
|
||
try:
|
||
return date(y, 2, 29)
|
||
except ValueError:
|
||
return date(y, 2, 28)
|
||
return date(y, m, dd)
|
||
|
||
|
||
def _overlaps(a_start, a_end, b_start, b_end) -> bool:
|
||
"""Проверяет пересечение двух интервалов одинакового типа (оба date либо оба datetime)."""
|
||
return a_start < b_end and a_end > b_start
|
||
|
||
|
||
# ===== основная функция развёртки =====
|
||
|
||
def expand_event_occurrences(
|
||
ev: CalendarEvent,
|
||
range_start: Union[date, datetime],
|
||
range_end: Union[date, datetime],
|
||
max_instances: int = 1000,
|
||
) -> List[Occurrence]:
|
||
"""Материализует вхождения события в окне [range_start, range_end).
|
||
|
||
Args:
|
||
ev: Экземпляр CalendarEvent.
|
||
range_start: Левая граница окна (включительно), допускается date или datetime.
|
||
range_end: Правая граница окна (исключительно), допускается date или datetime.
|
||
max_instances: Жёсткий лимит количества вхождений (для безопасности).
|
||
|
||
Returns:
|
||
list[Occurrence]: Список вхождений.
|
||
|
||
Notes:
|
||
- Для all-day события используется сравнение по датам.
|
||
- Для событий с временем — сравнение по datetime (окно приводится к datetime).
|
||
- Минимальная длительность: 1 день для all-day, 1 минута для timed.
|
||
"""
|
||
# пустое окно
|
||
if isinstance(range_start, datetime) and isinstance(range_end, datetime):
|
||
if range_start >= range_end:
|
||
return []
|
||
else:
|
||
# приводим к датам для проверки, если хотя бы один — date
|
||
rs_d = range_start.date() if isinstance(range_start, datetime) else range_start
|
||
re_d = range_end.date() if isinstance(range_end, datetime) else range_end
|
||
if rs_d >= re_d:
|
||
return []
|
||
|
||
all_day = ev.all_day
|
||
s_date = ev.start_date
|
||
e_date = ev.end_date or ev.start_date
|
||
s_time: Optional[time] = ev.start_time
|
||
e_time: Optional[time] = ev.end_time
|
||
|
||
# базовые границы одного экземпляра
|
||
base_start = datetime.combine(s_date, s_time or time.min) if not all_day else s_date
|
||
base_end = datetime.combine(e_date, e_time or time.max) if not all_day else e_date
|
||
|
||
# длительность одного экземпляра
|
||
duration = (
|
||
(base_end - base_start)
|
||
if not all_day
|
||
else timedelta(days=(e_date - s_date).days + 1)
|
||
)
|
||
if duration <= timedelta(0):
|
||
duration = timedelta(minutes=1) if not all_day else timedelta(days=1)
|
||
|
||
# нормализуем окно под тип события (чтобы не сравнивать datetime с date)
|
||
if all_day:
|
||
RS = range_start.date() if isinstance(range_start, datetime) else range_start
|
||
RE = range_end.date() if isinstance(range_end, datetime) else range_end
|
||
|
||
def push(start_d: date) -> bool:
|
||
end_d = start_d + duration # timedelta дней
|
||
if _overlaps(start_d, end_d, RS, RE):
|
||
occ.append(Occurrence(start=start_d, end=end_d, all_day=True))
|
||
return len(occ) >= max_instances
|
||
else:
|
||
RS = range_start if isinstance(range_start, datetime) else datetime.combine(range_start, time.min)
|
||
RE = range_end if isinstance(range_end, datetime) else datetime.combine(range_end, time.min)
|
||
|
||
def push(start_dt: datetime) -> bool:
|
||
end_dt = start_dt + duration
|
||
if _overlaps(start_dt, end_dt, RS, RE):
|
||
occ.append(Occurrence(start=start_dt, end=end_dt, all_day=False))
|
||
return len(occ) >= max_instances
|
||
|
||
occ: List[Occurrence] = []
|
||
|
||
rec = ev.recurrence
|
||
interval = max(1, ev.recurrence_interval or 1)
|
||
until: Optional[date] = ev.recurrence_until
|
||
count_limit = ev.recurrence_count or 10**9
|
||
produced = 0
|
||
|
||
# ---- ONE-TIME
|
||
if rec == Recurrence.ONE_TIME:
|
||
push(base_start if not all_day else s_date)
|
||
return occ
|
||
|
||
# ---- HOURLY (только timed)
|
||
if rec == Recurrence.HOURLY:
|
||
# базовый курсор
|
||
cur = datetime.combine(s_date, s_time or time.min)
|
||
|
||
# догоняем до начала окна
|
||
while cur + duration <= RS:
|
||
cur += timedelta(hours=interval)
|
||
|
||
# генерация в окне
|
||
while produced < count_limit and (until is None or cur.date() <= until):
|
||
if cur >= RE:
|
||
break
|
||
if push(cur):
|
||
break
|
||
produced += 1
|
||
cur += timedelta(hours=interval)
|
||
return occ
|
||
|
||
# ---- DAILY
|
||
if rec == Recurrence.DAILY:
|
||
cur = s_date
|
||
# догоняем до начала окна
|
||
RS_d = RS if all_day else RS.date()
|
||
while cur + timedelta(days=1) <= RS_d:
|
||
cur += timedelta(days=interval)
|
||
|
||
while produced < count_limit and (until is None or cur <= until):
|
||
# выход за окно
|
||
RE_d = RE if all_day else RE.date()
|
||
if cur >= RE_d:
|
||
break
|
||
if push(cur if all_day else datetime.combine(cur, s_time or time.min)):
|
||
break
|
||
produced += 1
|
||
cur += timedelta(days=interval)
|
||
return occ
|
||
|
||
# ---- WEEKLY
|
||
if rec == Recurrence.WEEKLY:
|
||
mask = ev.weekdays_mask or 0
|
||
allowed = {i for i in range(7) if (mask >> i) & 1} or {s_date.weekday()}
|
||
|
||
def weeks_from_start(d: date) -> int:
|
||
return (d - s_date).days // 7
|
||
|
||
cur = max(s_date, RS if isinstance(RS, date) else RS.date())
|
||
safe_guard = 0
|
||
while produced < count_limit and cur < (RE if isinstance(RE, date) else RE.date()) and safe_guard < max_instances * 14:
|
||
if (weeks_from_start(cur) % interval == 0) and (cur.weekday() in allowed):
|
||
if push(cur if all_day else datetime.combine(cur, s_time or time.min)):
|
||
break
|
||
produced += 1
|
||
cur += timedelta(days=1)
|
||
if until and cur > until:
|
||
break
|
||
safe_guard += 1
|
||
return occ
|
||
|
||
# ---- MONTHLY
|
||
if rec == Recurrence.MONTHLY:
|
||
cur = s_date
|
||
RS_d = RS if all_day else RS.date()
|
||
while _add_months(cur, interval) <= RS_d:
|
||
cur = _add_months(cur, interval)
|
||
|
||
while produced < count_limit and (until is None or cur <= until):
|
||
RE_d = RE if all_day else RE.date()
|
||
if cur >= RE_d:
|
||
break
|
||
if push(cur if all_day else datetime.combine(cur, s_time or time.min)):
|
||
break
|
||
produced += 1
|
||
cur = _add_months(cur, interval)
|
||
return occ
|
||
|
||
# ---- ANNUAL
|
||
if rec == Recurrence.ANNUAL:
|
||
cur = s_date
|
||
RS_d = RS if all_day else RS.date()
|
||
nxt = _add_years(cur, interval)
|
||
while nxt <= RS_d:
|
||
cur = nxt
|
||
nxt = _add_years(cur, interval)
|
||
|
||
while produced < count_limit and (until is None or cur <= until):
|
||
RE_d = RE if all_day else RE.date()
|
||
if cur >= RE_d:
|
||
break
|
||
if push(cur if all_day else datetime.combine(cur, s_time or time.min)):
|
||
break
|
||
produced += 1
|
||
cur = _add_years(cur, interval)
|
||
return occ
|
||
|
||
# fallback
|
||
push(base_start if not all_day else s_date)
|
||
return occ
|