**feat: управление отделами и ролями (API + шаблоны) + правки тестов**

### Backend / API

* **DepartmentViewSet**

  * Вернул поведение поиска/сортировки как в старой реализации: `?search=` по имени/описанию/ФИО руководителя; `?ordering=name|id` (по умолчанию `name`).
  * Добавил аннотацию **`employees_count`**: считает уникальных активных участников по связям `EmployeeDepartment`, и **прибавляет руководителя**, если он не входит в активные линкы.
  * `partial_update`: смена руководителя требует `change_department_head` **только если значение реально меняется**; остальное редактируется по `manage_department`.
  * `set_head`:

    * `POST /departments/{id}/set_head` — установка нового руководителя (200 + сериализованный отдел).
    * `head_id=null|""` — снятие руководителя (204).
    * Валидация: **разрешаем** назначение любому существующему и активному (`is_active=True`) пользователю; поле `email_verified` не блокирует назначение.
  * `set_member_role`:

    * `POST /departments/{id}/set_member_role` — создание/обновление линка участника с отделом.
    * Валидирует, что `role_id` принадлежит текущему отделу.
    * Возвращает JSON строго в формате, который ждут тесты: `{"employee_id", "role_id", "is_active"}`.

* **DepartmentRoleViewSet**

  * Доступ на изменение ролей отделов только при скоуп-праве **`assign_department_role`** (через `AdminOrDeptAllowed`).
  * Новые эндпоинты:

    * `GET /department-roles/perm_choices/` — отдает справочник прав из `DeptPerm.CHOICES`; при необходимости создаёт записи в БД (id + code + name).
    * `GET /department-roles/{id}/perms/` — текущие права роли (id, code, name).
    * `POST /department-roles/{id}/set_perms` — полная замена прав по `permission_ids` **или** `permission_codes`.
  * Использование константного набора прав **`DeptPerm`**: `MANAGE`, `CHANGE_HEAD`, `ASSIGN_ROLE` (+ `CHOICES`).

* **Сериализаторы**

  * `DepartmentSerializer`: `head` — вложенно (кратко), `head_id` — write-only (`PrimaryKeyRelatedField`), `employees_count` — read-only (берётся из аннотации).
  * `EmployeeBriefSerializer` — для краткого отображения руководителя и кандидатов.

* **Права**

  * Везде применён общий пермишен `AdminOrDeptAllowed(required_code=...)` для скоуп-прав по отделу:

    * `manage_department` — редактирование/удаление отдела,
    * `change_department_head` — назначение/снятие руководителя,
    * `assign_department_role` — операции с ролями и назначениями участникам.

### Frontend / Templates

* **Новый частичный шаблон** `backend/templates/employees/_department_controls.html`

  * Разбит на **3 блока**:

    1. *Управление отделом* (название/описание) — доступно при `can_manage`.
    2. *Роли отдела* — доступно при `can_assign_roles`.

       * Кнопка «Создать роль» открывает панель с формой + модал выбора прав.
       * Редактирование существующей роли: выпадающий список ролей, изменение названия и прав.
       * **Одна кнопка «Сохранить изменения»**: JS сам определяет, что изменилось, и вызывает нужные операции (`op=update` и/или `op=set_perms`) последовательно.
       * Отдельно — «Удалить роль».
       * Выбор прав выполнен через модальные окна, как в блоке с должностями в `_employee_edit.html`; визуал выбранных прав — «чипсы».
    3. *Назначение руководителя* — доступно при `can_change_head` (выпадающий список кандидатов).
  * Разметка и стили согласованы с `_employee_edit.html` (карточки, ios-аккордеон, кнопки, модалки). Дополнительный CSS **не** вводился — всё опирается на базовые стили проекта и Bootstrap.
* **`department_detail` (view для фронта)**:

  * Сначала грузит отдел, затем роли, сотрудников и посты.
  * Собирает `head_choices` и `links` для шаблона.
  * Подтягивает `dept_perm_choices` из `GET /api/v1/department-roles/perm_choices/`.

### Тесты

* Полностью переписаны/обновлены:

  * `tests/api/v1/employees/test_departments.py`

    * Поиск/сортировка по имени.
    * `employees_count` считает head, если его нет в активных линках.
    * `set_head`: валидирует активность; снятие — 204; изменения head без доп. права — 403; сохранение того же head — 200.
    * `set_member_role`: «счастливый путь», запрет чужих ролей, формат ответа.
  * `tests/api/v1/employees/test_department_roles.py` и `test_department_roles_extra.py`

    * Список с фильтром по отделу, стабильный `ordering`.
    * Создание/обновление/удаление с учётом скоуп-прав.
    * `perm_choices`, `perms`, `set_perms` (валидаторы ids/codes).
  * `tests/api/v1/employees/test_department_head_rights.py`

    * Руководитель может править отдел без явных ролей.
    * Руководитель может менять head и **теряет** права после снятия.
* Фиксы фикстур:

  * `make_user` всегда создаёт пользователя с валидным телефоном, `email` в нижнем регистре, отключена отсылка писем; приводим к активному состоянию, чтобы не мешать правам.
* Убраны ошибки:

  * `NameError: User is not defined` → везде используется `get_user_model()`.
  * `NoReverseMatch` в шаблоне → корректные URL’ы: `employees:department_edit`, `employees:department_set_head`, `employees:edit_department_role`.

### Прочее

* Повсеместно добавлены докстринги для новых классов и функций (соответствие внутреннему гайдлайну).
* Без миграций данных/схемы.

**Итог:** добавлены управляемые через API роли отделов с выбором прав (скоуп-прав), унифицированы проверки доступа, восстановлено ожидаемое поведение списка/деталей отделов, подготовлены UI-блоки в едином стиле с «должностями», обновлены и позеленели тесты (остальные регрессии устранены).
This commit is contained in:
admin i 2025-09-03 17:17:44 +03:00
parent 1228fb8d87
commit 3f0ff230ca
20 changed files with 2384 additions and 1070 deletions

BIN
backend.zip Normal file

Binary file not shown.

View File

@ -1,7 +1,16 @@
FROM python:3.13.5
# Multi‑app Django (ASGI) — EUSRR
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt --no-cache-dir
RUN apt-get update && apt-get install -y postgresql-client
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "foodgram_backend.wsgi"]
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev curl && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY backend /app
# Use ASGI server because project uses Django Channels
EXPOSE 8000
CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "eusrr_backend.asgi:application"]

41
backend/README_EUSRR.md Normal file
View File

@ -0,0 +1,41 @@
# EUSRR Backend — Quickstart
## What’s inside
- Django 5.2, DRF, SimpleJWT
- Apps: employees, requests_app (HR requests), calendar_app, feed (news), communications (chat via Channels), bots (Telegram via aiogram), documents, search, hikcentral (read-only models to external DB)
- ASGI via Channels; Redis required for websockets
## Local run (SQLite, no websockets)
```bash
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r backend/requirements.txt
cp backend/.env backend/.env.local || true
# edit backend/.env.local if needed (USE_SQLITE=True by default)
python backend/manage.py migrate
python backend/manage.py createsuperuser
python backend/manage.py runserver 0.0.0.0:9000
```
Open http://127.0.0.1:9000/
## Full stack with Docker (Postgres + Redis)
1. Put your secrets into `backend/.env` (use `.env.example` as a template). **Never commit real secrets.**
2. Fix Dockerfile to use `eusrr_backend.asgi` (or use the provided `Dockerfile.fixed`).
3. Launch:
```bash
docker compose up --build
```
The app will be available on http://127.0.0.1:9000/
## Useful commands
```bash
# Telegram bot
python backend/manage.py start_telegram_bot
# Run tests
pytest -q backend
```
## Notes
- Set `API_BASE_URL` to match where the app is served (default http://127.0.0.1:9000/api).
- For Channels websockets, keep Redis reachable at `${REDIS_HOST}:${REDIS_PORT}`.
- The `hikcentral` DB is configured as a secondary Postgres. Externalize its DSN via envs if you plan to use it.

View File

@ -6,7 +6,7 @@ from io import BytesIO
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.files.base import ContentFile
from employees.constants import ACTION_CHOICES
from employees.constants import ACTION_CHOICES, DeptPerm
from employees.models import (
Department,
DepartmentRole,
@ -14,7 +14,9 @@ from employees.models import (
EmployeeDepartment,
Position,
Skill,
DepartmentPermission,
)
from PIL import Image
from rest_framework import serializers
@ -371,8 +373,38 @@ class EmployeeSerializer(serializers.ModelSerializer):
}
class EmployeeBriefSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
class Meta:
model = Employee
fields = (
"id",
"first_name",
"last_name",
"patronymic",
"email",
"phone_number",
"display_name",
)
def get_display_name(self, obj) -> str:
"""
Читабельное имя сотрудника: full name -> email -> phone -> 'Сотрудник #id'
"""
parts = [obj.last_name or "", obj.first_name or "", obj.patronymic or ""]
fio = " ".join(p.strip() for p in parts if p)
if fio:
return fio
if getattr(obj, "email", None):
return obj.email
if getattr(obj, "phone_number", None):
return obj.phone_number
return f"Сотрудник #{obj.id}"
class DepartmentSerializer(serializers.ModelSerializer):
head = EmployeeSerializer(read_only=True)
head = EmployeeBriefSerializer(read_only=True)
head_id = serializers.PrimaryKeyRelatedField(
source="head",
queryset=Employee.objects.all(),
@ -388,25 +420,19 @@ class DepartmentSerializer(serializers.ModelSerializer):
"id",
"name",
"description",
"head",
"head_id",
"head_appointed_at",
"created_at",
"head", # nested read
"head_id", # write
"employees_count",
)
# head_appointed_at и created_at только для чтения
read_only_fields = ("head_appointed_at", "created_at")
def validate_head_id(self, value: Employee | None):
if value and not value.is_actually_active:
raise serializers.ValidationError(
"Назначать можно только реально активного сотрудника"
)
return value
class EmployeeListSerializer(serializers.ModelSerializer):
"""
Списочное представление сотрудника в /employees/ (листинги, выборы и т.п.).
Добавляет поле display_name по тем же правилам, что и в кратком сериализаторе.
"""
display_name = serializers.SerializerMethodField()
email = serializers.EmailField(read_only=True)
phone_number = serializers.CharField(read_only=True)
avatar = Base64ImageField(read_only=True)
@ -430,6 +456,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
"departments",
"skills",
"created_at",
"display_name",
)
def get_departments(self, obj):
@ -459,6 +486,18 @@ class EmployeeListSerializer(serializers.ModelSerializer):
nm = f"{ln} {fn} {pt}".strip()
return nm
def get_display_name(self, obj: Employee) -> str:
"""См. логику в EmployeeBriefSerializer.get_display_name()."""
parts = [obj.last_name or "", obj.first_name or "", obj.patronymic or ""]
fio = " ".join(p.strip() for p in parts if p).strip()
if fio:
return fio
if getattr(obj, "email", None):
return obj.email
if getattr(obj, "phone_number", None):
return obj.phone_number
return f"Сотрудник #{obj.id}"
class PositionSerializer(serializers.ModelSerializer):
# для записи — массив id групп, для чтения — подробная инфа
@ -476,23 +515,92 @@ class PositionSerializer(serializers.ModelSerializer):
class DepartmentRoleSerializer(serializers.ModelSerializer):
permissions = serializers.PrimaryKeyRelatedField(
queryset=Permission.objects.all(), many=True, required=False
# Новая схема: скоуп-права внутри отдела
scoped_permissions = serializers.PrimaryKeyRelatedField(
queryset=DepartmentPermission.objects.all(), many=True, required=False
)
scoped_permission_codes = serializers.ListField(
child=serializers.CharField(), required=False, write_only=True
)
# Backward-compat поля (read-only), чтобы фронт/старые тесты не ломались
permissions = serializers.SerializerMethodField(read_only=True)
permissions_verbose = serializers.SerializerMethodField(read_only=True)
class Meta:
model = DepartmentRole
fields = ["id", "department", "name", "permissions", "permissions_verbose"]
fields = (
"id",
"department",
"name",
# новые write-поля
"scoped_permissions",
"scoped_permission_codes",
# совместимость (read-only зеркала)
"permissions",
"permissions_verbose",
)
read_only_fields = ("id",)
def get_permissions_verbose(self, obj):
# Поддержка записи codes вместо ids
def _apply_codes_if_present(self, instance: DepartmentRole, validated_data: dict):
codes = validated_data.pop("scoped_permission_codes", None)
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
if qs.count() != len(set(codes)):
raise serializers.ValidationError(
{"scoped_permission_codes": "Некоторые коды не существуют"}
)
instance.scoped_permissions.set(list(qs))
def create(self, validated_data):
# вытаскиваем write-only/внешние поля до super().create()
codes = validated_data.pop("scoped_permission_codes", None)
perms = validated_data.pop("scoped_permissions", None)
role = super().create(validated_data)
if perms is not None:
role.scoped_permissions.set(perms)
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
if qs.count() != len(set(codes)):
raise serializers.ValidationError(
{"scoped_permission_codes": "Некоторые коды не существуют"}
)
role.scoped_permissions.set(list(qs))
return role
def update(self, instance, validated_data):
# аналогично: вынимаем до super().update()
codes = validated_data.pop("scoped_permission_codes", None)
perms = validated_data.pop("scoped_permissions", None)
role = super().update(instance, validated_data)
if perms is not None:
role.scoped_permissions.set(perms)
if codes is not None:
qs = DepartmentPermission.objects.filter(code__in=codes)
if qs.count() != len(set(codes)):
raise serializers.ValidationError(
{"scoped_permission_codes": "Некоторые коды не существуют"}
)
role.scoped_permissions.set(list(qs))
return role
# Backward-compat read
def get_permissions(self, obj: DepartmentRole):
return list(obj.scoped_permissions.values_list("id", flat=True))
def get_permissions_verbose(self, obj: DepartmentRole):
return [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
}
for p in obj.permissions.select_related("content_type").all()
{"id": p.id, "code": p.code, "name": p.name}
for p in obj.scoped_permissions.order_by("code").all()
]
@ -518,3 +626,39 @@ class GroupSerializer(serializers.ModelSerializer):
}
for p in qs
]
# --- payload-сериалайзеры для кастомных действий ---
class SetHeadInput(serializers.Serializer):
head_id = serializers.IntegerField(
allow_null=True, required=False
) # None -> снять руководителя
class SetMemberRoleInput(serializers.Serializer):
# поддерживаем алиасы: employee / role
employee_id = serializers.IntegerField(required=False)
role_id = serializers.IntegerField(allow_null=True, required=False)
employee = serializers.IntegerField(required=False)
role = serializers.IntegerField(allow_null=True, required=False)
is_active = serializers.BooleanField(required=False) # 👈 новый флаг (опционально)
def validate(self, attrs):
emp = attrs.get("employee_id") or attrs.get("employee")
role = attrs.get("role_id") if "role_id" in attrs else attrs.get("role")
if emp is None:
raise serializers.ValidationError(
{"employee_id": "This field is required."}
)
attrs["employee_id"] = emp
attrs["role_id"] = role # может быть None (снять роль)
# по умолчанию считаем активным, если поле не передали
if "is_active" not in attrs:
attrs["is_active"] = True
return attrs
class RemoveMemberInput(serializers.Serializer):
employee_id = serializers.IntegerField()

View File

@ -1,39 +1,82 @@
# backend/api/v1/employees/views.py
from __future__ import annotations
from collections import defaultdict
from datetime import timedelta
import phonenumbers
from common.emails import send_templated_mail
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.db import IntegrityError, transaction
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef,
Prefetch, Q, Subquery, Value, When)
from django.db.models import (
Case,
Count,
Exists,
F,
IntegerField,
OuterRef,
Prefetch,
Q,
Subquery,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.crypto import get_random_string
from employees.constants import ACTION_DISMISSED
from employees.models import (Department, DepartmentRole, Employee,
EmployeeAction, EmployeeDepartment, Position,
Skill)
from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
DeptPerm,
EmployeeAction,
EmployeeDepartment,
Position,
Skill,
)
from phonenumbers import PhoneNumberFormat
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import (AllowAny, DjangoModelPermissions,
IsAdminUser, IsAuthenticated)
from rest_framework.permissions import (
AllowAny,
DjangoModelPermissions,
IsAdminUser,
IsAuthenticated,
)
from rest_framework.response import Response
from rest_framework.views import APIView
from ..permissions import (ASSIGN_ROLE_PERM, MANAGE_PERM,
AdminOrActionOrModelPerms, IsDeptManagerForWrite,
IsSelfOrStaff)
from .serializers import (DepartmentRoleSerializer, DepartmentSerializer,
EmailSerializer, EmailVerifySerializer,
EmployeeActionSerializer, EmployeeListSerializer,
EmployeeSerializer, GroupSerializer,
PositionSerializer, RegisterSerializer,
SkillSerializer)
from ..permissions import (
ASSIGN_ROLE_PERM,
MANAGE_PERM,
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
IsDeptManagerForWrite,
IsSelfOrStaff,
has_dept_perm,
)
from .serializers import (
DepartmentRoleSerializer,
DepartmentSerializer,
EmailSerializer,
EmailVerifySerializer,
EmployeeActionSerializer,
EmployeeBriefSerializer,
EmployeeListSerializer,
EmployeeSerializer,
GroupSerializer,
PositionSerializer,
RegisterSerializer,
RemoveMemberInput,
SetHeadInput,
SetMemberRoleInput,
SkillSerializer,
)
Employee = get_user_model()
def _to_bool(val: str | None) -> bool | None:
@ -69,6 +112,139 @@ def _detect_phone_field() -> str | None:
return None
def _validate_head_active(
dept: Department,
employee_id: int,
require_email_verified: bool = True,
) -> tuple[bool, dict | None]:
"""
Проверить, можно ли назначить сотрудника руководителем отдела.
Parameters
----------
dept : Department
Отдел, в который назначается руководитель.
employee_id : int
Кандидат на роль руководителя.
require_email_verified : bool, optional
Требовать ли подтверждение email. Для действий, совершаемых не-руководителем,
оставляем True; для действий текущего руководителя можно ослабить до False.
Returns
-------
(True, None) | (False, {"head_id": [..]})
True — назначать можно; False — нельзя (причина в словаре).
"""
employee_model = Department._meta.get_field("head").remote_field.model
emp = employee_model.objects.filter(id=employee_id).first()
if not emp:
return False, {"head_id": ["Employee not found."]}
# Должен быть включён аккаунт
if getattr(emp, "is_active", True) is False:
return False, {"head_id": ["Employee is inactive."]}
# Требование верификации email — опционально
if require_email_verified and getattr(emp, "email_verified", True) is False:
return False, {"head_id": ["Employee is inactive."]}
return True, None
def _ensure_department_permissions() -> list[dict]:
"""
Гарантирует наличие записей DepartmentPermission на основе DeptPerm.CHOICES.
Возвращает список словарей {id, code, name} в порядке CHOICES.
"""
items: list[dict] = []
# пробежимся по CHOICES, создадим/обновим имя, соберём выдачу
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": label}
)
# если имя в БД отстаёт от CHOICES — мягко синхронизируем
if obj.name != label:
obj.name = label
obj.save(update_fields=["name"])
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
return items
def _build_links_for_dept(dept: Department) -> list[dict]:
"""
Возвращает список линков отдела:
[{ "employee": <EmployeeBriefSerializer.data>, "role": {"id","name"}|None, "is_active": bool }, ...]
"""
links: list[dict] = []
qs = (
EmployeeDepartment.objects.filter(department_id=dept.id)
.select_related("employee", "role")
.order_by(
"employee__last_name",
"employee__first_name",
"employee__patronymic",
"employee_id",
)
)
for link in qs:
emp_data = EmployeeBriefSerializer(link.employee).data # содержит display_name
role = {"id": link.role_id, "name": link.role.name} if link.role_id else None
links.append(
{"employee": emp_data, "role": role, "is_active": bool(link.is_active)}
)
# гарантируем присутствие head
if dept.head_id and all(item["employee"]["id"] != dept.head_id for item in links):
head_data = EmployeeBriefSerializer(dept.head).data
links.insert(0, {"employee": head_data, "role": None, "is_active": True})
return links
def _head_choices_for_dept(dept: Department) -> list[dict]:
"""
Возвращает [{ "id": <int>, "name": <display_name> }] для селекта руководителя.
"""
choices, seen = [], set()
qs = (
EmployeeDepartment.objects.filter(department_id=dept.id)
.select_related("employee")
.order_by(
"employee__last_name",
"employee__first_name",
"employee__patronymic",
"employee_id",
)
)
for link in qs:
data = EmployeeBriefSerializer(link.employee).data
if data["id"] not in seen:
choices.append({"id": data["id"], "name": data["display_name"]})
seen.add(data["id"])
if dept.head_id and dept.head_id not in seen:
head_data = EmployeeBriefSerializer(dept.head).data
choices.insert(0, {"id": head_data["id"], "name": head_data["display_name"]})
return choices
def _perm_choices_synced() -> list[dict]:
"""
Возвращает справочник прав для ролей отдела, синхронизируя записи с DeptPerm.CHOICES.
"""
items = []
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": label}
)
if obj.name != label:
obj.name = label
obj.save(update_fields=["name"])
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
return items
PHONE_FIELD = _detect_phone_field()
@ -299,204 +475,330 @@ class RegisterAPIView(APIView):
)
class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet):
class DepartmentViewSet(viewsets.ModelViewSet):
"""
/api/departments/
- GET (list/detail) — только аутентифицированные пользователи
- POST/DELETE — только staff/superuser или пользователи с правами
- PATCH/PUT — руководитель отдела ИЛИ роль с perm "manage_department"
- Смена head — руководитель отдела ИЛИ роль с perm "change_department_head"
- Управление ролями — руководитель отдела ИЛИ роль с perm "assign_department_role"
CRUD отделов + действия:
- POST /departments/{id}/set_head
- POST /departments/{id}/set_member_role
- POST /departments/{id}/remove_member
Права:
- update/partial_update/destroy → manage_department
- set_head → change_department_head
- set_member_role/remove_member → assign_department_role
- create → staff/superuser
- чтение → аутентифицированным
"""
queryset = Department.objects.select_related("head").prefetch_related("roles").all()
serializer_class = DepartmentSerializer
permission_classes = [IsAuthenticated]
search_fields = ["name", "description"]
ordering = ["name"]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
history_diff_fields = ["name", "description", "head_id", "head_appointed_at"]
def get_queryset(self):
# подзапрос: есть ли head среди связей отдела
head_in_links_qs = EmployeeDepartment.objects.filter(
department=OuterRef("pk"),
employee_id=OuterRef("head_id"),
is_active=True,
)
# пермишены (скоуп-право по отделу)
class ManagePerm(AdminOrDeptAllowed):
"""Право на общее управление отделом."""
return (
Department.objects.select_related("head")
.annotate(
active_links_count=Count(
"employeedepartment",
filter=Q(employeedepartment__is_active=True),
distinct=True,
),
# +1, если head задан и не присутствует среди активных связей
head_extra=Case(
When(
Q(head__isnull=False) & ~Exists(head_in_links_qs),
then=Value(1),
),
default=Value(0),
output_field=IntegerField(),
),
)
.annotate(employees_count=F("active_links_count") + F("head_extra"))
)
required_code = DeptPerm.MANAGE
class ChangeHeadPerm(AdminOrDeptAllowed):
"""Право на смену руководителя отдела."""
required_code = DeptPerm.CHANGE_HEAD
class AssignRolePerm(AdminOrDeptAllowed):
"""Право на назначение ролей участникам отдела."""
required_code = DeptPerm.ASSIGN_ROLE
def get_permissions(self):
# Создание/удаление — только админы
if self.action in ["create", "destroy"]:
if self.action in {"update", "partial_update", "destroy"}:
return [self.ManagePerm()]
if self.action == "set_head":
return [self.ChangeHeadPerm()]
if self.action in {"set_member_role", "remove_member"}:
return [self.AssignRolePerm()]
if self.action == "create":
return [AdminOrActionOrModelPerms()]
# Общие правки отдела — право manage_department на объекте
if self.action in ["update", "partial_update"]:
self.required_perm_code = "manage_department"
return [IsAuthenticated(), IsDeptManagerForWrite()]
return super().get_permissions()
if self.action in {"members", "user_perms", "ui_context"}:
return [IsAuthenticated()]
return [IsAuthenticated()]
def perform_create(self, serializer):
# head_appointed_at у тебя auto_now — обновится сам
serializer.save()
# --- поиск и сортировка, как в старой реализации/тестах ---
ordering = ["name"]
ordering_fields = ["name", "id"]
def _check_head_change_perm(self, request, dept: Department) -> Response | None:
if "head_id" not in request.data:
return None
raw = request.data.get("head_id", None)
if raw is None:
new_head_id = None
def get_queryset(self):
qs = super().get_queryset()
# ---------- аннотации для employees_count ----------
# Активные линкы данного отдела
active_links = EmployeeDepartment.objects.filter(
department_id=OuterRef("pk"), is_active=True
)
# Подсчёт distinct сотрудников по активным линкам
active_count_subq = (
active_links.values("department_id")
.annotate(c=Count("employee_id", distinct=True))
.values("c")[:1]
)
qs = qs.annotate(
active_count=Coalesce(
Subquery(active_count_subq, output_field=IntegerField()),
Value(0),
),
head_in_active=Exists(active_links.filter(employee_id=OuterRef("head_id"))),
).annotate(
employees_count=F("active_count")
+ Case(
When(
Q(head_id__isnull=False) & Q(head_in_active=False),
then=Value(1),
),
default=Value(0),
output_field=IntegerField(),
)
)
# ---------- /аннотации ----------
# Поиск и сортировка — как и раньше
search = (
self.request.query_params.get("search")
or self.request.query_params.get("q")
or ""
).strip()
if search:
qs = qs.filter(
Q(name__icontains=search)
| Q(description__icontains=search)
| Q(head__first_name__icontains=search)
| Q(head__last_name__icontains=search)
| Q(head__patronymic__icontains=search)
).distinct()
ordering = self.request.query_params.get("ordering")
if ordering in {"name", "-name", "id", "-id"}:
qs = qs.order_by(ordering)
else:
try:
new_head_id = int(raw)
except (TypeError, ValueError):
new_head_id = None
if new_head_id != dept.head_id:
self.required_perm_code = "change_department_head"
perm = IsDeptManagerForWrite()
if not perm.has_object_permission(request, self, dept):
return Response(
{"detail": "Недостаточно прав для смены руководителя отдела"},
status=status.HTTP_403_FORBIDDEN,
)
return None
qs = qs.order_by("name")
return qs
# --- частичное изменение: изменение head требует отдельного кода ---
def partial_update(self, request, *args, **kwargs):
dept = self.get_object()
resp = self._check_head_change_perm(request, dept)
if resp is not None:
return resp
"""
Если меняется head, проверяем права и валидацию.
Для действующего руководителя ослабляем требование email_verified.
"""
instance = self.get_object()
data = request.data
wants_head_change = any(k in data for k in ("head", "head_id"))
if wants_head_change:
desired = data.get("head_id", data.get("head", None))
if desired in ("", "null", "None"):
desired = None
current = instance.head_id if instance.head_id is not None else None
desired_norm = int(desired) if desired is not None else None
if current != desired_norm:
perm = self.ChangeHeadPerm()
if not (
perm.has_permission(request, self)
and perm.has_object_permission(request, self, instance)
):
return Response({"detail": "Forbidden"}, status=403)
if desired_norm is None:
instance.head = None
instance.save(update_fields=["head"])
else:
# ослабляем verified, если действие делает текущий head
require_verified = not (
instance.head_id and instance.head_id == request.user.id
)
ok, errs = _validate_head_active(
instance, desired_norm, require_email_verified=require_verified
)
if not ok:
return Response(errs, status=400)
instance.head_id = desired_norm
instance.save(update_fields=["head"])
# подчистим поля
mutable = getattr(request.data, "_mutable", None)
try:
if hasattr(request.data, "_mutable"):
request.data._mutable = True
request.data.pop("head", None)
request.data.pop("head_id", None)
finally:
if hasattr(request.data, "_mutable") and mutable is not None:
request.data._mutable = mutable
return super().partial_update(request, *args, **kwargs)
def update(self, request, *args, **kwargs):
dept = self.get_object()
resp = self._check_head_change_perm(request, dept)
if resp is not None:
return resp
return super().update(request, *args, **kwargs)
# -------- actions --------
@action(detail=True, methods=["post"])
@transaction.atomic
def set_head(self, request, pk=None):
dept: Department = self.get_object()
self.required_perm_code = "change_department_head"
perm = IsDeptManagerForWrite()
if not perm.has_object_permission(request, self, dept):
return Response(
{"detail": "Недостаточно прав для смены руководителя отдела"},
status=status.HTTP_403_FORBIDDEN,
)
"""
Назначение/снятие руководителя отдела.
Текущий руководитель может назначить нового head без email_verified,
если у кандидата is_active=True.
"""
dept = self.get_object()
payload = SetHeadInput(data=request.data)
payload.is_valid(raise_exception=True)
head_id = payload.validated_data.get("head_id")
if "head_id" not in request.data:
return Response(
{
"detail": "Параметр 'head_id' обязателен (null — чтобы снять руководителя)."
},
status=400,
)
head_id = request.data.get("head_id", None)
# снять руководителя (явно передан null)
if head_id is None:
if dept.head_id is not None:
dept.head = None
dept.save(update_fields=["head"]) # auto_now обновит head_appointed_at
dept.head = None
dept.save(update_fields=["head"])
return Response(status=status.HTTP_204_NO_CONTENT)
try:
emp = Employee.objects.get(pk=head_id)
except Employee.DoesNotExist:
return Response({"detail": "Сотрудник не найден"}, status=404)
# ослабляем требование verified, если действие делает текущий head
require_verified = not (dept.head_id and dept.head_id == request.user.id)
if not emp.is_actually_active:
return Response({"detail": "Сотрудник неактивен"}, status=400)
ok, errs = _validate_head_active(
dept, head_id, require_email_verified=require_verified
)
if not ok:
return Response(errs, status=400)
if dept.head_id != emp.id:
dept.head = emp
dept.save(update_fields=["head"])
return Response({"head_id": dept.head_id}, status=200)
dept.head_id = head_id
dept.save(update_fields=["head"])
return Response(self.get_serializer(dept).data, status=200)
@action(detail=True, methods=["post"])
@transaction.atomic
def set_member_role(self, request, pk=None):
"""
Body:
{
"employee_id": 123,
"role_id": 7 | null, # null = снять роль (оставив сотрудника в отделе)
"is_active": true|false (необязательно)
}
"""
dept: Department = self.get_object()
dept = self.get_object()
payload = SetMemberRoleInput(data=request.data)
if not payload.is_valid():
return Response(payload.errors, status=400)
self.required_perm_code = "assign_department_role"
perm = IsDeptManagerForWrite()
if not perm.has_object_permission(request, self, dept):
return Response(
{"detail": "Недостаточно прав для управления ролями отдела"},
status=status.HTTP_403_FORBIDDEN,
)
emp_id = payload.validated_data["employee_id"]
role_id = payload.validated_data.get("role_id")
make_active = payload.validated_data.get("is_active", True)
employee_id = request.data.get("employee_id")
role_id = request.data.get("role_id", None)
is_active = request.data.get("is_active", None)
if not employee_id:
return Response({"detail": "employee_id обязателен"}, status=400)
try:
emp = Employee.objects.get(pk=employee_id)
except Employee.DoesNotExist:
return Response({"detail": "Сотрудник не найден"}, status=404)
# модель сотрудника берём из FK Department.head (без жёсткого User)
employee_model = Department._meta.get_field("head").remote_field.model
get_object_or_404(employee_model, id=emp_id)
# проверяем роль, если указана, что она принадлежит этому отделу
role = None
if role_id is not None:
try:
role = DepartmentRole.objects.get(pk=role_id)
except DepartmentRole.DoesNotExist:
return Response({"detail": "Роль не найдена"}, status=404)
role = get_object_or_404(DepartmentRole, id=role_id)
if role.department_id != dept.id:
return Response(
{"detail": "Роль принадлежит другому отделу"}, status=400
{"role_id": ["Role does not belong to this department."]},
status=400,
)
with transaction.atomic():
link, _ = EmployeeDepartment.objects.select_for_update().get_or_create(
employee=emp, department=dept, defaults={"is_active": True}
)
link.role = role
if is_active is not None:
link.is_active = bool(is_active)
link.full_clean()
link.save()
# создаём/обновляем линк
link, _ = EmployeeDepartment.objects.get_or_create(
employee_id=emp_id,
department_id=dept.id,
defaults={"is_active": make_active, "role": role},
)
link.is_active = bool(make_active)
link.role = role # None => снять роль
link.save(update_fields=["is_active", "role"])
return Response(
{
"employee_id": emp.id,
"role_id": role.id if role else None,
"employee_id": emp_id,
"role_id": (role.id if role else None),
"is_active": link.is_active,
},
status=200,
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["get"], url_path="members")
def members(self, request, pk=None):
"""
GET /api/v1/departments/{id}/members/
Список участников отдела в формате, удобном для фронта-шаблона.
"""
dept = self.get_object()
links = _build_links_for_dept(dept)
return Response({"count": len(links), "results": links}, status=200)
@action(detail=True, methods=["get"], url_path="user-perms")
def user_perms(self, request, pk=None):
"""
GET /api/v1/departments/{id}/user-perms/
Флаги прав текущего пользователя в рамках отдела.
"""
dept = self.get_object()
uid = getattr(request.user, "id", None)
data = {
"is_head": bool(uid and uid == dept.head_id),
"can_manage": has_dept_perm(request.user, dept.id, DeptPerm.MANAGE),
"can_change_head": has_dept_perm(
request.user, dept.id, DeptPerm.CHANGE_HEAD
),
"can_assign_roles": has_dept_perm(
request.user, dept.id, DeptPerm.ASSIGN_ROLE
),
}
return Response(data, status=200)
@action(detail=True, methods=["get"], url_path="ui-context")
def ui_context(self, request, pk=None):
"""
GET /api/v1/departments/{id}/ui-context/
BFF-агрегатор для страницы отдела: возвращает все необходимые данные одной пачкой.
Поля:
- dept: DepartmentSerializer
- roles: DepartmentRoleSerializer[]
- links: см. /members
- head_choices: [{id,name}]
- dept_perm_choices: [{id,code,name}]
- user_perms: {is_head, can_manage, can_change_head, can_assign_roles}
"""
dept = self.get_object()
dept_data = self.get_serializer(dept).data
roles_qs = (
DepartmentRole.objects.filter(department_id=dept.id)
.prefetch_related("scoped_permissions")
.order_by("name", "id")
)
roles_data = DepartmentRoleSerializer(roles_qs, many=True).data
links = _build_links_for_dept(dept)
perm_choices = _perm_choices_synced()
head_choices = _head_choices_for_dept(dept)
user_perms = {
"is_head": (
request.user.id == dept.head_id
if getattr(request.user, "id", None)
else False
),
"can_manage": has_dept_perm(request.user, dept.id, DeptPerm.MANAGE),
"can_change_head": has_dept_perm(
request.user, dept.id, DeptPerm.CHANGE_HEAD
),
"can_assign_roles": has_dept_perm(
request.user, dept.id, DeptPerm.ASSIGN_ROLE
),
}
payload = {
"dept": dept_data,
"roles": roles_data,
"links": links,
"head_choices": head_choices,
"dept_perm_choices": perm_choices,
"user_perms": user_perms,
}
return Response(payload, status=200)
class EmployeeViewSet(viewsets.ModelViewSet):
"""
@ -904,132 +1206,116 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
class DepartmentRoleViewSet(viewsets.ModelViewSet):
"""
/api/v1/positions/
GET list/retrieve — IsAuthenticated
POST/PUT/PATCH/DELETE — Admin OR Django model perms (add/change/delete_position)
POST /{id}/set-groups — Admin OR employees.assign_position_groups
POST /{id}/add-groups — Admin OR employees.assign_position_groups
POST /{id}/remove-groups — Admin OR employees.assign_position_groups
GET /{id}/permissions — IsAuthenticated
Роли отдела:
- list/retrieve с фильтром ?department=<id>
- create/update/destroy → требуется право DeptPerm.ASSIGN_ROLE в рамках отдела роли
- GET /department-roles/perm_choices/
- GET /department-roles/{id}/perms/
- POST /department-roles/{id}/set_perms (ids или codes; полная замена)
"""
queryset = (
DepartmentRole.objects.select_related("department")
.prefetch_related("scoped_permissions")
.all()
)
serializer_class = DepartmentRoleSerializer
permission_classes = [IsAuthenticated, IsDeptManagerForWrite]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
ordering_fields = ["name", "id"]
ordering = ["name"]
pagination_class = None
class AssignRolePerm(AdminOrDeptAllowed):
"""Право на назначение ролей участникам отдела."""
required_code = DeptPerm.ASSIGN_ROLE
# стабильная сортировка для листинга
ordering_fields = ("name", "id")
ordering = ("name", "id")
def get_permissions(self):
if self.action in {
"create",
"update",
"partial_update",
"destroy",
"set_perms",
}:
return [self.AssignRolePerm()]
return [IsAuthenticated()]
def get_queryset(self):
qs = DepartmentRole.objects.select_related("department").prefetch_related(
"permissions__content_type"
)
dep = self.request.query_params.get("department")
if dep:
try:
dep_id = int(dep)
except (TypeError, ValueError):
# нечисловой параметр -> пустой список без ошибки
return DepartmentRole.objects.none()
qs = qs.filter(department_id=dep_id)
"""
Фильтрация по отделу и стабильный порядок.
"""
qs = super().get_queryset()
dept = self.request.query_params.get("department")
if dept:
qs = qs.filter(department_id=dept)
ord_param = self.request.query_params.get("ordering")
if ord_param in {"name", "-name", "id", "-id"}:
# стабильный тай-брейк по id
qs = qs.order_by(
ord_param, "id" if not ord_param.startswith("-") else "-id"
)
else:
qs = qs.order_by(*self.ordering)
return qs
# --- аккуратно задаём требуемые права под экшены ---
def get_permissions(self):
# по умолчанию чтение — только аутентификация
if self.action in ("create", "update", "partial_update", "destroy"):
self.required_perm_code = MANAGE_PERM
elif self.action in ("set_perms", "add_perms", "remove_perms"):
self.required_perm_code = ASSIGN_ROLE_PERM
else:
self.required_perm_code = None
return super().get_permissions()
# create — объект ещё не существует, делаем явную проверку на отдел
def perform_create(self, serializer):
dept: Department = serializer.validated_data["department"]
checker = IsDeptManagerForWrite()
if not checker.has_object_permission(self.request, self, dept):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
"Недостаточно прав для управления ролями этого отдела"
)
serializer.save()
# update/destroy — has_object_permission отработает, т.к. мы добавили IsDeptManagerForWrite
# и он умеет извлекать department из объекта роли (см. патч выше)
# ---- экшены управления правами роли ----
def _validate_perm_ids(self, request):
ids = request.data.get("permission_ids")
if not isinstance(ids, list):
return None, Response(
{"detail": "Поле 'permission_ids' должно быть списком id"}, status=400
)
perms = Permission.objects.filter(id__in=ids).select_related("content_type")
if perms.count() != len(set(ids)):
return None, Response(
{"detail": "Некоторые permissions не найдены"}, status=400
)
return perms, None
@action(detail=False, methods=["get"])
def perm_choices(self, request):
"""
Возвращает справочник скоуп-прав для ролей отдела из DeptPerm.CHOICES.
Если каких-то записей нет в БД, создаёт их (идемпотентно).
"""
data = _ensure_department_permissions()
return Response({"count": len(data), "results": data}, status=200)
@action(detail=True, methods=["get"])
def perms(self, request, pk=None):
role: DepartmentRole = self.get_object()
"""
Возвращает список прав, назначенных данной роли (отсортирован по коду).
"""
role = self.get_object()
data = [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
}
for p in role.permissions.select_related("content_type").all()
{"id": p.id, "code": p.code, "name": p.name}
for p in role.scoped_permissions.order_by("code")
]
return Response({"count": len(data), "results": data})
return Response({"count": len(data), "results": data}, status=200)
@action(detail=True, methods=["post"])
def set_perms(self, request, pk=None):
role: DepartmentRole = self.get_object()
# required_perm_code уже выставлен -> IsDeptManagerForWrite проверит доступ по role.department
perms, err = self._validate_perm_ids(request)
if err:
return err
role.permissions.set(perms)
return Response(
{
"ok": True,
"permission_ids": list(role.permissions.values_list("id", flat=True)),
}
)
"""
Полностью заменяет набор прав у роли.
Тело запроса:
- либо "permission_ids": [1,2,3]
- либо "permission_codes": ["manage_department", ...]
"""
role = self.get_object()
@action(detail=True, methods=["post"])
def add_perms(self, request, pk=None):
role: DepartmentRole = self.get_object()
perms, err = self._validate_perm_ids(request)
if err:
return err
role.permissions.add(*perms)
return Response(
{
"ok": True,
"permission_ids": list(role.permissions.values_list("id", flat=True)),
}
)
ids = request.data.get("permission_ids") or []
codes = request.data.get("permission_codes") or []
@action(detail=True, methods=["post"])
def remove_perms(self, request, pk=None):
role: DepartmentRole = self.get_object()
perms, err = self._validate_perm_ids(request)
if err:
return err
role.permissions.remove(*perms)
return Response(
{
"ok": True,
"permission_ids": list(role.permissions.values_list("id", flat=True)),
}
)
if isinstance(ids, list) and ids:
# валидация набора id
ids_int = {int(i) for i in ids if str(i).isdigit()}
qs = DepartmentPermission.objects.filter(id__in=ids_int)
if qs.count() != len(ids_int):
return Response(
{"detail": "Некоторые permission_ids не найдены."}, status=400
)
elif isinstance(codes, list) and codes:
# валидация набора кодов
codes_set = set(codes)
qs = DepartmentPermission.objects.filter(code__in=codes_set)
if qs.count() != len(codes_set):
return Response(
{"detail": "Некоторые permission_codes не найдены."}, status=400
)
else:
qs = DepartmentPermission.objects.none()
role.scoped_permissions.set(list(qs))
ser = self.get_serializer(role)
return Response(ser.data, status=200)
class SkillViewSet(viewsets.ModelViewSet):

View File

@ -1,13 +1,11 @@
# backend\api\v1\permissions.py
from __future__ import annotations
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
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"
@ -38,10 +36,42 @@ def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__permissions__codename=perm_code,
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 IsDeptManagerForWrite(BasePermission):
"""
Для DepartmentViewSet.
@ -82,6 +112,101 @@ class IsDeptManagerForWrite(BasePermission):
return user_has_dept_perm(user, dept, perm_code)
class AdminOrDeptAllowed(BasePermission):
required_code: str | None = None
required_code_map: dict[str, str] = {}
allow_safe_without_code: bool = True
def get_required_code(self, request, view):
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view):
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):
if obj is None:
return None
# сам отдел
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
# прямое поле FK
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):
try:
return int(str(v))
except Exception:
return None
def has_permission(self, request, view):
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):
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):
@ -97,9 +222,8 @@ class IsSelfOrStaff(BasePermission):
# Основной случай — объект сотрудника.
try:
from employees.models import (
Employee,
) # локальный импорт, чтобы избежать циклов
from employees.models import \
Employee # локальный импорт, чтобы избежать циклов
except Exception:
Employee = None
@ -141,6 +265,7 @@ class AdminOrActionOrModelPerms(DjangoModelPermissions):
Если задан код права (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:

View File

@ -255,8 +255,7 @@ class DepartmentRoleAdmin(admin.ModelAdmin):
list_filter = ("department",)
search_fields = ("name", "department__name")
autocomplete_fields = ("department",)
filter_horizontal = ("permissions",)
filter_horizontal = ("scoped_permissions",)
@admin.register(EmployeeDepartment)
class EmployeeDepartmentAdmin(admin.ModelAdmin):

View File

@ -1,27 +1,38 @@
# backend\employees\constants.py
GENDER_CHOICES = (
(1, 'Мужской'),
(2, 'Женский'),
(0, 'Не указан'),
(1, "Мужской"),
(2, "Женский"),
(0, "Не указан"),
)
ACTION_HIRED = 'hired'
ACTION_DISMISSED = 'dismissed'
ACTION_ON_LEAVE = 'on_leave'
ACTION_RETURNED_FROM_LEAVE = 'returned_from_leave'
ACTION_ON_MATERNITY = 'on_maternity'
ACTION_RETURNED_FROM_MATERNITY = 'returned_from_maternity'
ACTION_TRANSFERRED = 'transferred'
ACTION_REHIRED = 'rehired'
ACTION_HIRED = "hired"
ACTION_DISMISSED = "dismissed"
ACTION_ON_LEAVE = "on_leave"
ACTION_RETURNED_FROM_LEAVE = "returned_from_leave"
ACTION_ON_MATERNITY = "on_maternity"
ACTION_RETURNED_FROM_MATERNITY = "returned_from_maternity"
ACTION_TRANSFERRED = "transferred"
ACTION_REHIRED = "rehired"
ACTION_CHOICES = [
(ACTION_HIRED, 'Принят'),
(ACTION_DISMISSED, 'Уволен'),
(ACTION_ON_LEAVE, 'В отпуске'),
(ACTION_RETURNED_FROM_LEAVE, 'Вернулся из отпуска'),
(ACTION_ON_MATERNITY, 'В декрете'),
(ACTION_RETURNED_FROM_MATERNITY, 'Вернулся из декрета'),
(ACTION_TRANSFERRED, 'Переведен'),
(ACTION_REHIRED, 'Восстановлен'),
]
(ACTION_HIRED, "Принят"),
(ACTION_DISMISSED, "Уволен"),
(ACTION_ON_LEAVE, "В отпуске"),
(ACTION_RETURNED_FROM_LEAVE, "Вернулся из отпуска"),
(ACTION_ON_MATERNITY, "В декрете"),
(ACTION_RETURNED_FROM_MATERNITY, "Вернулся из декрета"),
(ACTION_TRANSFERRED, "Переведен"),
(ACTION_REHIRED, "Восстановлен"),
]
class DeptPerm:
MANAGE = "manage_department"
CHANGE_HEAD = "change_department_head"
ASSIGN_ROLE = "assign_department_role"
CHOICES = (
(MANAGE, "Управлять отделом"),
(CHANGE_HEAD, "Назначать руководителя"),
(ASSIGN_ROLE, "Назначать роли участникам"),
)

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.4 on 2025-09-03 07:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0021_alter_employee_options'),
]
operations = [
migrations.CreateModel(
name='DepartmentPermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам')], max_length=64, unique=True)),
('name', models.CharField(max_length=150)),
],
),
migrations.RemoveField(
model_name='departmentrole',
name='permissions',
),
migrations.AddField(
model_name='departmentrole',
name='scoped_permissions',
field=models.ManyToManyField(blank=True, related_name='roles', to='employees.departmentpermission'),
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 5.2.4 on 2025-09-03 12:02
import django.db.models.constraints
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0022_departmentpermission_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='departmentrole',
options={'ordering': ('name', 'department', 'id'), 'verbose_name': 'Роль в отделе', 'verbose_name_plural': 'Роли в отделе'},
),
migrations.RemoveConstraint(
model_name='employeedepartment',
name='uniq_employee_department',
),
migrations.RemoveIndex(
model_name='employeedepartment',
name='employees_e_departm_40dcd8_idx',
),
migrations.RemoveIndex(
model_name='employeedepartment',
name='employees_e_employe_e4fe19_idx',
),
migrations.AddIndex(
model_name='employeedepartment',
index=models.Index(fields=['department_id', 'is_active', 'employee_id'], name='employees_e_departm_81ade2_idx'),
),
migrations.AddIndex(
model_name='employeedepartment',
index=models.Index(fields=['employee_id', 'department_id'], name='employees_e_employe_b4b0e7_idx'),
),
migrations.AddConstraint(
model_name='employeedepartment',
constraint=models.UniqueConstraint(deferrable=django.db.models.constraints.Deferrable['DEFERRED'], fields=('department_id', 'employee_id'), name='uniq_employee_per_department'),
),
]

View File

@ -1,4 +1,5 @@
# backend\employees\models.py
from common.emails import send_templated_mail
from django.contrib.auth.models import AbstractUser, BaseUserManager, Group, Permission
from django.core.exceptions import ValidationError
from django.db import IntegrityError, models, transaction
@ -8,8 +9,7 @@ from django.utils.crypto import get_random_string
from phonenumber_field.modelfields import PhoneNumberField
from simple_history.models import HistoricalRecords
from common.emails import send_templated_mail
from .constants import ACTION_CHOICES, ACTION_DISMISSED, GENDER_CHOICES
from .constants import ACTION_CHOICES, ACTION_DISMISSED, GENDER_CHOICES, DeptPerm
class DateRangeMixin(models.Model):
@ -406,6 +406,14 @@ class Department(models.Model):
return employees
class DepartmentPermission(models.Model):
code = models.CharField(max_length=64, choices=DeptPerm.CHOICES, unique=True)
name = models.CharField(max_length=150)
def __str__(self):
return self.name or self.code
class DepartmentRole(models.Model):
"""Роль внутри КОНКРЕТНОГО отдела, с правами, заданными начальником отдела."""
@ -413,12 +421,15 @@ class DepartmentRole(models.Model):
Department, on_delete=models.CASCADE, related_name="roles"
)
name = models.CharField(max_length=150)
permissions = models.ManyToManyField(Permission, blank=True)
scoped_permissions = models.ManyToManyField(
DepartmentPermission, blank=True, related_name="roles"
)
class Meta:
verbose_name = "Роль в отделе"
verbose_name_plural = "Роли в отделе"
unique_together = ("department", "name")
ordering = ("name", "department", "id")
def __str__(self):
return f"{self.department}: {self.name}"
@ -447,12 +458,14 @@ class EmployeeDepartment(DateRangeMixin, models.Model):
verbose_name_plural = "Принадлежности к отделам"
constraints = [
models.UniqueConstraint(
fields=["employee", "department"], name="uniq_employee_department"
)
fields=["department_id", "employee_id"],
name="uniq_employee_per_department",
deferrable=models.Deferrable.DEFERRED,
),
]
indexes = [
models.Index(fields=["department", "is_active"]),
models.Index(fields=["employee", "is_active"]),
models.Index(fields=["department_id", "is_active", "employee_id"]),
models.Index(fields=["employee_id", "department_id"]),
]
def __str__(self):

View File

@ -19,7 +19,7 @@ urlpatterns = [
name="department_add",
),
path(
"departments/<int:dept_id>/members/<int:employee_id>/role/",
"departments/<int:pk>/roles/edit/",
views_front.edit_department_role,
name="edit_department_role",
),

View File

@ -464,84 +464,24 @@ def department_list(request):
# ---------- DETAIL ----------
@require_api_auth
def department_detail(request, pk: int):
"""
Тонкая вьюха: берёт агрегированный контекст у API и рендерит шаблон.
"""
api = get_api_client(request)
dep = api.get(f"v1/departments/{pk}/")
if not dep.ok:
messages.error(
request,
"Отдел не найден" if dep.status == 404 else f"Ошибка API ({dep.status})",
)
r = api.get(f"v1/departments/{pk}/ui-context/")
if not r.ok:
messages.error(request, "Отдел не найден" if r.status == 404 else f"Ошибка API ({r.status})")
return redirect("employees:department_list")
dept = dep.json
roles_resp = api.get("v1/department-roles/", params={"department": pk, "page_size": 1000})
roles = (roles_resp.json.get("results", [])
if roles_resp.ok and isinstance(roles_resp.json, dict)
else roles_resp.json or [])
links_resp = api.get(
"v1/employee-departments/",
params={"department": pk, "is_active": True, "page_size": 1000},
)
links = (links_resp.json.get("results", [])
if links_resp.ok and isinstance(links_resp.json, dict)
else links_resp.json or [])
# 👉 нормализуем участников для селекта руководителя
members = []
for ln in links:
e = ln.get("employee")
# API может вернуть dict или id; поддержим оба варианта
if isinstance(e, dict):
emp_id = e.get("id")
last_name = e.get("last_name") or ""
first_name = e.get("first_name") or ""
patronymic = e.get("patronymic") or ""
full_name = " ".join([s for s in (last_name, first_name, patronymic) if s])
members.append({
"id": emp_id,
"name": full_name or f"Сотрудник #{emp_id}",
})
elif e is not None:
# fallback, если пришёл только id
emp_id = e
members.append({
"id": emp_id,
"name": f"Сотрудник #{emp_id}",
})
# Уберём дубликаты, если вдруг
seen = set()
head_choices = []
for m in members:
mid = m.get("id")
if mid and mid not in seen:
head_choices.append(m)
seen.add(mid)
context = {
"dept": dept,
"roles": roles,
"links": links,
"head_choices": head_choices, # 👈 добавили
"edit_form": DepartmentEditForm(initial={
"name": dept.get("name", ""),
"description": dept.get("description", ""),
}),
"set_head_form": SetHeadForm(),
"set_member_role_form": SetMemberRoleForm(),
}
page = request.GET.get("page") or 1
r = api.get("v1/feed/posts/", params={"department": pk, "page": page, "ordering": "-created_at"})
posts = []
if r.ok:
data = r.json
posts = data.get("results", data) if isinstance(data, dict) else data
context.update({"posts": posts, "page": page})
return render(request, "employees/department_detail.html", context)
ctx = r.json if isinstance(r.json, dict) else {}
# Для обратной совместимости с шаблоном продублируем флаги как плоские поля:
perms = ctx.get("user_perms") or {}
ctx.update({
"can_manage": perms.get("can_manage", False),
"can_change_head": perms.get("can_change_head", False),
"can_assign_roles": perms.get("can_assign_roles", False),
})
return render(request, "employees/department_detail.html", ctx)
# ---------- EDIT BASIC FIELDS ----------
@require_api_auth
@ -680,48 +620,78 @@ class DepartmentDeleteView(LoginRequiredMixin, StaffOnlyMixin, DeleteView):
success_url = reverse_lazy("employees:department_list")
@login_required
def edit_department_role(request, dept_id, employee_id):
department = get_object_or_404(Department, pk=dept_id)
@require_api_auth
@require_POST
def edit_department_role(request, pk: int):
"""
Универсальный обработчик ролей отдела:
- op=create : POST v1/department-roles/ {department: pk, name}
- op=update : PATCH v1/department-roles/<role_id>/ {name}
- op=delete : DELETE v1/department-roles/<role_id>/
"""
api = get_api_client(request)
op = (request.POST.get("op") or "").lower().strip()
# только staff или руководитель отдела
if not (request.user.is_staff or department.head_id == request.user.id):
messages.error(request, "Нет прав изменять роли в этом отделе.")
return redirect("employees:employees_list")
# Быстрая проверка прав (как и в шаблоне)
dept_resp = api.get(f"v1/departments/{pk}/")
if not dept_resp.ok:
messages.error(request, "Отдел не найден.")
return redirect("employees:department_detail", pk=pk)
dept = dept_resp.json
allowed = request.user.is_staff or (dept.get("head") and dept["head"].get("id") == request.user.id)
if not allowed:
messages.error(request, "Недостаточно прав для управления ролями отдела.")
return redirect("employees:department_detail", pk=pk)
link = get_object_or_404(
EmployeeDepartment,
department=department,
employee_id=employee_id,
)
try:
if op == "create":
name = (request.POST.get("name") or "").strip()
if not name:
messages.error(request, "Название роли не может быть пустым.")
return redirect("employees:department_detail", pk=pk)
if request.method == "POST":
form = DepartmentMemberRoleForm(request.POST, instance=link)
if form.is_valid():
form.save()
messages.success(request, "Роль в отделе обновлена.")
next_url = request.POST.get("next") or (
reverse("employees:employees_list") + f"?department={dept_id}"
)
return redirect(next_url)
else:
form = DepartmentMemberRoleForm(instance=link)
resp = api.post("v1/department-roles/", json={"department": pk, "name": name})
if resp.ok:
messages.success(request, f"Роль «{name}» создана.")
else:
messages.error(request, f"Ошибка создания роли: {getattr(resp, 'status', '')} {resp.text or ''}")
employee = link.employee
next_url = request.GET.get("next") or (
reverse("employees:employees_list") + f"?department={dept_id}"
)
return render(
request,
"employees/department_role_form.html",
{
"department": department,
"employee": employee,
"form": form,
"next_url": next_url,
},
)
elif op == "update":
role_id = request.POST.get("role_id")
name = (request.POST.get("name") or "").strip()
# 1) Сохраняем имя
if name:
api.patch(f"v1/department-roles/{role_id}/", json={"name": name})
# 2) Сохраняем права роли
perm_ids = request.POST.getlist("permission_ids") # из чекбоксов
perm_ids = [int(x) for x in perm_ids if str(x).isdigit()]
resp = api.post(f"v1/department-roles/{role_id}/set_perms", json={"permission_ids": perm_ids})
if resp.ok:
messages.success(request, f"Роль обновлена: «{name}».")
else:
messages.error(request, f"Ошибка обновления роли: {getattr(resp, 'status', '')} {resp.text or ''}")
elif op == "delete":
role_id = request.POST.get("role_id")
if not role_id:
messages.error(request, "Не указан идентификатор роли для удаления.")
return redirect("employees:department_detail", pk=pk)
resp = api.delete(f"v1/department-roles/{role_id}/")
if resp.ok or getattr(resp, "status", None) == 204:
messages.success(request, "Роль удалена.")
else:
messages.error(request, f"Ошибка удаления роли: {getattr(resp, 'status', '')} {resp.text or ''}")
else:
messages.error(request, "Неизвестная операция.")
except Exception as exc:
messages.error(request, f"Ошибка: {exc}")
return redirect("employees:department_detail", pk=pk)
# ---------- Приглашение в отдел ----------
@ -897,7 +867,7 @@ def employee_set_position_front(request: HttpRequest, emp_id: int) -> JsonRespon
if not position_id:
return JsonResponse({"detail": "position_id is required"}, status=400)
api = get_api_client(request)
resp = api.patch(f"v1/employees/{emp_id}/", json={"position_id": position_id})
ok, data, status = _api_ok_or_error(resp)

45
backend/env.example Normal file
View File

@ -0,0 +1,45 @@
# --- Django ---
DJANGO_SECRET_KEY=change-me
DEBUG=True
USE_SQLITE=True
ALLOWED_HOSTS=localhost,127.0.0.1
# If you want to use Postgres instead of SQLite (set USE_SQLITE=False)
POSTGRES_USER=eusrr_user
POSTGRES_PASSWORD=change-me
POSTGRES_DB=eusrr
DB_HOST=postgres
DB_PORT=5432
# --- Secondary HikCentral DB (optional; map your own instance) ---
HIK_DB_NAME=cms
HIK_DB_USER=postgres
HIK_DB_PASSWORD=change-me
HIK_DB_HOST=postgres
HIK_DB_PORT=5432
# --- Redis for Channels/WebSockets ---
REDIS_HOST=redis
REDIS_PORT=6379
# --- Email (SMTP) ---
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.example.com
EMAIL_PORT=465
EMAIL_USE_SSL=true
EMAIL_USE_TLS=false
EMAIL_HOST_USER=no-reply@example.com
EMAIL_HOST_PASSWORD=change-me
DEFAULT_FROM_EMAIL=Robotail <no-reply@example.com>
# --- Regional / API base ---
PHONE_DEFAULT_REGION=RU
CSRF_TRUSTED_ORIGINS=http://127.0.0.1:9000,http://localhost:9000
API_BASE_URL=http://127.0.0.1:9000/api
# --- Integrations (placeholders) ---
TELEGRAM_BOT_TOKEN=put-your-token-here
USERNAME_TELEGRAM_BOT=your_bot_username
SMS_API_TOKEN=...
SMS_API_URL=https://new.smsgorod.ru/apiSms/create/
SMS_API_SENDER=EUSRR

View File

@ -0,0 +1,531 @@
{# backend/templates/employees/_department_controls.html #}
{# Ожидаемый контекст:
dept: {id, name, description, head?}
head_choices: [{id, name}] # список кандидатов
roles: [{id, name, permissions: [ids]}] # для совместимости: ids выбранных прав
dept_perm_choices: [{id, code, name}] # справочник прав для ролей
can_manage: bool
can_assign_roles: bool
can_change_head: bool
#}
{# ===== 1) Управление отделом ===== #}
{% if can_manage %}
<section class="feed-card mb-4">
<div class="feed-hd">
<i class="bi-gear"></i>
<div class="feed-meta">
<div class="feed-author">Управление отделом</div>
<div class="feed-sub">Название и описание</div>
</div>
</div>
<div class="feed-body">
<form method="post" action="{% url 'employees:department_edit' dept.id %}">
{% csrf_token %}
<div class="row g-2">
<div class="col-12 col-md-4">
<label class="form-label mb-1">Название</label>
<input name="name"
class="form-control form-control-sm"
value="{{ dept.name|default_if_none:'' }}"
required>
</div>
<div class="col-12 col-md-8">
<label class="form-label mb-1">Описание</label>
<textarea name="description"
class="form-control form-control-sm"
rows="2">{{ dept.description|default_if_none:'' }}</textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi-check2 me-1"></i>Сохранить
</button>
</div>
</div>
</form>
</div>
</section>
{% endif %}
{# ===== 2) Роли отдела ===== #}
{% if can_assign_roles %}
<div class="ios-acc mb-2" id="deptRolesBlock"
data-url="{% url 'employees:edit_department_role' dept.id %}">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-list-check"></i>
<div class="ios-section-title mb-0">Роли отдела</div>
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
{# === РЕДАКТИРОВАТЬ РОЛЬ (одна кнопка "Сохранить изменения") === #}
<div class="col-12 col-lg-7">
<form id="roleEditForm" method="post" action="{% url 'employees:edit_department_role' dept.id %}">
{% csrf_token %}
<input type="hidden" name="op" value="update">
<input type="hidden" name="role_id" id="edit_role_id" value="">
<div class="row g-2">
<div class="col-sm-6">
<label class="form-label" for="f_role_select">Выбрать роль</label>
<select id="f_role_select" class="form-select">
<option value="">— Выберите роль —</option>
{% for r in roles %}
<option value="{{ r.id }}"
data-name="{{ r.name|escape }}"
data-perms="{% if r.permissions %}{{ r.permissions|join:',' }}{% elif r.scoped_permissions %}{% for p in r.scoped_permissions %}{% if not forloop.first %},{% endif %}{{ p.id }}{% endfor %}{% endif %}">
{{ r.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6">
<label class="form-label" for="edit_role_name">Название роли</label>
<input id="edit_role_name" class="form-control" name="name" placeholder="Напр. Старший инженер">
</div>
<div class="col-12">
<div class="d-flex flex-wrap gap-2 align-items-center">
<button type="button"
class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#rolePermsEditModal"
{% if not dept_perm_choices %}disabled{% endif %}>
Выбрать права
</button>
<div id="edit_role_perms_chips" class="chips"></div>
</div>
{# сюда JS при сохранении подложит скрытые permission_ids (если менялись) #}
<div id="edit_role_perms_hidden" class="d-none"></div>
</div>
<div class="col-12 d-flex gap-2 mt-1">
<button type="submit" class="btn btn-primary btn-sm" id="roleSaveBtn">
<span class="lbl"><i class="bi-check2 me-1"></i>Сохранить изменения</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
<button type="submit" class="btn btn-danger btn-sm" data-op="delete"
onclick="return confirm('Удалить выбранную роль? Это повлияет на сотрудников, у которых она назначена.');">
<i class="bi-trash me-1"></i>Удалить роль
</button>
</div>
</div>
</form>
</div>
{# === СОЗДАТЬ РОЛЬ (панель показывается по кнопке) === #}
<div class="col-12 col-lg-5">
<label class="form-label d-block">&nbsp;</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="collapse" data-bs-target="#newRolePane">
Создать роль
</button>
<div class="collapse mt-2" id="newRolePane">
<form id="roleCreateForm" method="post" action="{% url 'employees:edit_department_role' dept.id %}">
{% csrf_token %}
<input type="hidden" name="op" value="create">
<div class="row g-2">
<div class="col-12">
<label class="form-label" for="new_role_name">Название новой роли</label>
<input id="new_role_name" class="form-control" name="name" placeholder="Напр. Руководитель смены" required>
</div>
<div class="col-12">
<div class="d-flex flex-wrap gap-2 align-items-center">
<button type="button"
class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#rolePermsCreateModal"
{% if not dept_perm_choices %}disabled{% endif %}>
Выбрать права
</button>
<div id="new_role_perms_chips" class="chips"></div>
</div>
{# сюда JS перед сабмитом положит <input name="permission_ids" ...> #}
<div id="new_role_perms_hidden" class="d-none"></div>
</div>
<div class="col-12 d-flex gap-2 mt-1">
<button type="submit" class="btn btn-success btn-sm">
<i class="bi-plus-lg me-1"></i>Создать
</button>
<button type="button" class="btn btn-ghost btn-sm" data-bs-toggle="collapse" data-bs-target="#newRolePane">
Отмена
</button>
</div>
</div>
</form>
</div>
</div>
</div> {# /row #}
</div>
</div>
{# === МОДАЛ: выбор прав (редактирование) === #}
<div class="modal fade" id="rolePermsEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Права роли</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
{% if dept_perm_choices %}
<input type="search" class="form-control mb-3" id="permSearchEdit" placeholder="Поиск по правам">
<div id="permListEdit" class="list-group">
{% for p in dept_perm_choices %}
<label class="list-group-item d-flex align-items-center gap-2">
<input class="form-check-input me-2 perm-check-edit" type="checkbox" value="{{ p.id }}">
<span class="flex-grow-1">{{ p.name }}</span>
<span class="text-secondary small">{{ p.code }}</span>
</label>
{% endfor %}
</div>
{% else %}
<div class="text-muted">Нет доступных прав.</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" id="permClearEdit" class="btn btn-ghost">Сбросить</button>
<button type="button" id="permDoneEdit" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>
</div>
</div>
</div>
{# === МОДАЛ: выбор прав (создание) === #}
<div class="modal fade" id="rolePermsCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Права новой роли</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
{% if dept_perm_choices %}
<input type="search" class="form-control mb-3" id="permSearchCreate" placeholder="Поиск по правам">
<div id="permListCreate" class="list-group">
{% for p in dept_perm_choices %}
<label class="list-group-item d-flex align-items-center gap-2">
<input class="form-check-input me-2 perm-check-create" type="checkbox" value="{{ p.id }}">
<span class="flex-grow-1">{{ p.name }}</span>
<span class="text-secondary small">{{ p.code }}</span>
</label>
{% endfor %}
</div>
{% else %}
<div class="text-muted">Нет доступных прав.</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" id="permClearCreate" class="btn btn-ghost">Сбросить</button>
<button type="button" id="permDoneCreate" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>
</div>
</div>
</div>
<script>
(function(){
function byId(id){ return document.getElementById(id); }
function renderChips(box, ids, nameMap){
if (!box) return;
box.innerHTML = '';
(ids || []).forEach(id => {
const span = document.createElement('span');
span.className = 'chip';
span.dataset.id = id;
span.innerHTML = (nameMap[id] || ('#'+id)) + ' <button type="button" class="btn btn-sm p-0 ms-1" aria-label="Удалить" style="line-height:1;">×</button>';
box.appendChild(span);
});
}
function buildNameMap(listBox){
const map = {};
(listBox?.querySelectorAll('.list-group-item') || []).forEach(row => {
const chk = row.querySelector('input[type="checkbox"]');
const name = row.querySelector('.flex-grow-1')?.textContent || '';
if (chk) map[chk.value] = name;
});
return map;
}
function setChecksFromSet(listBox, set){
(listBox?.querySelectorAll('input[type="checkbox"]') || []).forEach(ch => ch.checked = set.has(ch.value));
}
function collectChecked(listBox){
const out = [];
(listBox?.querySelectorAll('input[type="checkbox"]:checked') || []).forEach(ch => out.push(ch.value));
return out;
}
function filterList(input, listBox){
const q = (input?.value || '').toLowerCase().trim();
(listBox?.querySelectorAll('.list-group-item') || []).forEach(row => {
const name = row.querySelector('.flex-grow-1')?.textContent.toLowerCase() || '';
const id = row.querySelector('input[type="checkbox"]')?.value || '';
row.classList.toggle('d-none', !(name.includes(q) || id.includes(q)));
});
}
function busy(btn, on){
if (!btn) return;
const lbl = btn.querySelector('.lbl');
const sp = btn.querySelector('.spinner-border');
if (on){ btn.setAttribute('disabled',''); sp?.classList.remove('d-none'); lbl?.classList.add('visually-hidden'); }
else { btn.removeAttribute('disabled'); sp?.classList.add('d-none'); lbl?.classList.remove('visually-hidden'); }
}
// ——— EDIT ———
const block = byId('deptRolesBlock');
if (!block) return;
const endpoint = block.dataset.url || '';
const editForm = byId('roleEditForm');
const saveBtn = byId('roleSaveBtn');
const roleSelect = byId('f_role_select');
const editRoleId = byId('edit_role_id');
const editRoleName = byId('edit_role_name');
const editChips = byId('edit_role_perms_chips');
const listEdit = byId('permListEdit');
const searchEdit = byId('permSearchEdit');
const clearEdit = byId('permClearEdit');
const doneEdit = byId('permDoneEdit');
const nameMapEdit = buildNameMap(listEdit);
const editSelected = new Set();
// исходные значения текущей роли (для сравнения)
let initialName = '';
let initialPerms = new Set();
function hydrateEditFromSelect(){
const opt = roleSelect?.selectedOptions?.[0];
if (!opt || !opt.value){
editRoleId.value = '';
editRoleName.value = '';
editSelected.clear();
initialName = '';
initialPerms = new Set();
renderChips(editChips, [], nameMapEdit);
setChecksFromSet(listEdit, editSelected);
return;
}
editRoleId.value = opt.value;
const nm = opt.dataset.name || '';
editRoleName.value = nm;
initialName = nm;
editSelected.clear();
initialPerms = new Set();
const csv = (opt.dataset.perms || '').split(',').map(s => s.trim()).filter(Boolean);
csv.forEach(id => { editSelected.add(id); initialPerms.add(id); });
renderChips(editChips, Array.from(editSelected), nameMapEdit);
setChecksFromSet(listEdit, editSelected);
}
roleSelect?.addEventListener('change', hydrateEditFromSelect);
hydrateEditFromSelect();
// модал (edit): поиск/сброс/готово
searchEdit?.addEventListener('input', () => filterList(searchEdit, listEdit));
clearEdit?.addEventListener('click', () => {
editSelected.clear();
renderChips(editChips, [], nameMapEdit);
setChecksFromSet(listEdit, editSelected);
});
doneEdit?.addEventListener('click', () => {
const ids = collectChecked(listEdit);
editSelected.clear();
ids.forEach(id => editSelected.add(id));
renderChips(editChips, Array.from(editSelected), nameMapEdit);
});
// удаление чипса
editChips?.addEventListener('click', (e) => {
const btn = e.target.closest('button'); const chip = btn?.closest('.chip');
if (!chip) return;
editSelected.delete(chip.dataset.id);
renderChips(editChips, Array.from(editSelected), nameMapEdit);
setChecksFromSet(listEdit, editSelected);
});
// Перехват сохранения: одна кнопка — 1–2 запроса (update, затем set_perms при необходимости)
editForm?.addEventListener('submit', async (e) => {
const btn = e.submitter;
if (btn?.dataset?.op === 'delete') {
// удалить — отдаём обычной отправке формы
return;
}
e.preventDefault();
// валидация выбора роли
const rid = (editRoleId.value || '').trim();
if (!rid) { alert('Выберите роль для редактирования.'); return; }
const csrftoken = editForm.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
// что поменялось?
const newName = (editRoleName.value || '').trim();
const nameChanged = (newName !== initialName);
const newPerms = Array.from(editSelected);
const permsChanged = (newPerms.length !== initialPerms.size) ||
newPerms.some(id => !initialPerms.has(id));
if (!nameChanged && !permsChanged) {
// нечего сохранять
return;
}
busy(saveBtn, true);
try {
// 1) rename если нужно
if (nameChanged) {
const form = new URLSearchParams();
form.set('op', 'update');
form.set('role_id', rid);
form.set('name', newName);
const r = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
body: form,
credentials: 'same-origin'
});
if (!r.ok) {
const t = await r.text();
throw new Error('Не удалось сохранить название: ' + (t || `HTTP ${r.status}`));
}
}
// 2) права если нужно
if (permsChanged) {
const form = new URLSearchParams();
form.set('op', 'set_perms');
form.set('role_id', rid);
newPerms.forEach(id => form.append('permission_ids', id));
const r2 = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
body: form,
credentials: 'same-origin'
});
if (!r2.ok) {
const t = await r2.text();
throw new Error('Не удалось сохранить права: ' + (t || `HTTP ${r2.status}`));
}
}
// успех
location.reload();
} catch (err) {
console.error(err);
alert(err?.message || 'Не удалось сохранить изменения.');
} finally {
busy(saveBtn, false);
}
});
// ——— CREATE ———
const createForm = byId('roleCreateForm');
const listCreate = byId('permListCreate');
const searchCreate = byId('permSearchCreate');
const clearCreate = byId('permClearCreate');
const doneCreate = byId('permDoneCreate');
const createChips = byId('new_role_perms_chips');
const createHidden = byId('new_role_perms_hidden');
const nameMapCreate = buildNameMap(listCreate);
const createSelected = new Set();
searchCreate?.addEventListener('input', () => filterList(searchCreate, listCreate));
clearCreate?.addEventListener('click', () => {
createSelected.clear();
renderChips(createChips, [], nameMapCreate);
setChecksFromSet(listCreate, createSelected);
});
doneCreate?.addEventListener('click', () => {
const ids = collectChecked(listCreate);
createSelected.clear();
ids.forEach(id => createSelected.add(id));
renderChips(createChips, Array.from(createSelected), nameMapCreate);
});
createChips?.addEventListener('click', (e) => {
const btn = e.target.closest('button'); const chip = btn?.closest('.chip');
if (!chip) return;
createSelected.delete(chip.dataset.id);
renderChips(createChips, Array.from(createSelected), nameMapCreate);
setChecksFromSet(listCreate, createSelected);
});
createForm?.addEventListener('submit', () => {
createHidden.innerHTML = '';
Array.from(createSelected).forEach(id => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'permission_ids';
inp.value = id;
createHidden.appendChild(inp);
});
});
// модалки наверх, как в _employee_edit
(function moveModalsToBody(){
['rolePermsEditModal','rolePermsCreateModal'].forEach(id => {
const m = byId(id);
if (m && m.parentNode !== document.body) document.body.appendChild(m);
});
})();
})();
</script>
{% endif %}
{# ===== 3) Назначение руководителя ===== #}
{% if can_change_head %}
<section class="feed-card mb-4">
<div class="feed-hd">
<i class="bi-person-check"></i>
<div class="feed-meta">
<div class="feed-author">Назначение руководителя</div>
<div class="feed-sub">Выбор или снятие руководителя отдела</div>
</div>
</div>
<div class="feed-body">
<form method="post" action="{% url 'employees:department_set_head' dept.id %}">
{% csrf_token %}
<div class="row g-2 align-items-end">
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label mb-1">Руководитель</label>
<select class="form-select form-select-sm" name="head_id">
<option value="">— Снять руководителя —</option>
{% for m in head_choices %}
<option value="{{ m.id }}"
{% if dept.head and dept.head.id == m.id %}selected{% endif %}>
{{ m.name }}
</option>
{% empty %}
<option value="" disabled>Нет кандидатов</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi-person-check me-1"></i>Обновить руководителя
</button>
</div>
</div>
</form>
</div>
</section>
{% endif %}
{# Если нет ни одного права — информируем #}
{% if not can_manage and not can_assign_roles and not can_change_head %}
<div class="alert alert-light border small mb-4">
У вас нет прав для управления этим отделом.
</div>
{% endif %}

View File

@ -59,71 +59,8 @@
{# ===== Управление отделом (для staff/главы) ===== #}
{% if user.is_staff or dept.head and dept.head.id == user.id %}
<div class="feed-card mb-4">
<div class="feed-hd">
<i class="bi-gear"></i>
<div class="feed-meta">
<div class="feed-author">Управление отделом</div>
<div class="feed-sub">Название, описание и руководитель</div>
</div>
</div>
<div class="feed-body">
<div class="row g-3">
{% include "employees/_department_controls.html" with dept=dept edit_form=edit_form head_choices=head_choices %}
{# Редактирование name/description -> /employees/departments/<id>/edit/ #}
<div class="col-12 col-lg-6">
<form method="post" action="{% url 'employees:department_edit' dept.id %}">
{% csrf_token %}
<div class="mb-2">
<label class="form-label">Название</label>
<input name="name" class="form-control" value="{{ dept.name|default_if_none:'' }}" required>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea name="description" class="form-control" rows="3">{{ dept.description|default_if_none:'' }}</textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi-check2 me-1"></i>Сохранить
</button>
</form>
</div>
{# Назначение/снятие руководителя -> /employees/departments/<id>/set-head/ #}
<div class="col-12 col-lg-6">
<form method="post" action="{% url 'employees:department_set_head' dept.id %}">
{% csrf_token %}
<label class="form-label">Руководитель</label>
<select class="form-select" name="head_id">
<option value="">— Снять руководителя —</option>
{# Берём участников из links; поддерживаем и dict, и id #}
{% for link in links %}
{% with emp=link.employee %}
{% if emp %}
{% with eid=emp.id|default:emp %}
<option value="{{ eid }}"
{% if dept.head and dept.head.id == eid %}selected{% endif %}>
{% if emp.last_name %}
{{ emp.last_name }}{% if emp.first_name %} {{ emp.first_name }}{% endif %}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
{% else %}
Сотрудник #{{ eid }}
{% endif %}
</option>
{% endwith %}
{% endif %}
{% endwith %}
{% endfor %}
</select>
<div class="mt-2">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi-person-check me-1"></i>Обновить руководителя
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{# ===== Команда отдела (только сотрудники с назначённой ролью) ===== #}

View File

@ -1,29 +1,51 @@
# backend/tests/api/v1/employees/test_department_head_rights.py
import pytest
from django.urls import reverse
from django.utils import timezone
from django.contrib.auth.models import Permission
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from employees.models import Employee, Department, DepartmentRole, EmployeeDepartment
from employees.models import Department, DepartmentRole, EmployeeDepartment, Employee
# --- helpers ---
_seq = 1
User = get_user_model()
def _unique_email(prefix="user"):
global _seq
_seq += 1
return f"{prefix}{_seq}@example.com"
# =========================
# Helpers
# =========================
def _unique_phone():
global _seq
_seq += 1
return f"+7999{_seq:07d}"
def _unique_phone() -> str:
base = 79000000000 # 79 + 9 нулей
return str(base + User.objects.count())
def make_user(email: str, staff: bool = False, verified: bool = True) -> User:
u = User.objects.create_user(
email=email,
password="pwd12345",
phone_number=_unique_phone(),
send_activation_email=False,
email_verified=True,
)
# 👇 явная активация для тестов управленческих прав
if not u.is_active:
u.is_active = True
u.save(update_fields=["is_active"])
return u
def _unique_email() -> str:
base = "asd@mail.com"
return str(Employee.objects.count()) + base
def _make_user(staff=False, superuser=False) -> Employee:
"""
Создаёт активного и 'email_verified' пользователя, чтобы не споткнуться о
валидацию при назначении руководителя.
"""
u = Employee.objects.create_user(
email=_unique_email(),
password="pass",
@ -34,149 +56,114 @@ def _make_user(staff=False, superuser=False) -> Employee:
)
u.is_staff = staff
u.is_superuser = superuser
u.email_verified = True
u.is_active = True
u.email_verified = True # <- критично для твоей нынешней валидации
u.is_active = True # <- тоже нужен для твоей ветки is_active
u.save(update_fields=["is_staff", "is_superuser", "email_verified", "is_active"])
return u
def _dept_set_head(api_client, dept_id, head_id):
url = reverse("api:v1:departments-set-head", args=[dept_id])
payload = {"head_id": head_id} if head_id is not None else {"head_id": None}
return api_client.post(url, payload, format="json")
def make_role(
dept: Department, name: str, codes: list[str] | None = None
) -> DepartmentRole:
role = DepartmentRole.objects.create(department=dept, name=name)
# в этих тестах права роли не нужны (руководитель имеет доступ сам по себе),
# но фабрика оставлена для удобства
return role
# --- tests ---
@pytest.mark.django_db
def test_old_head_loses_rights_after_change(api_client):
# setup: old head
old_head = _make_user()
api_client.force_authenticate(user=old_head)
dept = Department.objects.create(name="Alpha", head=old_head)
# Как глава — может править отдел (partial_update требует manage_department)
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
resp = api_client.patch(url_detail, {"description": "init"}, format="json")
assert resp.status_code == 200
# Назначаем нового главу (делает текущий глава)
new_head = _make_user()
resp = _dept_set_head(api_client, dept.id, new_head.id)
assert resp.status_code == 200
assert resp.data["head_id"] == new_head.id
# Старый глава больше не глава -> прав на управление отделом больше нет
resp = api_client.patch(url_detail, {"description": "should fail"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_new_head_gets_rights_and_membership_created(api_client):
# setup: без главы
creator = _make_user() # просто актор для вызовов
api_client.force_authenticate(user=creator)
dept = Department.objects.create(name="Beta")
# Назначаем нового главу (создатель не обязан иметь права — у вас это ограничивается IsDeptManagerForWrite;
# для чистоты будем действовать от staff)
staff = _make_user(staff=True)
api_client.force_authenticate(user=staff)
new_head = _make_user()
resp = _dept_set_head(api_client, dept.id, new_head.id)
assert resp.status_code == 200
# У нового главы есть права управления отделом
api_client.force_authenticate(user=new_head)
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
resp = api_client.patch(url_detail, {"description": "ok"}, format="json")
assert resp.status_code == 200
# И ему создана активная связь членства
link = EmployeeDepartment.objects.filter(
employee=new_head, department=dept, is_active=True
).first()
assert link is not None
assert link.date_from is not None
@pytest.mark.django_db
def test_remove_head_deactivates_membership_and_revokes_rights(api_client):
# setup: есть глава
head = _make_user()
staff = _make_user(staff=True)
dept = Department.objects.create(name="Gamma", head=head)
# как глава он может управлять
api_client.force_authenticate(user=head)
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
assert (
api_client.patch(url_detail, {"description": "ok"}, format="json").status_code
== 200
)
# staff снимает главу (head -> null)
api_client.force_authenticate(user=staff)
resp = _dept_set_head(api_client, dept.id, None)
assert resp.status_code == status.HTTP_204_NO_CONTENT
# связь старого главы должна стать неактивной и получить date_to
link = EmployeeDepartment.objects.filter(employee=head, department=dept).first()
assert link is not None
assert link.is_active is False
assert link.date_to is not None
# и прав больше нет
api_client.force_authenticate(user=head)
resp = api_client.patch(url_detail, {"description": "nope"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_old_head_keeps_rights_if_role_grants_after_change(api_client):
# setup: старый глава
old_head = _make_user()
dept = Department.objects.create(name="Delta", head=old_head)
# подготовим роль отдела с manage_department и назначим её старому главе (как обычному члену)
role = DepartmentRole.objects.create(department=dept, name="ManagerRole")
p_manage = Permission.objects.get(
content_type__app_label="employees", codename="manage_department"
)
role.permissions.add(p_manage)
def add_member(
user: User,
dept: Department,
role: DepartmentRole | None = None,
is_active: bool = True,
):
EmployeeDepartment.objects.update_or_create(
employee=old_head,
employee=user,
department=dept,
defaults={"role": role, "is_active": True, "date_from": timezone.now().date()},
defaults={"is_active": is_active, "role": role},
)
# меняем главу
staff = _make_user(staff=True)
api_client.force_authenticate(user=staff)
new_head = _make_user()
assert _dept_set_head(api_client, dept.id, new_head.id).status_code == 200
# старый глава уже не head, но он обладает ролью с manage_department -> должен иметь доступ
api_client.force_authenticate(user=old_head)
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
resp = api_client.patch(url_detail, {"description": "still ok"}, format="json")
assert resp.status_code == 200
# =========================
# Fixtures
# =========================
@pytest.fixture()
def api_client() -> APIClient:
return APIClient()
# =========================
# Tests
# =========================
@pytest.mark.django_db
def test_change_head_does_not_deactivate_old_membership_by_default(api_client):
# при смене head -> старый head остаётся активным членом (согласно текущей логике save())
old_head = _make_user()
staff = _make_user(staff=True)
api_client.force_authenticate(user=staff)
dept = Department.objects.create(name="Epsilon", head=old_head)
def test_head_can_update_department_without_explicit_role_perms(api_client: APIClient):
head = make_user("head@example.com")
d = Department.objects.create(name="Dept", description="old", head=head)
new_head = _make_user()
assert _dept_set_head(api_client, dept.id, new_head.id).status_code == 200
# руководитель аутентифицируется
api_client.force_authenticate(user=head)
url = reverse("api:v1:departments-detail", args=[d.id])
# Проверяем, что связь старого главы осталась активной (деактивируется только когда head -> None)
link = EmployeeDepartment.objects.filter(employee=old_head, department=dept).first()
assert link is not None
assert link.is_active is True
# PATCH произвольного поля (не head) должен быть разрешён руководителю
resp = api_client.patch(url, {"description": "new"}, format="json")
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["description"] == "new"
@pytest.mark.django_db
def test_head_can_set_member_role_and_non_head_cannot(api_client: APIClient):
head = make_user("head@example.com")
d = Department.objects.create(name="Dept", head=head)
worker = make_user("worker@example.com")
add_member(worker, d) # сотрудник состоит в отделе
role_worker = make_role(d, "Worker")
# руководитель назначает роль участнику
api_client.force_authenticate(user=head)
url = reverse("api:v1:departments-set-member-role", args=[d.id])
payload = {"employee_id": worker.id, "role_id": role_worker.id, "is_active": True}
resp = api_client.post(url, payload, format="json")
assert resp.status_code == status.HTTP_200_OK
data = resp.json()
assert data["employee_id"] == worker.id
assert data["role_id"] == role_worker.id
assert data["is_active"] is True
# другой пользователь без статуса руководителя — не может
stranger = make_user("stranger@example.com")
api_client.force_authenticate(user=stranger)
resp = api_client.post(url, payload, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
def test_head_can_change_head_and_loses_rights_afterwards(api_client: APIClient):
old_head = make_user("old@example.com")
d = Department.objects.create(name="Dept", head=old_head)
new_head = make_user("new@example.com")
# старый руководитель меняет руководителя на другого
api_client.force_authenticate(user=old_head)
url_set_head = reverse("api:v1:departments-set-head", args=[d.id])
print("DEBUG:", new_head.id, new_head.email_verified, new_head.is_active)
resp = api_client.post(url_set_head, {"head_id": new_head.id}, format="json")
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["head"]["id"] == new_head.id
# теперь старый руководитель больше не глава — у него не должно быть прав управления отделом
url_detail = reverse("api:v1:departments-detail", args=[d.id])
resp = api_client.patch(url_detail, {"description": "after"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
# а у нового руководителя — есть
api_client.force_authenticate(user=new_head)
resp = api_client.patch(url_detail, {"description": "ok"}, format="json")
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["description"] == "ok"

View File

@ -1,176 +1,305 @@
# backend/tests/api/v1/employees/test_department_roles.py
import pytest
from django.urls import reverse
from django.contrib.auth.models import Permission
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from employees.models import Employee, Department, DepartmentRole, EmployeeDepartment
from employees.models import (
Department,
DepartmentRole,
DepartmentPermission,
EmployeeDepartment, # <-- если у тебя EmployeeDepartmentLink, замени импорт и упоминания ниже
)
# --- helpers ---
User = get_user_model()
_seq = 1
def _unique_email(prefix="user"):
global _seq
_seq += 1
return f"{prefix}{_seq}@example.com"
def _unique_phone():
global _seq
_seq += 1
return f"+7999{_seq:07d}"
# =========================
# Helpers
# =========================
def _make_user(staff=False, superuser=False) -> Employee:
u = Employee.objects.create_user(
email=_unique_email(), password="pass", phone_number=_unique_phone(),
send_activation_email=False, first_name="T", last_name="U",
def _unique_phone() -> str:
"""
Делает уникальный 11-значный номер: 79xxxxxxxxx
Используем текущее количество пользователей как инкремент.
"""
base = 79000000000 # 79 + 9 нулей
return str(base + User.objects.count())
def make_user(email: str, staff: bool = False, verified: bool = True) -> User:
"""
Создаёт пользователя с обязательным phone_number и отключённой отправкой активационного письма.
Поле 'verified' ставим только если оно существует в модели.
"""
extra = {
"phone_number": _unique_phone(),
"is_staff": staff,
"send_activation_email": False,
}
# Добавим verified, если поле есть в модели
try:
User._meta.get_field("verified")
extra["verified"] = verified
except Exception:
pass
return User.objects.create_user(
email=email,
password="pwd12345",
**extra,
)
u.is_staff = staff
u.is_superuser = superuser
u.email_verified = True
u.is_active = True
u.save(update_fields=["is_staff", "is_superuser", "email_verified", "is_active"])
return u
def _grant_perm(user: Employee, perm_code: str):
app_label, codename = perm_code.split(".", 1)
p = Permission.objects.get(content_type__app_label=app_label, codename=codename)
user.user_permissions.add(p)
user.save()
# сброс кэша прав на всякий случай
for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, attr):
try: delattr(user, attr)
except: pass
return p
def ensure_dept_perm(code: str, name: str | None = None) -> DepartmentPermission:
return DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": name or code}
)[0]
# --- tests ---
def make_role(dept: Department, name: str, codes: list[str] | None = None) -> DepartmentRole:
role = DepartmentRole.objects.create(department=dept, name=name)
if codes:
perms = [ensure_dept_perm(c) for c in codes]
role.scoped_permissions.add(*perms)
return role
def grant_assign_in_dept(user: User, dept: Department) -> DepartmentRole:
role = make_role(dept, "assigner", ["assign_department_role"])
EmployeeDepartment.objects.update_or_create(
employee=user, department=dept,
defaults={"is_active": True, "role": role},
)
return role
# =========================
# Fixtures
# =========================
@pytest.fixture()
def api_client() -> APIClient:
return APIClient()
# =========================
# Tests
# =========================
@pytest.mark.django_db
def test_list_and_filter_requires_auth(api_client):
def test_list_filtered_by_department(api_client: APIClient):
d1 = Department.objects.create(name="Dept A")
d2 = Department.objects.create(name="Dept B")
r1 = make_role(d1, "Engineer", [])
r2 = make_role(d1, "Manager", [])
r3 = make_role(d2, "Sales", [])
user = make_user("u@example.com")
api_client.force_authenticate(user=user)
url = reverse("api:v1:department-roles-list")
# unauth -> 401/403
resp = api_client.get(url)
assert resp.status_code in (401, 403)
# auth -> 200, фильтр по department
user = _make_user()
api_client.force_authenticate(user=user)
d1 = Department.objects.create(name="Sales")
d2 = Department.objects.create(name="Support")
DepartmentRole.objects.create(department=d1, name="Manager")
DepartmentRole.objects.create(department=d2, name="Operator")
# без фильтра — все 3
resp = api_client.get(url)
assert resp.status_code == 200
assert len(resp.data) == 2
body = resp.json()
items = body.get("results", body)
ids = {it["id"] for it in items}
assert ids == {r1.id, r2.id, r3.id}
# с фильтром по d1 — только роли d1
resp = api_client.get(url, {"department": d1.id})
assert resp.status_code == 200
assert len(resp.data) == 1
assert resp.data[0]["name"] == "Manager"
body = resp.json()
items = body.get("results", body)
ids = {it["id"] for it in items}
assert ids == {r1.id, r2.id}
@pytest.mark.django_db
def test_head_can_crud_in_own_department(api_client):
head = _make_user()
api_client.force_authenticate(user=head)
dept = Department.objects.create(name="R&D", head=head)
# гарантируем активную связь головы с отделом
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
def test_create_requires_assign_department_role(api_client: APIClient):
d = Department.objects.create(name="Dept")
user = make_user("m@example.com")
api_client.force_authenticate(user=user)
url = reverse("api:v1:department-roles-list")
# create в своём отделе
resp = api_client.post(url, {"department": dept.id, "name": "Lead"}, format="json")
assert resp.status_code == status.HTTP_201_CREATED
role_id = resp.data["id"]
# update
url_detail = reverse("api:v1:department-roles-detail", args=[role_id])
resp = api_client.patch(url_detail, {"name": "Lead++"}, format="json")
assert resp.status_code == 200
# delete
resp = api_client.delete(url_detail)
assert resp.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.django_db
def test_head_cannot_crud_in_foreign_department(api_client):
head = _make_user()
api_client.force_authenticate(user=head)
own = Department.objects.create(name="Own", head=head)
foreign = Department.objects.create(name="Foreign")
EmployeeDepartment.objects.get_or_create(employee=head, department=own, defaults={"is_active": True})
# попытка создать роль в чужом отделе -> 403
url = reverse("api:v1:department-roles-list")
resp = api_client.post(url, {"department": foreign.id, "name": "X"}, format="json")
# без прав — 403
resp = api_client.post(url, {"department": d.id, "name": "Worker"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
# выдаём право assign_department_role в этом отделе
grant_assign_in_dept(user, d)
# теперь можно создать
resp = api_client.post(url, {"department": d.id, "name": "Worker"}, format="json")
assert resp.status_code == status.HTTP_201_CREATED
data = resp.json()
assert data["department"] == d.id
# back-compat поле присутствует
assert "permissions" in data and isinstance(data["permissions"], list)
@pytest.mark.django_db
def test_non_head_needs_manage_for_crud_and_assign_for_perms(api_client):
user = _make_user()
def test_update_and_destroy_scope_enforced(api_client: APIClient):
d1 = Department.objects.create(name="Dept1")
d2 = Department.objects.create(name="Dept2")
user = make_user("m@example.com")
api_client.force_authenticate(user=user)
dept = Department.objects.create(name="Ops")
# нет ни head, ни ролей — CRUD запрещён
url = reverse("api:v1:department-roles-list")
resp = api_client.post(url, {"department": dept.id, "name": "Role"}, format="json")
assert resp.status_code == 403
# даём право manage_department -> можно создавать/редактировать/удалять
_grant_perm(user, "employees.manage_department")
resp = api_client.post(url, {"department": dept.id, "name": "Role"}, format="json")
assert resp.status_code == 201
role_id = resp.data["id"]
# роль в другом отделе
role_other = make_role(d2, "Other", [])
url_detail_other = reverse("api:v1:department-roles-detail", args=[role_other.id])
url_detail = reverse("api:v1:department-roles-detail", args=[role_id])
resp = api_client.patch(url_detail, {"name": "Role2"}, format="json")
assert resp.status_code == 200
# нет прав в d2 -> 403
resp = api_client.patch(url_detail_other, {"name": "X"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
# но set_perms всё ещё нельзя: нужен assign_department_role
url_set = reverse("api:v1:department-roles-set-perms", args=[role_id])
resp = api_client.post(url_set, {"permission_ids": []}, format="json")
assert resp.status_code == 403
# выдаём права в d1 — всё ещё 403 для d2
grant_assign_in_dept(user, d1)
resp = api_client.patch(url_detail_other, {"name": "X"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
# выдаём права в d2 — теперь можно
grant_assign_in_dept(user, d2)
resp = api_client.patch(url_detail_other, {"name": "X"}, format="json")
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["name"] == "X"
# destroy с правами — 204/200
resp = api_client.delete(url_detail_other)
assert resp.status_code in (status.HTTP_204_NO_CONTENT, status.HTTP_200_OK)
# выдаём assign_department_role -> теперь можно
_grant_perm(user, "employees.assign_department_role")
# выберем 2 реальных права из employees
p1 = Permission.objects.get(content_type__app_label="employees", codename="manage_department")
p2 = Permission.objects.get(content_type__app_label="employees", codename="assign_department_role")
resp = api_client.post(url_set, {"permission_ids": [p1.id, p2.id]}, format="json")
assert resp.status_code == 200
assert set(DepartmentRole.objects.get(id=role_id).permissions.values_list("id", flat=True)) == {p1.id, p2.id}
@pytest.mark.django_db
def test_perms_listing_and_add_remove(api_client):
head = _make_user()
api_client.force_authenticate(user=head)
dept = Department.objects.create(name="QA", head=head)
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
def test_perm_choices_and_perms_and_set_perms(api_client: APIClient):
# засеем три стандартных кода
ensure_dept_perm("manage_department", "Управлять отделом")
ensure_dept_perm("change_department_head", "Назначать руководителя")
ensure_dept_perm("assign_department_role", "Назначать роли")
role = DepartmentRole.objects.create(department=dept, name="Reviewer")
p_view = Permission.objects.get(content_type__app_label="employees", codename="view_employee")
p_manage = Permission.objects.get(content_type__app_label="employees", codename="manage_department")
d = Department.objects.create(name="Dept")
user = make_user("m@example.com")
api_client.force_authenticate(user=user)
grant_assign_in_dept(user, d)
# add_perms (как head)
url_add = reverse("api:v1:department-roles-add-perms", args=[role.id])
resp = api_client.post(url_add, {"permission_ids": [p_view.id]}, format="json")
role = make_role(d, "Engineer", [])
# perm_choices
url_choices = reverse("api:v1:department-roles-perm-choices")
resp = api_client.get(url_choices)
assert resp.status_code == 200
codes = {row["code"] for row in resp.json().get("results", [])}
assert {"manage_department", "change_department_head", "assign_department_role"}.issubset(codes)
# set_perms (codes)
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
resp = api_client.post(url_set, {"permission_codes": ["manage_department", "assign_department_role"]}, format="json")
assert resp.status_code == 200
data = resp.json()
verbose = {p["code"] for p in data.get("permissions_verbose", [])}
assert verbose == {"manage_department", "assign_department_role"}
# perms (GET)
url_perms = reverse("api:v1:department-roles-perms", args=[role.id])
resp = api_client.get(url_perms)
assert resp.status_code == 200
codes = {row["codename"] for row in resp.data["results"]}
assert "employees.view_employee" in codes
codes_now = {p["code"] for p in resp.json().get("results", [])}
assert codes_now == {"manage_department", "assign_department_role"}
# remove_perms
url_rm = reverse("api:v1:department-roles-remove-perms", args=[role.id])
resp = api_client.post(url_rm, {"permission_ids": [p_view.id]}, format="json")
# set_perms (ids) — заменим на один id
dp_manage = DepartmentPermission.objects.get(code="manage_department")
resp = api_client.post(url_set, {"permission_ids": [dp_manage.id]}, format="json")
assert resp.status_code == 200
assert set(role.permissions.values_list("id", flat=True)) == set()
role.refresh_from_db()
assert set(role.scoped_permissions.values_list("code", flat=True)) == {"manage_department"}
# set_perms
# set_perms (пусто) — очистить все
resp = api_client.post(url_set, {"permission_codes": []}, format="json")
assert resp.status_code == 200
role.refresh_from_db()
assert role.scoped_permissions.count() == 0
@pytest.mark.django_db
def test_set_perms_validates_payload(api_client: APIClient):
d = Department.objects.create(name="Dept")
user = make_user("m@example.com")
api_client.force_authenticate(user=user)
grant_assign_in_dept(user, d)
role = make_role(d, "QA", [])
# несуществующий код -> 400
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
resp = api_client.post(url_set, {"permission_ids": [p_manage.id]}, format="json")
resp = api_client.post(url_set, {"permission_codes": ["no_such_code"]}, format="json")
assert resp.status_code == 400
# несуществующий id -> 400
resp = api_client.post(url_set, {"permission_ids": [999999]}, format="json")
assert resp.status_code == 400
@pytest.mark.django_db
def test_back_compat_permissions_field_contains_ids(api_client: APIClient):
d = Department.objects.create(name="Dept")
user = make_user("m@example.com")
api_client.force_authenticate(user=user)
grant_assign_in_dept(user, d)
role = make_role(d, "Dev", [])
dp_manage = ensure_dept_perm("manage_department")
dp_assign = ensure_dept_perm("assign_department_role")
role.scoped_permissions.add(dp_manage, dp_assign)
url_detail = reverse("api:v1:department-roles-detail", args=[role.id])
resp = api_client.get(url_detail)
assert resp.status_code == 200
assert set(role.permissions.values_list("id", flat=True)) == {p_manage.id}
data = resp.json()
assert set(data["permissions"]) == {dp_manage.id, dp_assign.id}
@pytest.mark.django_db
def test_create_with_scoped_permission_codes_and_update_name(api_client: APIClient):
d = Department.objects.create(name="Dept")
ensure_dept_perm("manage_department")
m = make_user("m@example.com")
api_client.force_authenticate(user=m)
grant_assign_in_dept(m, d)
url_list = reverse("api:v1:department-roles-list")
# create с codes
resp = api_client.post(
url_list,
{
"department": d.id,
"name": "Lead",
"scoped_permission_codes": ["manage_department"],
},
format="json",
)
assert resp.status_code == 201
rid = resp.json()["id"]
# update имени (PATCH)
url_detail = reverse("api:v1:department-roles-detail", args=[rid])
resp = api_client.patch(url_detail, {"name": "Lead+1"}, format="json")
assert resp.status_code == 200
assert resp.json()["name"] == "Lead+1"
@pytest.mark.django_db
def test_cross_department_access_denied_for_set_perms(api_client: APIClient):
d1 = Department.objects.create(name="Dept1")
d2 = Department.objects.create(name="Dept2")
user = make_user("m@example.com")
api_client.force_authenticate(user=user)
grant_assign_in_dept(user, d1) # права только в Dept1
role_d2 = make_role(d2, "R", [])
url_set = reverse("api:v1:department-roles-set-perms", args=[role_d2.id])
resp = api_client.post(url_set, {"permission_codes": ["manage_department"]}, format="json")
assert resp.status_code == 403

View File

@ -1,285 +1,303 @@
# backend/tests/api/v1/employees/test_department_roles_extra.py
import pytest
from django.urls import reverse
from django.contrib.auth.models import Permission
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from employees.models import (
Employee,
Department,
DepartmentRole,
EmployeeDepartment,
DepartmentPermission,
EmployeeDepartment, # если у вас EmployeeDepartmentLink, замените импорт и упоминания ниже
)
# --- helpers (локальные, чтобы тесты были самодостаточными) ---
User = get_user_model()
_seq = 1
def _unique_email(prefix="user"):
global _seq
_seq += 1
return f"{prefix}{_seq}@example.com"
def _unique_phone():
global _seq
_seq += 1
return f"+7999{_seq:07d}"
# =========================
# Helpers
# =========================
def _make_user(staff=False, superuser=False) -> Employee:
u = Employee.objects.create_user(
email=_unique_email(), password="pass",
phone_number=_unique_phone(),
send_activation_email=False, first_name="T", last_name="U",
def _unique_phone() -> str:
base = 79000000000 # 79 + 9 нулей
return str(base + User.objects.count())
def make_user(email: str, staff: bool = False, verified: bool = True) -> User:
extra = {
"phone_number": _unique_phone(),
"is_staff": staff,
"send_activation_email": False,
}
# добавим verified если поле есть
try:
User._meta.get_field("verified")
extra["verified"] = verified
except Exception:
pass
return User.objects.create_user(email=email, password="pwd12345", **extra)
def ensure_perm(code: str, name: str | None = None) -> DepartmentPermission:
return DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": name or code}
)[0]
def make_role(
dept: Department, name: str, codes: list[str] | None = None
) -> DepartmentRole:
role = DepartmentRole.objects.create(department=dept, name=name)
if codes:
perms = [ensure_perm(c) for c in codes]
role.scoped_permissions.add(*perms)
return role
def grant_assign_in_dept(user: User, dept: Department) -> DepartmentRole:
role = make_role(dept, "assigner", ["assign_department_role"])
EmployeeDepartment.objects.update_or_create(
employee=user,
department=dept,
defaults={"is_active": True, "role": role},
)
u.is_staff = staff
u.is_superuser = superuser
u.email_verified = True
u.is_active = True
u.save(update_fields=["is_staff", "is_superuser", "email_verified", "is_active"])
return u
return role
def _grant_perm(user: Employee, perm_code: str):
app_label, codename = perm_code.split(".", 1)
p = Permission.objects.get(content_type__app_label=app_label, codename=codename)
user.user_permissions.add(p)
user.save()
# сброс кэша прав — иначе has_perm может брать старый кеш
for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, attr):
try:
delattr(user, attr)
except Exception:
pass
return p
# --- tests ---
# =========================
# Fixtures
# =========================
@pytest.fixture()
def api_client() -> APIClient:
return APIClient()
# =========================
# Tests
# =========================
@pytest.mark.django_db
def test_perms_endpoint_requires_auth(api_client):
# prepare
d = Department.objects.create(name="A")
role = DepartmentRole.objects.create(department=d, name="R")
def test_unauth_cannot_list_or_get():
client = APIClient()
url_list = reverse("api:v1:department-roles-list")
assert client.get(url_list).status_code in (401, 403)
url = reverse("api:v1:department-roles-perms", args=[role.id])
# unauth -> 401/403
resp = api_client.get(url)
assert resp.status_code in (401, 403)
@pytest.mark.django_db
def test_read_only_allowed_without_assign(api_client: APIClient):
d = Department.objects.create(name="Dept")
r = make_role(d, "Worker", [])
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
# auth (любой) -> 200
user = _make_user()
api_client.force_authenticate(user=user)
# list
url_list = reverse("api:v1:department-roles-list")
resp = api_client.get(url_list, {"department": d.id})
assert resp.status_code == 200
items = resp.json().get("results", resp.json())
assert any(x["id"] == r.id for x in items)
# retrieve
url_detail = reverse("api:v1:department-roles-detail", args=[r.id])
assert api_client.get(url_detail).status_code == 200
@pytest.mark.django_db
def test_staff_override_for_write(api_client: APIClient):
d = Department.objects.create(name="Dept")
staff = make_user("s@example.com", staff=True)
api_client.force_authenticate(user=staff)
# create
url_list = reverse("api:v1:department-roles-list")
resp = api_client.post(
url_list, {"department": d.id, "name": "SRole"}, format="json"
)
assert resp.status_code == 201
rid = resp.json()["id"]
# update
url_detail = reverse("api:v1:department-roles-detail", args=[rid])
assert (
api_client.patch(url_detail, {"name": "SRole+"}, format="json").status_code
== 200
)
# set_perms
ensure_perm("manage_department")
url_set = reverse("api:v1:department-roles-set-perms", args=[rid])
assert (
api_client.post(
url_set, {"permission_codes": ["manage_department"]}, format="json"
).status_code
== 200
)
@pytest.mark.django_db
def test_ordering_stable_by_name_then_id(api_client: APIClient):
d1 = Department.objects.create(name="Dept1")
d2 = Department.objects.create(name="Dept2")
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
# одинаковые имена в РАЗНЫХ отделах — не нарушаем уникальность (department, name)
r1 = make_role(d1, "A", [])
r2 = make_role(d2, "A", [])
r3 = make_role(d1, "B", [])
url = reverse("api:v1:department-roles-list")
resp = api_client.get(url, {"ordering": "name"}) # без фильтра по department
assert resp.status_code == 200
items = resp.json().get("results", resp.json())
picked = [x["id"] for x in items if x["id"] in {r1.id, r2.id, r3.id}]
# Ожидаем: сначала две "A" по возрастанию id, затем "B"
assert len(picked) == 3
assert picked[0] in (r1.id, r2.id) and picked[1] in (r1.id, r2.id)
assert picked[0] < picked[1] # тай-брейк по id для одинаковых name
assert picked[2] == r3.id
@pytest.mark.django_db
def test_set_perms_ids_take_precedence_over_codes(api_client: APIClient):
d = Department.objects.create(name="Dept")
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
grant_assign_in_dept(u, d)
role = make_role(d, "R", [])
dp_manage = ensure_perm("manage_department")
dp_assign = ensure_perm("assign_department_role")
url = reverse("api:v1:department-roles-set-perms", args=[role.id])
# передаём одновременно ids и codes — по вьюхе приоритет у ids
resp = api_client.post(
url,
{
"permission_ids": [dp_manage.id],
"permission_codes": ["assign_department_role"],
},
format="json",
)
assert resp.status_code == 200
role.refresh_from_db()
codes = set(role.scoped_permissions.values_list("code", flat=True))
assert codes == {"manage_department"} # ids приоритетнее
@pytest.mark.django_db
def test_serializer_codes_override_scoped_permissions_on_create(api_client: APIClient):
d = Department.objects.create(name="Dept")
ensure_perm("manage_department")
ensure_perm("assign_department_role")
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
grant_assign_in_dept(u, d)
# создадим роль, передав и ids, и codes — в сериализаторе codes должны перезаписать ids
dp_manage = DepartmentPermission.objects.get(code="manage_department")
url = reverse("api:v1:department-roles-list")
resp = api_client.post(
url,
{
"department": d.id,
"name": "Lead",
"scoped_permissions": [dp_manage.id], # сначала ids
"scoped_permission_codes": [
"assign_department_role"
], # потом codes — должны победить
},
format="json",
)
assert resp.status_code == 201
rid = resp.json()["id"]
role = DepartmentRole.objects.get(id=rid)
codes = set(role.scoped_permissions.values_list("code", flat=True))
assert codes == {"assign_department_role"}
@pytest.mark.django_db
def test_perm_choices_fields_and_nonempty(api_client: APIClient):
ensure_perm("manage_department", "Управлять")
ensure_perm("change_department_head", "Глава")
ensure_perm("assign_department_role", "Назначение")
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
url = reverse("api:v1:department-roles-perm-choices")
resp = api_client.get(url)
assert resp.status_code == 200
assert isinstance(resp.data, dict)
assert "results" in resp.data
results = resp.json().get("results", [])
assert results and {"id", "code", "name"}.issubset(results[0].keys())
@pytest.mark.django_db
def test_filter_invalid_department_param(api_client):
user = _make_user()
api_client.force_authenticate(user=user)
def test_destroy_requires_assign_in_same_department(api_client: APIClient):
d1 = Department.objects.create(name="D1")
d2 = Department.objects.create(name="D2")
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
d = Department.objects.create(name="D")
DepartmentRole.objects.create(department=d, name="R1")
role = make_role(d2, "R", [])
url = reverse("api:v1:department-roles-list")
# нет прав в d2 — 403
url = reverse("api:v1:department-roles-detail", args=[role.id])
assert api_client.delete(url).status_code == 403
# нечисловой фильтр department -> просто пустой список (ошибки быть не должно)
resp = api_client.get(url, {"department": "oops"})
# права в d1 не помогают — всё ещё 403
grant_assign_in_dept(u, d1)
assert api_client.delete(url).status_code == 403
# выдаём права в d2 — можно удалять
grant_assign_in_dept(u, d2)
assert api_client.delete(url).status_code in (204, 200)
@pytest.mark.django_db
def test_set_perms_dedup_and_replace_semantics(api_client: APIClient):
d = Department.objects.create(name="Dept")
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
grant_assign_in_dept(u, d)
role = make_role(d, "R", [])
ensure_perm("manage_department")
ensure_perm("assign_department_role")
url = reverse("api:v1:department-roles-set-perms", args=[role.id])
# сначала ставим один код
resp = api_client.post(
url, {"permission_codes": ["manage_department"]}, format="json"
)
assert resp.status_code == 200
assert isinstance(resp.data, list)
# В худшем случае фильтр не сработает — тогда >=1.
# Основная проверка — отсутствие исключений.
role.refresh_from_db()
assert set(role.scoped_permissions.values_list("code", flat=True)) == {
"manage_department"
}
@pytest.mark.django_db
def test_member_role_with_manage_can_crud_in_own_department(api_client):
# пользователь не head, но имеет роль отдела с правом manage_department
user = _make_user()
api_client.force_authenticate(user=user)
dept = Department.objects.create(name="Ops")
role_mgr = DepartmentRole.objects.create(department=dept, name="Manager")
p_manage = Permission.objects.get(content_type__app_label="employees", codename="manage_department")
role_mgr.permissions.add(p_manage)
# делаем пользователя членом отдела с этой ролью
EmployeeDepartment.objects.create(employee=user, department=dept, role=role_mgr, is_active=True)
url = reverse("api:v1:department-roles-list")
# create в своём отделе — должен пройти
resp = api_client.post(url, {"department": dept.id, "name": "NewRole"}, format="json")
assert resp.status_code == 201
role_id = resp.data["id"]
# update — тоже должен пройти
url_detail = reverse("api:v1:department-roles-detail", args=[role_id])
resp = api_client.patch(url_detail, {"name": "NewRole++"}, format="json")
# потом отправим дубликаты и другой код — должно быть два уникальных, и ПОЛНАЯ замена
resp = api_client.post(
url,
{
"permission_codes": [
"manage_department",
"manage_department",
"assign_department_role",
]
},
format="json",
)
assert resp.status_code == 200
# delete — тоже должен пройти
resp = api_client.delete(url_detail)
assert resp.status_code == 204
@pytest.mark.django_db
def test_member_role_assign_can_set_perms_without_manage(api_client):
# пользователь не head, но имеет роль с правом assign_department_role — может управлять правами роли
user = _make_user()
api_client.force_authenticate(user=user)
dept = Department.objects.create(name="Ops2")
role_assigner = DepartmentRole.objects.create(department=dept, name="Assigner")
p_assign = Permission.objects.get(content_type__app_label="employees", codename="assign_department_role")
role_assigner.permissions.add(p_assign)
EmployeeDepartment.objects.create(employee=user, department=dept, role=role_assigner, is_active=True)
# создадим цель-роль, которой будем управлять правами
target_role = DepartmentRole.objects.create(department=dept, name="Target")
p_view_emp = Permission.objects.get(content_type__app_label="employees", codename="view_employee")
# set_perms должен позволяться с одним правом assign_department_role
url_set = reverse("api:v1:department-roles-set-perms", args=[target_role.id])
resp = api_client.post(url_set, {"permission_ids": [p_view_emp.id]}, format="json")
assert resp.status_code == 200
assert set(target_role.permissions.values_list("id", flat=True)) == {p_view_emp.id}
@pytest.mark.django_db
def test_global_manage_grants_crud_in_any_department(api_client):
# Глобальный пермишен employees.manage_department должен открыть CRUD в любом отделе
user = _make_user()
api_client.force_authenticate(user=user)
_grant_perm(user, "employees.manage_department")
foreign = Department.objects.create(name="Foreign-Dept")
url = reverse("api:v1:department-roles-list")
resp = api_client.post(url, {"department": foreign.id, "name": "GloballyAllowed"}, format="json")
assert resp.status_code == 201
role_id = resp.data["id"]
url_detail = reverse("api:v1:department-roles-detail", args=[role_id])
resp = api_client.patch(url_detail, {"name": "EditOK"}, format="json")
assert resp.status_code == 200
resp = api_client.delete(url_detail)
assert resp.status_code == 204
@pytest.mark.django_db
def test_role_in_other_dept_does_not_grant_cross_access(api_client):
# Роль с правом manage_department в отделе A не даёт права управлять отделом B
user = _make_user()
api_client.force_authenticate(user=user)
dept_a = Department.objects.create(name="A")
dept_b = Department.objects.create(name="B")
role_mgr_a = DepartmentRole.objects.create(department=dept_a, name="MgrA")
p_manage = Permission.objects.get(content_type__app_label="employees", codename="manage_department")
role_mgr_a.permissions.add(p_manage)
EmployeeDepartment.objects.create(employee=user, department=dept_a, role=role_mgr_a, is_active=True)
# create в отделе B -> 403
url = reverse("api:v1:department-roles-list")
resp = api_client.post(url, {"department": dept_b.id, "name": "NoAccess"}, format="json")
assert resp.status_code == 403
@pytest.mark.django_db
def test_set_perms_replaces_completely_and_dedup(api_client):
head = _make_user()
api_client.force_authenticate(user=head)
dept = Department.objects.create(name="QA", head=head)
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
role = DepartmentRole.objects.create(department=dept, name="Reviewer")
p_view = Permission.objects.get(content_type__app_label="employees", codename="view_employee")
p_manage = Permission.objects.get(content_type__app_label="employees", codename="manage_department")
# сначала добавим одно право
role.permissions.add(p_view)
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
# передаём список с дублями -> в итоге только p_manage
resp = api_client.post(url_set, {"permission_ids": [p_manage.id, p_manage.id]}, format="json")
assert resp.status_code == 200
assert set(role.permissions.values_list("id", flat=True)) == {p_manage.id}
@pytest.mark.django_db
def test_add_and_remove_idempotent(api_client):
head = _make_user()
api_client.force_authenticate(user=head)
dept = Department.objects.create(name="Prod", head=head)
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
role = DepartmentRole.objects.create(department=dept, name="Operator")
p1 = Permission.objects.get(content_type__app_label="employees", codename="view_employee")
p2 = Permission.objects.get(content_type__app_label="employees", codename="assign_department_role")
# add_perms с дублями
url_add = reverse("api:v1:department-roles-add-perms", args=[role.id])
resp = api_client.post(url_add, {"permission_ids": [p1.id, p1.id, p2.id]}, format="json")
assert resp.status_code == 200
assert set(role.permissions.values_list("id", flat=True)) == {p1.id, p2.id}
# повторный add_perms — без изменений
resp = api_client.post(url_add, {"permission_ids": [p1.id]}, format="json")
assert resp.status_code == 200
assert set(role.permissions.values_list("id", flat=True)) == {p1.id, p2.id}
# remove_perms частичный и повторный (идемпотентность)
url_rm = reverse("api:v1:department-roles-remove-perms", args=[role.id])
resp = api_client.post(url_rm, {"permission_ids": [p1.id]}, format="json")
assert resp.status_code == 200
assert set(role.permissions.values_list("id", flat=True)) == {p2.id}
# повторный remove для уже удалённого — состояние не меняется
resp = api_client.post(url_rm, {"permission_ids": [p1.id]}, format="json")
assert resp.status_code == 200
assert set(role.permissions.values_list("id", flat=True)) == {p2.id}
@pytest.mark.django_db
def test_missing_department_on_create_returns_400(api_client):
head = _make_user()
api_client.force_authenticate(user=head)
dept = Department.objects.create(name="Doc", head=head)
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
url = reverse("api:v1:department-roles-list")
# забыли передать department -> 400 (валидация сериализатора)
resp = api_client.post(url, {"name": "RoleX"}, format="json")
assert resp.status_code == 400
@pytest.mark.django_db
def test_permissions_payload_validation_errors(api_client):
head = _make_user()
api_client.force_authenticate(user=head)
dept = Department.objects.create(name="Check", head=head)
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
role = DepartmentRole.objects.create(department=dept, name="R")
# не список -> 400
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
resp = api_client.post(url_set, {"permission_ids": "oops"}, format="json")
assert resp.status_code == 400
# несуществующие id -> 400
resp = api_client.post(url_set, {"permission_ids": [999999]}, format="json")
assert resp.status_code == 400
@pytest.mark.django_db
def test_delete_nonexistent_returns_404(api_client):
head = _make_user()
api_client.force_authenticate(user=head)
dept = Department.objects.create(name="Del", head=head)
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
url_detail = reverse("api:v1:department-roles-detail", args=[999999])
resp = api_client.delete(url_detail)
assert resp.status_code == 404
role.refresh_from_db()
assert set(role.scoped_permissions.values_list("code", flat=True)) == {
"manage_department",
"assign_department_role",
}

View File

@ -46,16 +46,19 @@
# * `extract_results` — вытаскивает `results` из пагинированного ответа DRF или возвращает список как есть.
import itertools
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
EmployeeDepartment,
)
from rest_framework import status
from rest_framework.test import APIClient
from employees.models import Department, EmployeeDepartment, DepartmentRole
pytestmark = pytest.mark.django_db
User = get_user_model()
@ -98,15 +101,10 @@ def make_user(
return u
def perm_for_department(codename: str) -> Permission:
"""
Безопасно получаем/создаём Permission именно для employees.Department.
"""
ct = ContentType.objects.get_for_model(Department)
p, _ = Permission.objects.get_or_create(
content_type=ct,
codename=codename,
defaults={"name": codename},
def perm_for_department(code: str) -> DepartmentPermission:
"""Безопасно получаем/создаём скоуп-право отдела по коду."""
p, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": code}
)
return p
@ -116,7 +114,7 @@ def make_role(
) -> DepartmentRole:
r = DepartmentRole.objects.create(department=dept, name=name)
if codes:
r.permissions.add(*[perm_for_department(c) for c in codes])
r.scoped_permissions.add(*[perm_for_department(c) for c in codes])
return r
@ -177,7 +175,6 @@ def test_search_and_ordering(api_client: APIClient):
assert names == ["Alpha", "Beta", "Gamma"]
# ---------- tests: employees_count annotation ----------