Corp/backend/api/v1/permissions.py
Игорь Надеин 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

387 lines
16 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\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",
]