Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (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.
157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
# backend/calendar_app/signals.py
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from typing import Optional
|
||
|
||
from django.apps import apps as django_apps
|
||
from django.db import transaction
|
||
from django.db.models.signals import post_delete, post_save
|
||
from django.dispatch import receiver
|
||
from django.utils.translation import gettext_lazy as _
|
||
|
||
from calendar_app.models import CalendarEvent, Recurrence
|
||
|
||
|
||
BIRTHDAY_COLOR = "#FFC107" # опционально — визуальный цвет
|
||
BIRTHDAY_SOURCE_FMT = "employee:{id}:birthday"
|
||
|
||
|
||
def _get_employee_birthday(obj) -> Optional[date]:
|
||
"""Извлекает дату рождения сотрудника из популярных полей.
|
||
|
||
Args:
|
||
obj: Экземпляр модели сотрудника.
|
||
|
||
Returns:
|
||
Optional[date]: Дата рождения или None.
|
||
|
||
Raises:
|
||
AttributeError: Не выбрасывается наружу; поля проверяются безопасно.
|
||
"""
|
||
for attr in ("birth_date", "date_of_birth", "birthday", "dob"):
|
||
if hasattr(obj, attr):
|
||
val = getattr(obj, attr)
|
||
if isinstance(val, date):
|
||
return val
|
||
return None
|
||
|
||
|
||
def _get_employee_display_name(obj) -> str:
|
||
"""Возвращает человекочитаемое имя сотрудника.
|
||
|
||
Порядок предпочтения:
|
||
- метод get_full_name()
|
||
- поля full_name / fio
|
||
- first_name + last_name
|
||
- __str__()
|
||
|
||
Args:
|
||
obj: Экземпляр модели сотрудника.
|
||
|
||
Returns:
|
||
str: Имя для заголовка события.
|
||
"""
|
||
if hasattr(obj, "get_full_name") and callable(getattr(obj, "get_full_name")):
|
||
name = obj.get_full_name()
|
||
if name:
|
||
return str(name)
|
||
for attr in ("full_name", "fio"):
|
||
if hasattr(obj, attr):
|
||
val = getattr(obj, attr)
|
||
if val:
|
||
return str(val)
|
||
first = getattr(obj, "first_name", "") or ""
|
||
last = getattr(obj, "last_name", "") or ""
|
||
name = f"{first} {last}".strip()
|
||
return name or str(obj)
|
||
|
||
|
||
def _upsert_birthday_event(*, employee, birthday: Optional[date]) -> None:
|
||
"""Создаёт/обновляет/удаляет событие дня рождения сотрудника.
|
||
|
||
Логика:
|
||
- Если `birthday` задана → upsert глобального ежегодного события с source='employee:<id>:birthday'.
|
||
- Если `birthday` = None → удаляем событие по этому source.
|
||
|
||
Args:
|
||
employee: Экземпляр сотрудника.
|
||
birthday (Optional[date]): Дата рождения.
|
||
|
||
Raises:
|
||
ValueError: Если у сотрудника нет первичного ключа (не сохранён).
|
||
"""
|
||
if not getattr(employee, "pk", None):
|
||
raise ValueError("Сотрудник должен быть сохранён, чтобы синхронизировать событие.")
|
||
|
||
source_key = BIRTHDAY_SOURCE_FMT.format(id=employee.pk)
|
||
|
||
if not birthday:
|
||
CalendarEvent.objects.filter(source=source_key).delete()
|
||
return
|
||
|
||
title = _("День рождения: {name}").format(name=_get_employee_display_name(employee))
|
||
|
||
# all-day ежегодное событие компании: department=None
|
||
defaults = {
|
||
"title": title,
|
||
"description": "",
|
||
"start_date": birthday,
|
||
"end_date": birthday,
|
||
"start_time": None,
|
||
"end_time": None,
|
||
"all_day": True,
|
||
"recurrence": Recurrence.ANNUAL,
|
||
"color": BIRTHDAY_COLOR,
|
||
"location": "",
|
||
"department": None,
|
||
}
|
||
|
||
# upsert по source
|
||
obj, created = CalendarEvent.objects.update_or_create(
|
||
source=source_key,
|
||
defaults=defaults,
|
||
)
|
||
# Если человек переименован — при следующем save заголовок обновится
|
||
# через defaults с update_or_create.
|
||
|
||
|
||
@receiver(post_save)
|
||
def handle_employee_saved(sender, instance, created, **kwargs):
|
||
"""Реакция на сохранение сотрудника — синхронизация события дня рождения.
|
||
|
||
Подключается динамически из AppConfig.ready(), только для модели employees.Employee.
|
||
|
||
Args:
|
||
sender: Класс модели (ожидается Employee).
|
||
instance: Сохранённый сотрудник.
|
||
created (bool): True при создании, False при обновлении.
|
||
"""
|
||
# Ограничим обработку только моделью Employee (см. apps.py)
|
||
if sender._meta.label != "employees.Employee":
|
||
return
|
||
|
||
birthday = _get_employee_birthday(instance)
|
||
|
||
# Внутри транзакции БД, чтобы не получить "призрачные" записи
|
||
with transaction.atomic():
|
||
_upsert_birthday_event(employee=instance, birthday=birthday)
|
||
|
||
|
||
@receiver(post_delete)
|
||
def handle_employee_deleted(sender, instance, **kwargs):
|
||
"""Удаляет календарное событие дня рождения при удалении сотрудника.
|
||
|
||
Подключается динамически из AppConfig.ready(), только для модели employees.Employee.
|
||
|
||
Args:
|
||
sender: Класс модели (ожидается Employee).
|
||
instance: Удалённый сотрудник.
|
||
"""
|
||
if sender._meta.label != "employees.Employee":
|
||
return
|
||
|
||
source_key = BIRTHDAY_SOURCE_FMT.format(id=getattr(instance, "pk", None))
|
||
if source_key:
|
||
CalendarEvent.objects.filter(source=source_key).delete()
|