Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (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.
387 lines
16 KiB
Python
387 lines
16 KiB
Python
# backend\api\v1\permissions.py
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, Optional
|
||
|
||
from django.contrib.auth.models import AbstractBaseUser
|
||
from employees.constants import DeptPerm
|
||
from employees.models import Department, EmployeeDepartment
|
||
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
|
||
DjangoModelPermissions)
|
||
|
||
MANAGE_PERM = "manage_department"
|
||
CHANGE_HEAD_PERM = "change_department_head"
|
||
ASSIGN_ROLE_PERM = "assign_department_role"
|
||
|
||
|
||
def user_is_dept_head(user, dept: Department) -> bool:
|
||
return bool(
|
||
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
|
||
)
|
||
|
||
|
||
def user_is_staffish(user) -> bool:
|
||
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
|
||
|
||
|
||
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
|
||
if not (user and user.is_authenticated):
|
||
return False
|
||
# staff/superuser — сразу ок (оставим как было)
|
||
if user_is_staffish(user):
|
||
return True
|
||
# руководитель — сразу ок
|
||
if user_is_dept_head(user, dept):
|
||
return True
|
||
# основная проверка: активная связь + у роли есть нужный Permission
|
||
return EmployeeDepartment.objects.filter(
|
||
employee_id=user.id,
|
||
department_id=dept.id,
|
||
is_active=True,
|
||
role__scoped_permissions__codename=perm_code,
|
||
).exists()
|
||
|
||
|
||
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
|
||
"""
|
||
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
|
||
|
||
Логика:
|
||
1) staff/superuser → всегда True
|
||
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
|
||
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
|
||
4) иначе False
|
||
"""
|
||
if not getattr(user, "is_authenticated", False):
|
||
return False
|
||
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
|
||
return True
|
||
|
||
# 2) Руководитель отдела имеет все управленческие права в своём отделе
|
||
if Department.objects.filter(id=department_id, head_id=user.id).exists():
|
||
return True
|
||
|
||
# 3) Проверка роли в отделе
|
||
link = (
|
||
EmployeeDepartment.objects.select_related("role")
|
||
.filter(employee_id=user.id, department_id=department_id, is_active=True)
|
||
.first()
|
||
)
|
||
if not link or not link.role_id:
|
||
return False
|
||
|
||
# роль хранит «скоуп-права» только для этого отдела
|
||
return link.role.scoped_permissions.filter(code=code).exists()
|
||
|
||
|
||
class AdminOrDeptAllowed(BasePermission):
|
||
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
|
||
|
||
Поведение:
|
||
1. Если пользователь superuser/staff — доступ всегда разрешён.
|
||
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
|
||
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
|
||
3. Идентификатор отдела извлекается из:
|
||
- URL kwargs: 'department_pk' → 'department_id';
|
||
- request.data: 'department' → 'department_id';
|
||
- query params: 'department' → 'department_id'.
|
||
На уровне has_object_permission отдел берётся из самого объекта:
|
||
- Department.id;
|
||
- obj.department_id / obj.dept_id;
|
||
- obj.department.id.
|
||
4. Если код права не задан (required_code/required_code_map пусты), то
|
||
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
|
||
Для небезопасных методов в этом случае доступ запрещён.
|
||
|
||
Атрибуты класса:
|
||
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
|
||
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
|
||
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
|
||
|
||
Примеры:
|
||
class ManageCalendarPerm(AdminOrDeptAllowed):
|
||
required_code = DeptPerm.MANAGE_CALENDAR
|
||
|
||
class DeptEventsPerm(AdminOrDeptAllowed):
|
||
required_code_map = {
|
||
"create": DeptPerm.MANAGE_CALENDAR,
|
||
"update": DeptPerm.MANAGE_CALENDAR,
|
||
"partial_update": DeptPerm.MANAGE_CALENDAR,
|
||
"destroy": DeptPerm.MANAGE_CALENDAR,
|
||
}
|
||
"""
|
||
|
||
required_code: Optional[str] = None
|
||
required_code_map: Dict[str, str] = {}
|
||
allow_safe_without_code: bool = True
|
||
|
||
# -------- helpers --------
|
||
|
||
def get_required_code(self, request, view) -> Optional[str]:
|
||
"""Определяет требуемый код права для текущего экшена.
|
||
|
||
Сначала берёт из required_code_map по имени action (DRF: view.action),
|
||
затем fallback на required_code.
|
||
|
||
Args:
|
||
request: DRF Request (не используется в текущей логике).
|
||
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
|
||
|
||
Returns:
|
||
Optional[str]: Код права или None, если право для экшена не требуется.
|
||
"""
|
||
return (
|
||
self.required_code_map.get(getattr(view, "action", None))
|
||
or self.required_code
|
||
)
|
||
|
||
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
|
||
"""Пытается извлечь department_id из kwargs, body или query-параметров.
|
||
|
||
Порядок:
|
||
1) view.kwargs: department_pk, department_id
|
||
2) request.data: department, department_id
|
||
3) request.query_params: department, department_id
|
||
|
||
Args:
|
||
request: DRF Request
|
||
view: DRF View
|
||
|
||
Returns:
|
||
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
|
||
"""
|
||
for k in ("department_pk", "department_id"):
|
||
v = view.kwargs.get(k)
|
||
if v is not None:
|
||
return v
|
||
for k in ("department", "department_id"):
|
||
v = request.data.get(k)
|
||
if v is not None:
|
||
return v
|
||
for k in ("department", "department_id"):
|
||
v = request.query_params.get(k)
|
||
if v is not None:
|
||
return v
|
||
return None
|
||
|
||
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
|
||
"""Извлекает department_id из объекта.
|
||
|
||
Порядок:
|
||
- Если obj — модель Department с полем id → вернуть id.
|
||
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
|
||
- Если у obj есть атрибут department (FK) → взять department.id.
|
||
|
||
Args:
|
||
obj: Модель/объект detail-view.
|
||
|
||
Returns:
|
||
Optional[int]: department_id или None.
|
||
"""
|
||
if obj is None:
|
||
return None
|
||
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
|
||
return obj.id
|
||
for attr in ("department_id", "dept_id"):
|
||
if hasattr(obj, attr):
|
||
return getattr(obj, attr)
|
||
dep = getattr(obj, "department", None)
|
||
if dep is not None:
|
||
return getattr(dep, "id", None)
|
||
return None
|
||
|
||
def _to_int_or_none(self, v) -> Optional[int]:
|
||
"""Аккуратное приведение к int.
|
||
|
||
Args:
|
||
v: Любое значение с возможностью строкового преобразования.
|
||
|
||
Returns:
|
||
Optional[int]: int или None при ошибке.
|
||
"""
|
||
try:
|
||
return int(str(v))
|
||
except Exception:
|
||
return None
|
||
|
||
# -------- main checks --------
|
||
|
||
def has_permission(self, request, view) -> bool:
|
||
"""Проверка доступа на уровне запроса (до доступа к объекту).
|
||
|
||
Правила:
|
||
- Неаутентифицированные → False.
|
||
- superuser/staff → True.
|
||
- Если код не требуется:
|
||
- SAFE-методы → allow_safe_without_code ? True : False
|
||
- небезопасные → False
|
||
- Если код требуется:
|
||
- Пытаемся найти department_id в запросе;
|
||
- Если нашли → проверяем has_dept_perm(user, dept_id, code);
|
||
- Если не нашли:
|
||
* если detail-вью → отложить проверку до has_object_permission (вернуть True);
|
||
* иначе → False.
|
||
|
||
Returns:
|
||
bool: Разрешить/запретить доступ.
|
||
"""
|
||
user = getattr(request, "user", None)
|
||
if not user or not user.is_authenticated:
|
||
return False
|
||
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
|
||
return True
|
||
|
||
code = self.get_required_code(request, view)
|
||
if not code:
|
||
return (
|
||
request.method in SAFE_METHODS
|
||
if self.allow_safe_without_code
|
||
else False
|
||
)
|
||
|
||
dept_id = self._to_int_or_none(
|
||
self._extract_dept_id_from_request(request, view)
|
||
)
|
||
if dept_id is None:
|
||
# detail-экшены проверим на объекте
|
||
return True if getattr(view, "detail", False) else False
|
||
|
||
return has_dept_perm(user, dept_id, code)
|
||
|
||
def has_object_permission(self, request, view, obj) -> bool:
|
||
"""Проверка доступа к конкретному объекту (detail).
|
||
|
||
Правила:
|
||
- superuser/staff → True.
|
||
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
|
||
- Иначе:
|
||
- Пытаемся извлечь department_id из объекта;
|
||
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
|
||
- если не нашли вообще → False;
|
||
- иначе → has_dept_perm(user, dept_id, code).
|
||
|
||
Returns:
|
||
bool: Разрешить/запретить доступ к объекту.
|
||
"""
|
||
user = getattr(request, "user", None)
|
||
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
|
||
return True
|
||
|
||
code = self.get_required_code(request, view)
|
||
if not code:
|
||
return (
|
||
request.method in SAFE_METHODS
|
||
if self.allow_safe_without_code
|
||
else False
|
||
)
|
||
|
||
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
|
||
if dept_id is None:
|
||
dept_id = self._to_int_or_none(
|
||
self._extract_dept_id_from_request(request, view)
|
||
)
|
||
if dept_id is None:
|
||
return False
|
||
|
||
return has_dept_perm(request.user, dept_id, code)
|
||
|
||
|
||
class IsSelfOrStaff(BasePermission):
|
||
# На class-level пускаем любого аутентифицированного.
|
||
def has_permission(self, request, view):
|
||
return bool(request.user and request.user.is_authenticated)
|
||
|
||
# На object-level: сам объект, либо staff/superuser.
|
||
def has_object_permission(self, request, view, obj):
|
||
user = request.user
|
||
if not user or not user.is_authenticated:
|
||
return False
|
||
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
|
||
return True
|
||
|
||
# Основной случай — объект сотрудника.
|
||
try:
|
||
from employees.models import \
|
||
Employee # локальный импорт, чтобы избежать циклов
|
||
except Exception:
|
||
Employee = None
|
||
|
||
if Employee is not None and isinstance(obj, Employee):
|
||
return obj.pk == user.pk
|
||
|
||
# Fallback: сравнить id из URL, если obj не Employee (на всякий случай)
|
||
lookup = getattr(view, "lookup_field", "pk")
|
||
return str(view.kwargs.get(lookup)) == str(user.pk)
|
||
|
||
|
||
class IsAuthorOrStaffForComments(BasePermission):
|
||
"""
|
||
Создание/чтение — любой аутентифицированный (проверяет IsAuthenticated во view).
|
||
Изменение/удаление — автор комментария или staff/superuser.
|
||
"""
|
||
|
||
message = "Можно редактировать/удалять только свои комментарии."
|
||
|
||
def has_permission(self, request, view):
|
||
# IsAuthenticated уже стоит во вьюхе; дублируем True для небезопасных методов,
|
||
# чтобы проверка прошла до object-level.
|
||
return bool(request.user and request.user.is_authenticated)
|
||
|
||
def has_object_permission(self, request, view, obj):
|
||
if request.method in SAFE_METHODS:
|
||
return True
|
||
user = request.user
|
||
if not (user and user.is_authenticated):
|
||
return False
|
||
if user_is_staffish(user):
|
||
return True
|
||
return getattr(obj, "author_id", None) == getattr(user, "id", None)
|
||
|
||
|
||
class AdminOrActionOrModelPerms(DjangoModelPermissions):
|
||
"""
|
||
Админ/суперпользователь → всегда True.
|
||
Если задан код права (view.required_perm_code или required_perms_by_action[action]),
|
||
проверяем его через user.has_perm(...). Иначе — стандартные DjangoModelPermissions.
|
||
"""
|
||
|
||
def has_permission(self, request, view):
|
||
user = request.user
|
||
if not user or not user.is_authenticated:
|
||
return False
|
||
if user.is_superuser or user.is_staff:
|
||
return True
|
||
|
||
mapping = getattr(view, "required_perms_by_action", {}) or {}
|
||
action = getattr(view, "action", None)
|
||
code = getattr(view, "required_perm_code", None) or mapping.get(action)
|
||
if code:
|
||
return user.has_perm(code)
|
||
|
||
return super().has_permission(request, view)
|
||
|
||
def has_object_permission(self, request, view, obj):
|
||
user = request.user
|
||
if user.is_superuser or user.is_staff:
|
||
return True
|
||
|
||
mapping = getattr(view, "required_perms_by_action", {}) or {}
|
||
action = getattr(view, "action", None)
|
||
code = getattr(view, "required_perm_code", None) or mapping.get(action)
|
||
if code:
|
||
return user.has_perm(code)
|
||
|
||
return super().has_object_permission(request, view, obj)
|
||
|
||
|
||
__all__ = [
|
||
"MANAGE_PERM",
|
||
"CHANGE_HEAD_PERM",
|
||
"ASSIGN_ROLE_PERM",
|
||
"IsSelfOrStaff",
|
||
"IsAuthorOrStaffForComments",
|
||
"AdminOrActionOrModelPerms",
|
||
"user_is_dept_head",
|
||
"user_has_dept_perm",
|
||
]
|