Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (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.
258 lines
10 KiB
Python
258 lines
10 KiB
Python
# 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)
|