Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (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.
205 lines
9.4 KiB
Python
205 lines
9.4 KiB
Python
# backend/api/v1/calendar/serializers.py
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import date, datetime, time, timedelta
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from calendar_app.models import CalendarEvent, Recurrence
|
||
from django.utils.translation import gettext_lazy as _
|
||
from rest_framework import serializers
|
||
|
||
|
||
class CalendarEventWriteSerializer(serializers.ModelSerializer):
|
||
"""Создание/обновление события календаря с полной валидацией."""
|
||
|
||
# Разрешаем пустой список для PATCH (когда меняют weekly -> daily и присылают weekdays: [])
|
||
weekdays = serializers.ListField(
|
||
child=serializers.IntegerField(min_value=0, max_value=6),
|
||
required=False,
|
||
allow_empty=True,
|
||
write_only=True,
|
||
help_text="Список дней недели для WEEKLY (0=Mon..6=Sun).",
|
||
)
|
||
department_id = serializers.IntegerField(required=False, allow_null=True)
|
||
|
||
class Meta:
|
||
model = CalendarEvent
|
||
fields = (
|
||
"id",
|
||
"department_id",
|
||
"title",
|
||
"description",
|
||
"start_date",
|
||
"end_date",
|
||
"start_time",
|
||
"end_time",
|
||
"all_day",
|
||
"recurrence",
|
||
"recurrence_interval",
|
||
"recurrence_until",
|
||
"recurrence_count",
|
||
"weekdays_mask",
|
||
"weekdays", # write-only
|
||
"color",
|
||
"location",
|
||
)
|
||
read_only_fields = ("id",)
|
||
|
||
# ---- helpers
|
||
def _coalesce(self, data: Dict[str, Any], instance: Optional[CalendarEvent], key: str, default=None):
|
||
if key in data:
|
||
return data.get(key)
|
||
if instance is not None:
|
||
return getattr(instance, key)
|
||
return default
|
||
|
||
@staticmethod
|
||
def _weekdays_to_mask(values: list[int]) -> int:
|
||
mask = 0
|
||
for v in values:
|
||
iv = int(v)
|
||
if not (0 <= iv <= 6):
|
||
raise ValueError("weekday out of range")
|
||
mask |= (1 << iv)
|
||
return mask
|
||
|
||
# ---- validation
|
||
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
||
inst: Optional[CalendarEvent] = getattr(self, "instance", None)
|
||
is_create = inst is None
|
||
|
||
s_date: date = self._coalesce(attrs, inst, "start_date")
|
||
if not s_date:
|
||
raise serializers.ValidationError({"start_date": _("Обязательное поле.")})
|
||
|
||
e_date: Optional[date] = self._coalesce(attrs, inst, "end_date", None)
|
||
s_time: Optional[time] = self._coalesce(attrs, inst, "start_time", None)
|
||
e_time: Optional[time] = self._coalesce(attrs, inst, "end_time", None)
|
||
|
||
all_day_raw = self._coalesce(attrs, inst, "all_day", None)
|
||
rec: str = self._coalesce(attrs, inst, "recurrence", Recurrence.ONE_TIME)
|
||
|
||
# ВАЖНО: не используем "or 1", чтобы 0 не превратился в 1
|
||
interval_val = self._coalesce(attrs, inst, "recurrence_interval", 1)
|
||
try:
|
||
interval = int(interval_val)
|
||
except Exception:
|
||
raise serializers.ValidationError({"recurrence_interval": _("Интервал должен быть числом.")})
|
||
|
||
until: Optional[date] = self._coalesce(attrs, inst, "recurrence_until", None)
|
||
rcount: Optional[int] = self._coalesce(attrs, inst, "recurrence_count", None)
|
||
|
||
wmask: int = int(self._coalesce(attrs, inst, "weekdays_mask", 0))
|
||
weekdays_input = attrs.get("weekdays", None)
|
||
if weekdays_input is not None:
|
||
try:
|
||
wmask = self._weekdays_to_mask(weekdays_input)
|
||
except Exception:
|
||
raise serializers.ValidationError({"weekdays": _("Неверный формат дней недели (ожидаются числа 0..6).")})
|
||
attrs["weekdays_mask"] = wmask
|
||
attrs.pop("weekdays", None)
|
||
|
||
color: Optional[str] = self._coalesce(attrs, inst, "color", None)
|
||
|
||
# 1) База
|
||
if interval < 1:
|
||
raise serializers.ValidationError({"recurrence_interval": _("Интервал должен быть ≥ 1.")})
|
||
if until is not None and until < s_date:
|
||
raise serializers.ValidationError({"recurrence_until": _("Дата окончания повторения не может быть раньше даты начала.")})
|
||
# Конфликтующие параметры
|
||
if until is not None and rcount is not None:
|
||
raise serializers.ValidationError({"recurrence_count": _("Нельзя одновременно задавать recurrence_until и recurrence_count.")})
|
||
|
||
if color and not re.fullmatch(r"#([0-9A-Fa-f]{6})", color):
|
||
raise serializers.ValidationError({"color": _("Цвет должен быть в формате #RRGGBB.")})
|
||
|
||
# 2) Время / all_day
|
||
has_any_time = (s_time is not None) or (e_time is not None)
|
||
if has_any_time and (s_time is None or e_time is None):
|
||
raise serializers.ValidationError(_("Если указываете время, заполните и начало, и окончание."))
|
||
|
||
if all_day_raw is None:
|
||
attrs["all_day"] = not has_any_time
|
||
else:
|
||
if all_day_raw and has_any_time:
|
||
raise serializers.ValidationError(_("Нельзя указывать время для события на весь день."))
|
||
if (all_day_raw is False) and not has_any_time:
|
||
raise serializers.ValidationError(_("Для события с временем укажите start_time и end_time."))
|
||
|
||
all_day: bool = attrs.get("all_day", inst.all_day if inst else (not has_any_time))
|
||
|
||
# end_date по умолчанию = start_date
|
||
if e_date is None:
|
||
e_date = s_date
|
||
attrs["end_date"] = e_date
|
||
|
||
# 3) Хронология
|
||
if all_day:
|
||
if e_date < s_date:
|
||
raise serializers.ValidationError({"end_date": _("Дата окончания не может быть раньше даты начала.")})
|
||
else:
|
||
start_dt = datetime.combine(s_date, s_time)
|
||
end_dt = datetime.combine(e_date, e_time)
|
||
if end_dt <= start_dt:
|
||
raise serializers.ValidationError({"end_time": _("Окончание должно быть позже начала.")})
|
||
|
||
# 4) Повторяемость
|
||
if rec == Recurrence.HOURLY and all_day:
|
||
raise serializers.ValidationError({"recurrence": _("Ежечасное повторение возможно только для событий с временем.")})
|
||
|
||
if rec == Recurrence.WEEKLY:
|
||
# create: требуем явные дни
|
||
if is_create and wmask == 0:
|
||
raise serializers.ValidationError({"weekdays_mask": _("Для еженедельного события укажите дни недели (weekdays или weekdays_mask).")})
|
||
# update: если маска не пришла и осталась 0 — подставим день недели старта
|
||
if (not is_create) and ("weekdays_mask" not in attrs) and (wmask == 0):
|
||
wmask = 1 << s_date.weekday()
|
||
attrs["weekdays_mask"] = wmask
|
||
if wmask < 0 or wmask > 0b1111111:
|
||
raise serializers.ValidationError({"weekdays_mask": _("Недопустимая маска дней недели.")})
|
||
else:
|
||
# для НЕ weekly игнорируем присланные weekdays/маску (если вдруг присланы пустыми)
|
||
attrs.pop("weekdays", None)
|
||
# маску не трогаем, если не прислана — оставляем как есть/0
|
||
|
||
if rec not in Recurrence.values:
|
||
raise serializers.ValidationError({"recurrence": _("Недопустимый тип повторения.")})
|
||
|
||
if rcount is not None and rcount < 1:
|
||
raise serializers.ValidationError({"recurrence_count": _("Количество повторов должно быть ≥ 1.")})
|
||
|
||
return attrs
|
||
|
||
def create(self, validated_data: Dict[str, Any]) -> CalendarEvent:
|
||
validated_data.pop("weekdays", None)
|
||
return CalendarEvent.objects.create(**validated_data)
|
||
|
||
def update(self, instance: CalendarEvent, validated_data: Dict[str, Any]) -> CalendarEvent:
|
||
validated_data.pop("weekdays", None)
|
||
for f, v in validated_data.items():
|
||
setattr(instance, f, v)
|
||
instance.save()
|
||
return instance
|
||
|
||
|
||
class CalendarEventSerializer(serializers.ModelSerializer):
|
||
"""Read-сериализатор (база модели, если нужен просмотр без развёртки)."""
|
||
|
||
class Meta:
|
||
model = CalendarEvent
|
||
fields = "__all__"
|
||
|
||
|
||
class EventOccurrenceSerializer(serializers.Serializer):
|
||
"""Сериализатор одного материализованного вхождения для FullCalendar."""
|
||
|
||
id = serializers.IntegerField()
|
||
title = serializers.CharField()
|
||
start = serializers.DateTimeField(required=False)
|
||
end = serializers.DateTimeField(required=False)
|
||
allDay = serializers.BooleanField()
|
||
color = serializers.CharField(allow_null=True, required=False)
|
||
recurrence = serializers.CharField()
|
||
department_id = serializers.IntegerField(allow_null=True, required=False)
|