Игорь Надеин e30c059529 **feat(calendar): единая модель/эндпоинт календаря, повторяемость, права, сигналы и фронт**
Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (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.
2025-09-09 17:55:15 +03:00

258 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend/calendar_app/models.py
from __future__ import annotations
from datetime import datetime
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
User = get_user_model()
class Recurrence(models.TextChoices):
"""Типы повторяемости события календаря."""
ONE_TIME = "one_time", _("Одноразовое")
HOURLY = "hourly", _("Ежечасно")
DAILY = "daily", _("Ежедневно")
WEEKLY = "weekly", _("Еженедельно")
MONTHLY = "monthly", _("Ежемесячно")
ANNUAL = "annual", _("Ежегодно")
class CalendarEvent(models.Model):
"""Событие календаря (компании или отдела) с поддержкой повторяемости.
Если `department` = NULL → событие компании (глобальное).
Если `department` задан → событие конкретного отдела.
"""
# Область
department = models.ForeignKey(
"employees.Department",
on_delete=models.CASCADE,
related_name="calendar_events",
verbose_name=_("Отдел"),
null=True,
blank=True,
help_text=_("Пусто — событие компании; задано — событие отдела."),
)
# Основное
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
# Даты/время
start_date = models.DateField(_("Дата начала"))
end_date = models.DateField(_("Дата окончания"), null=True, blank=True)
start_time = models.TimeField(_("Время начала"), null=True, blank=True)
end_time = models.TimeField(_("Время окончания"), null=True, blank=True)
all_day = models.BooleanField(_("Весь день"), default=True)
# Повторяемость
recurrence = models.CharField(
_("Повторение"),
max_length=20,
choices=Recurrence.choices,
default=Recurrence.ONE_TIME,
)
recurrence_interval = models.PositiveSmallIntegerField(
_("Интервал повторения"),
default=1,
help_text=_("Шаг повторения (каждые N часов/дней/недель/месяцев)."),
)
recurrence_count = models.PositiveIntegerField(
_("Количество повторений"),
null=True,
blank=True,
help_text=_("Максимум создаваемых вхождений; нельзя вместе с датой окончания."),
)
recurrence_until = models.DateField(
_("Повторять до (включительно)"),
null=True,
blank=True,
help_text=_(
"Дата, до которой повторять. Нельзя вместе с количеством повторов."
),
)
weekdays_mask = models.PositiveSmallIntegerField(
_("Дни недели (битовая маска)"),
default=0,
help_text=_(
"Для еженедельных событий: 1=Пн,2=Вт,4=Ср,8=Чт,16=Пт,32=Сб,64=Вс; можно суммировать."
),
)
# Отображение
color = models.CharField(
_("Цвет (hex)"), max_length=7, blank=True, help_text="#RRGGBB"
)
location = models.CharField(_("Локация"), max_length=200, blank=True)
# Служебное
created_by = models.ForeignKey(
User,
verbose_name=_("Создал"),
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="calendar_events_created",
)
created_at = models.DateTimeField(auto_now_add=True)
source = models.CharField(
_("Источник события"),
max_length=120,
blank=True,
db_index=True,
help_text=_(
"Технический ключ связи с внешним объектом, напр. 'employee:<id>:birthday'"
),
)
class Meta:
verbose_name = _("Событие календаря")
verbose_name_plural = _("События календаря")
ordering = ["department", "start_date", "start_time"]
indexes = [
models.Index(fields=["department", "start_date"]),
models.Index(fields=["department", "recurrence", "start_date"]),
models.Index(fields=["start_date", "end_date"]),
models.Index(fields=["start_date", "start_time"]),
models.Index(fields=["source"]),
]
constraints = [
models.UniqueConstraint(
fields=["source"],
name="cal_event_source_unique",
condition=~models.Q(source=""),
),
]
permissions = [
("manage_department_events", "Может управлять событиями отдела"),
]
def __str__(self) -> str:
scope = self.department or _("Компания")
if self.end_date:
return f"{scope}: {self.title} ({self.start_date:%d.%m.%Y}–{self.end_date:%d.%m.%Y})"
return f"{scope}: {self.title} ({self.start_date:%d.%m.%Y})"
@property
def is_company(self) -> bool:
"""True если событие глобальное (без отдела)."""
return self.department_id is None
@property
def has_time(self) -> bool:
"""True если задано точное время (оба поля времени)."""
return self.start_time is not None and self.end_time is not None
# ---------- Валидация ----------
def clean(self) -> None:
"""Валидирует согласованность дат/времени и повторяемости.
Raises:
ValidationError: При некорректных датах/времени или настройках повторения.
"""
# База дат
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError(
{"end_date": _("Дата окончания не может быть раньше даты начала.")}
)
# Время — либо оба, либо ни одного
if (self.start_time is None) ^ (self.end_time is None):
raise ValidationError(
_("Если указываете время, заполните и начало, и окончание.")
)
# В пределах одного дня: end_time >= start_time
if (
self.has_time
and self.end_date
and self.start_date == self.end_date
and self.end_time < self.start_time
):
raise ValidationError(
{"end_time": _("Время окончания не может быть раньше времени начала.")}
)
# Согласование all_day
if self.has_time and self.all_day:
self.all_day = False
if (not self.has_time) and (not self.all_day):
self.all_day = True
# Повторяемость: ограничения
if self.recurrence in {Recurrence.HOURLY} and not self.has_time:
raise ValidationError(
_("Ежечасное событие требует указания времени начала и окончания.")
)
if (
self.recurrence in {Recurrence.HOURLY, Recurrence.DAILY}
and self.recurrence_interval < 1
):
raise ValidationError(
{"recurrence_interval": _("Интервал должен быть ≥ 1.")}
)
if self.recurrence == Recurrence.WEEKLY:
if self.recurrence_interval < 1:
raise ValidationError(
{"recurrence_interval": _("Интервал должен быть ≥ 1.")}
)
# Если маска не задана — используем день недели start_date
if self.weekdays_mask == 0 and self.start_date:
self.weekdays_mask = 1 << (
(self.start_date.weekday()) % 7
) # 0=Mon...6=Sun
if self.recurrence == Recurrence.MONTHLY and self.recurrence_interval < 1:
raise ValidationError(
{"recurrence_interval": _("Интервал должен быть ≥ 1.")}
)
# until/count взаимоисключительны
if self.recurrence_until and self.recurrence_count:
raise ValidationError(
_("Нельзя одновременно задавать 'до даты' и 'количество повторов'.")
)
# ----- Утилиты для фронта -----
def iso_start(self) -> str:
"""Возвращает ISO-строку начала (date или datetime)."""
if self.has_time:
dt = datetime.combine(self.start_date, self.start_time)
return dt.isoformat()
return self.start_date.isoformat()
def iso_end(self) -> str:
"""Возвращает ISO-строку конца (date или datetime). Если end_date пустая — возвращает start."""
d = self.end_date or self.start_date
if self.has_time and self.end_date:
dt = datetime.combine(d, self.end_time)
return dt.isoformat()
return d.isoformat()
def get_absolute_url(self) -> str:
"""URL календаря по области события."""
if self.department_id:
return reverse(
"calendar_app:department_calendar", args=[self.department_id]
)
return reverse("calendar_app:company_calendar")
def save(self, *args, **kwargs) -> None:
"""Приводит маску дней недели к 0, если она не задана.
Это нужно для фикстур/сырого .create(...), чтобы не падать на NOT NULL.
"""
if self.weekdays_mask is None:
self.weekdays_mask = 0
super().save(*args, **kwargs)