Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (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.
216 lines
9.0 KiB
Python
216 lines
9.0 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from typing import Optional, Type, List, Dict, Any
|
||
|
||
from django.core.cache import cache
|
||
from django.http import Http404
|
||
from django.utils.dateparse import parse_date
|
||
from rest_framework import serializers, status
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.renderers import JSONRenderer
|
||
from rest_framework.response import Response
|
||
from rest_framework.viewsets import ModelViewSet
|
||
|
||
from calendar_app.models import CalendarEvent
|
||
from .serializers import (
|
||
CalendarEventSerializer,
|
||
CalendarEventWriteSerializer,
|
||
)
|
||
from calendar_app.services.recurrence import expand_event_occurrences
|
||
|
||
from ..permissions import AdminOrActionOrModelPerms, AdminOrDeptAllowed # пермишены для "компании"
|
||
from employees.constants import DeptPerm # коды прав отделов
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class CalendarEventsViewSet(ModelViewSet):
|
||
"""Единый CRUD по событиям календаря (компания/отдел) с развёрткой повторяемости.
|
||
|
||
list:
|
||
Возвращает МАТЕРИАЛИЗОВАННЫЕ вхождения в окне [start, end).
|
||
Если указан department_id — события отдела, иначе — компании.
|
||
|
||
retrieve/create/update/partial_update/destroy:
|
||
Работают с объектом CalendarEvent.
|
||
"""
|
||
|
||
queryset = CalendarEvent.objects.select_related("department").order_by(
|
||
"start_date", "start_time"
|
||
)
|
||
renderer_classes = [JSONRenderer]
|
||
|
||
# ---------- Внутренний пермишен для отделов ----------
|
||
|
||
class ManageCalendarPerm(AdminOrDeptAllowed):
|
||
"""Право на управление календарём отдела (для компании молчит)."""
|
||
|
||
required_code = DeptPerm.MANAGE_CALENDAR
|
||
|
||
# ---------- Подключение пермишенов ----------
|
||
|
||
def get_permissions(self):
|
||
"""Для компании — AdminOrActionOrModelPerms, для отдела — ManageCalendarPerm.
|
||
|
||
ВАЖНО: прокидываем найденный department_id в kwargs, чтобы пермишен
|
||
гарантированно его увидел на этапе has_permission().
|
||
"""
|
||
dep = self._dept_id(required=False)
|
||
print(f"Request user: {self.request.user}, Auth: {self.request.auth}")
|
||
if dep is None:
|
||
|
||
return [AdminOrActionOrModelPerms()]
|
||
|
||
# События отдела: подложим dep в kwargs (если его не было)
|
||
# чтобы AdminOrDeptAllowed нашёл department_id без чтения request.data
|
||
self.kwargs = dict(self.kwargs) # скопировать, т.к. может быть MappingProxy
|
||
self.kwargs.setdefault("department_id", dep)
|
||
|
||
return [self.ManageCalendarPerm()]
|
||
|
||
# ---------- Сериализаторы ----------
|
||
|
||
def get_serializer_class(self) -> Type[serializers.Serializer]:
|
||
"""Выбор сериализатора по действию.
|
||
|
||
Returns:
|
||
Type[serializers.Serializer]: Класс сериализатора.
|
||
"""
|
||
if self.action in {"create", "update", "partial_update"}:
|
||
return CalendarEventWriteSerializer
|
||
return CalendarEventSerializer
|
||
|
||
# ---------- Утилиты ----------
|
||
|
||
def _dept_id(self, *, required: bool = False) -> Optional[int]:
|
||
"""Читает department_id из kwargs/query/body.
|
||
|
||
ВАЖНО: *не* путать pk события с department_id.
|
||
|
||
Args:
|
||
required (bool): Если True — бросить 404 при отсутствии/ошибке.
|
||
|
||
Raises:
|
||
Http404: При некорректном или отсутствующем department_id (когда required=True).
|
||
|
||
Returns:
|
||
Optional[int]: PK отдела или None (компания).
|
||
"""
|
||
# nested-url вида: /departments/{department_id}/events/
|
||
for key in ("department_pk", "department_id"):
|
||
if key in self.kwargs:
|
||
try:
|
||
return int(self.kwargs[key])
|
||
except Exception:
|
||
raise Http404("Некорректный department_id в URL.")
|
||
|
||
req = getattr(self, "request", None)
|
||
if req is not None:
|
||
for key in ("department_id", "department"):
|
||
val = req.query_params.get(key)
|
||
if val is not None:
|
||
try:
|
||
return int(val)
|
||
except Exception:
|
||
raise Http404("Некорректный department_id в query.")
|
||
|
||
data = getattr(req, "data", {}) if req is not None else {}
|
||
if isinstance(data, dict):
|
||
for key in ("department_id", "department"):
|
||
if key in data and data[key] is not None:
|
||
try:
|
||
return int(data[key])
|
||
except Exception:
|
||
raise Http404("Некорректный department_id в теле запроса.")
|
||
|
||
if required:
|
||
raise Http404("Не указан department_id.")
|
||
return None
|
||
|
||
# ---------- QuerySet ----------
|
||
|
||
def get_queryset(self):
|
||
"""QS для list фильтруем по компании/отделу, для detail — не фильтруем.
|
||
|
||
Returns:
|
||
QuerySet: Набор для текущего действия.
|
||
"""
|
||
qs = super().get_queryset()
|
||
if getattr(self, "action", None) != "list":
|
||
# detail — нужен полный QS, чтобы по pk находить любой объект
|
||
return qs
|
||
|
||
dep = self._dept_id(required=False)
|
||
return qs.filter(department__isnull=True) if dep is None else qs.filter(department_id=dep)
|
||
|
||
# ---------- LIST: материализация повторов ----------
|
||
|
||
def list(self, request, *args, **kwargs):
|
||
"""Возвращает материализованные вхождения в окне [start, end).
|
||
|
||
Query params:
|
||
start (YYYY-MM-DD) — левая граница (включительно).
|
||
end (YYYY-MM-DD) — правая граница (исключительно).
|
||
|
||
Returns:
|
||
Response: Массив объектов для FullCalendar.
|
||
"""
|
||
start_str = request.query_params.get("start")
|
||
end_str = request.query_params.get("end")
|
||
if not start_str or not end_str:
|
||
return Response([], status=status.HTTP_200_OK)
|
||
|
||
r_start: Optional[date] = parse_date(start_str[:10])
|
||
r_end: Optional[date] = parse_date(end_str[:10])
|
||
if not r_start or not r_end:
|
||
return Response(
|
||
{"detail": "Некорректные параметры start/end."},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
if r_start >= r_end:
|
||
return Response([], status=status.HTTP_200_OK)
|
||
|
||
items: List[Dict[str, Any]] = []
|
||
for ev in self.get_queryset():
|
||
for oc in expand_event_occurrences(
|
||
ev, range_start=r_start, range_end=r_end, max_instances=1000
|
||
):
|
||
items.append(
|
||
{
|
||
"id": ev.id, # идентификатор «родителя»; можно заменить на oc.id, если нужен уникальный на каждое вхождение
|
||
"title": ev.title,
|
||
"start": oc.start.isoformat(),
|
||
"end": oc.end.isoformat(),
|
||
"allDay": oc.all_day,
|
||
"color": ev.color or None,
|
||
"recurrence": ev.recurrence,
|
||
"department_id": ev.department_id,
|
||
}
|
||
)
|
||
return Response(items, status=status.HTTP_200_OK)
|
||
|
||
# ---------- Создание/изменение/удаление ----------
|
||
|
||
def perform_create(self, serializer: CalendarEventWriteSerializer) -> None:
|
||
"""Сохраняет объект, подставляя department_id из контекста (если есть) и автора.
|
||
|
||
Raises:
|
||
ValidationError: Если сериализатор невалиден (поднимет сам DRF).
|
||
"""
|
||
dep = self._dept_id(required=False)
|
||
serializer.save(
|
||
department_id=dep if dep is not None else serializer.validated_data.get("department_id"),
|
||
created_by=self.request.user,
|
||
)
|
||
cache.clear()
|
||
|
||
def perform_update(self, serializer: CalendarEventWriteSerializer) -> None:
|
||
"""Обновляет событие и чистит кеш."""
|
||
serializer.save()
|
||
cache.clear()
|
||
|
||
def perform_destroy(self, instance: CalendarEvent) -> None:
|
||
"""Удаляет событие и чистит кеш."""
|
||
instance.delete()
|
||
cache.clear() |