### 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-блоки в едином стиле с «должностями», обновлены и позеленели тесты (остальные регрессии устранены).
665 lines
23 KiB
Python
665 lines
23 KiB
Python
import base64
|
||
import binascii
|
||
from collections import defaultdict
|
||
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, DeptPerm
|
||
from employees.models import (
|
||
Department,
|
||
DepartmentRole,
|
||
EmployeeAction,
|
||
EmployeeDepartment,
|
||
Position,
|
||
Skill,
|
||
DepartmentPermission,
|
||
)
|
||
|
||
from PIL import Image
|
||
from rest_framework import serializers
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
class Base64ImageField(serializers.ImageField):
|
||
"""
|
||
Двухстороннее поле:
|
||
- input: принимает строку base64 или data URI (data:image/...;base64,xxxx)
|
||
- output: возвращает data URI (data:image/...;base64,xxxx)
|
||
Совместимо с Python 3.13 (без imghdr).
|
||
"""
|
||
|
||
default_ext = "jpg"
|
||
|
||
def _detect_ext_mime(self, content: bytes) -> tuple[str, str]:
|
||
"""Определяем расширение и MIME через Pillow."""
|
||
try:
|
||
im = Image.open(BytesIO(content))
|
||
fmt = (im.format or "").upper()
|
||
ext_map = {
|
||
"JPEG": "jpg",
|
||
"JPG": "jpg",
|
||
"PNG": "png",
|
||
"WEBP": "webp",
|
||
"GIF": "gif",
|
||
"BMP": "bmp",
|
||
"TIFF": "tiff",
|
||
"ICO": "ico",
|
||
}
|
||
ext = ext_map.get(fmt, self.default_ext)
|
||
mime = Image.MIME.get(fmt, "image/jpeg")
|
||
return ext, mime
|
||
except Exception:
|
||
return self.default_ext, "image/jpeg"
|
||
|
||
def to_internal_value(self, data):
|
||
# Принимаем None/пустое
|
||
if data in (None, "", b""):
|
||
return super().to_internal_value(data)
|
||
|
||
if isinstance(data, str):
|
||
# Срезаем префикс data:image/...;base64, если есть
|
||
if data.startswith("data:image"):
|
||
try:
|
||
_, data = data.split(";base64,", 1)
|
||
except ValueError:
|
||
raise serializers.ValidationError("Некорректный формат data URI.")
|
||
try:
|
||
decoded = base64.b64decode(data)
|
||
except (TypeError, ValueError, binascii.Error):
|
||
raise serializers.ValidationError("Невалидный base64.")
|
||
|
||
ext, _ = self._detect_ext_mime(decoded)
|
||
file = ContentFile(decoded, name=f"upload.{ext}")
|
||
return super().to_internal_value(file)
|
||
|
||
return super().to_internal_value(data)
|
||
|
||
def to_representation(self, value):
|
||
if not value:
|
||
return ""
|
||
# Получаем байты
|
||
try:
|
||
file_obj = getattr(value, "file", None) or value
|
||
content = file_obj.read()
|
||
if hasattr(file_obj, "seek"):
|
||
file_obj.seek(0)
|
||
except Exception:
|
||
try:
|
||
with open(value.path, "rb") as f:
|
||
content = f.read()
|
||
except Exception:
|
||
return ""
|
||
|
||
b64 = base64.b64encode(content).decode("ascii")
|
||
_, mime = self._detect_ext_mime(content)
|
||
return f"data:{mime};base64,{b64}"
|
||
|
||
|
||
class EmailSerializer(serializers.Serializer):
|
||
email = serializers.EmailField()
|
||
|
||
|
||
class EmailVerifySerializer(serializers.Serializer):
|
||
email = serializers.EmailField()
|
||
code = serializers.CharField(max_length=6)
|
||
|
||
|
||
class RegisterSerializer(serializers.Serializer):
|
||
first_name = serializers.CharField(max_length=150)
|
||
last_name = serializers.CharField(max_length=150)
|
||
phone_number = serializers.CharField(max_length=100)
|
||
email = serializers.EmailField()
|
||
password = serializers.CharField(min_length=6)
|
||
birth_date = serializers.DateField()
|
||
|
||
telegram = serializers.CharField(required=False, allow_blank=True, default="")
|
||
whatsapp = serializers.CharField(required=False, allow_blank=True, default="")
|
||
wechat = serializers.CharField(required=False, allow_blank=True, default="")
|
||
|
||
# Доп. поля модели можно тоже принять, но они опциональны:
|
||
avatar = Base64ImageField(required=False, allow_null=True)
|
||
patronymic = serializers.CharField(required=False, allow_blank=True, default="")
|
||
gender = serializers.IntegerField(required=False, allow_null=True)
|
||
position = serializers.IntegerField(required=False, allow_null=True)
|
||
skills = serializers.ListField(child=serializers.IntegerField(), required=False)
|
||
|
||
|
||
class SkillSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = Skill
|
||
fields = ["id", "name"]
|
||
|
||
|
||
class PositionBriefSerializer(serializers.ModelSerializer):
|
||
class Meta:
|
||
model = Position
|
||
fields = ("id", "name")
|
||
|
||
|
||
class EmployeeActionSerializer(serializers.ModelSerializer):
|
||
action_display = serializers.SerializerMethodField()
|
||
date_display = serializers.SerializerMethodField()
|
||
history = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = EmployeeAction
|
||
fields = [
|
||
"id",
|
||
"employee",
|
||
"action",
|
||
"action_display",
|
||
"date",
|
||
"date_display",
|
||
"comment",
|
||
"extra",
|
||
"history", # ← добавили
|
||
]
|
||
|
||
def get_action_display(self, obj):
|
||
return obj.get_action_display()
|
||
|
||
def get_date_display(self, obj):
|
||
return obj.date.strftime("%d.%m.%Y %H:%M") if obj.date else None
|
||
|
||
def get_history(self, obj):
|
||
if not self.context.get("include_action_history", False):
|
||
return []
|
||
|
||
items = list(
|
||
obj.history.select_related("history_user").order_by(
|
||
"-history_date", "-history_id"
|
||
)
|
||
)
|
||
|
||
action_labels = dict(ACTION_CHOICES)
|
||
history_type_labels = {"+": "Создано", "~": "Изменено", "-": "Удалено"}
|
||
diff_fields = ["action", "comment", "extra"]
|
||
|
||
out = []
|
||
for i, cur in enumerate(items):
|
||
prev = items[i + 1] if i + 1 < len(items) else None
|
||
changes = {}
|
||
|
||
for name in diff_fields:
|
||
new = getattr(cur, name, None)
|
||
old = getattr(prev, name, None) if prev else None
|
||
|
||
if name == "date":
|
||
new_fmt = new.isoformat() if new else None
|
||
old_fmt = old.isoformat() if old else None
|
||
if new_fmt != old_fmt:
|
||
changes[name] = {"old": old_fmt, "new": new_fmt}
|
||
elif name == "action":
|
||
if old != new:
|
||
changes[name] = {
|
||
"old": old,
|
||
"old_display": action_labels.get(old),
|
||
"new": new,
|
||
"new_display": action_labels.get(new),
|
||
}
|
||
else:
|
||
if old != new:
|
||
changes[name] = {"old": old, "new": new}
|
||
|
||
out.append(
|
||
{
|
||
"history_id": cur.history_id,
|
||
"history_type": cur.history_type,
|
||
"history_type_display": history_type_labels.get(cur.history_type),
|
||
"history_date": cur.history_date.isoformat(),
|
||
"history_date_display": cur.history_date.strftime("%d.%m.%Y %H:%M"),
|
||
"history_user": getattr(cur.history_user, "email", None),
|
||
# текущее значение действия в этом снимке + «человеческий» перевод
|
||
"action": getattr(cur, "action", None),
|
||
"action_display": action_labels.get(getattr(cur, "action", None)),
|
||
"changes": changes,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
class EmployeeSerializer(serializers.ModelSerializer):
|
||
"""Полная версия сотрудника для собственных эндпоинтов /employees/."""
|
||
|
||
avatar = Base64ImageField(required=False, allow_null=True)
|
||
actions = EmployeeActionSerializer(many=True, read_only=True)
|
||
|
||
# навыки: читаем красиво, пишем ids
|
||
skills = SkillSerializer(many=True, read_only=True)
|
||
skills_ids = serializers.PrimaryKeyRelatedField(
|
||
queryset=Skill.objects.all(), many=True, write_only=True, required=False
|
||
)
|
||
|
||
# должность: читаем объектом, пишем через position_id
|
||
position = PositionBriefSerializer(read_only=True)
|
||
position_id = serializers.PrimaryKeyRelatedField(
|
||
source="position",
|
||
queryset=Position.objects.all(),
|
||
write_only=True,
|
||
required=False,
|
||
allow_null=True,
|
||
)
|
||
|
||
departments = serializers.SerializerMethodField()
|
||
auth = serializers.SerializerMethodField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = (
|
||
"avatar",
|
||
"actions",
|
||
"id",
|
||
"email",
|
||
"last_name",
|
||
"first_name",
|
||
"patronymic",
|
||
"departments",
|
||
"gender",
|
||
"phone_number",
|
||
"birth_date",
|
||
"position",
|
||
"position_id",
|
||
"telegram",
|
||
"whatsapp",
|
||
"wechat",
|
||
"skills",
|
||
"skills_ids",
|
||
"is_active",
|
||
"email_verified",
|
||
"created_at",
|
||
"updated_at",
|
||
"last_login",
|
||
"date_joined",
|
||
"auth",
|
||
)
|
||
read_only_fields = (
|
||
"is_active",
|
||
"email_verified",
|
||
"created_at",
|
||
"updated_at",
|
||
"last_login",
|
||
"date_joined",
|
||
"auth",
|
||
)
|
||
extra_kwargs = {
|
||
# чтобы точно не принимали/не отдавали пароль и коды
|
||
"password": {"write_only": True, "required": False},
|
||
}
|
||
|
||
def to_representation(self, instance):
|
||
data = super().to_representation(instance)
|
||
return data
|
||
|
||
def update(self, instance, validated_data):
|
||
skills = validated_data.pop("skills_ids", None)
|
||
instance = super().update(instance, validated_data)
|
||
if skills is not None:
|
||
instance.skills.set(skills)
|
||
return instance
|
||
|
||
def get_departments(self, obj):
|
||
links = getattr(obj, "dept_links", None)
|
||
if links is None:
|
||
links = EmployeeDepartment.objects.select_related(
|
||
"department", "role"
|
||
).filter(employee=obj, is_active=True)
|
||
out = []
|
||
for link in links:
|
||
dept = link.department
|
||
out.append(
|
||
{
|
||
"id": link.department_id,
|
||
"name": dept.name if link.department_id else None,
|
||
"role_id": link.role_id,
|
||
"role_name": link.role.name if link.role_id else None,
|
||
"is_head": (dept.head_id == obj.id),
|
||
}
|
||
)
|
||
return out
|
||
|
||
def get_fields(self):
|
||
fields = super().get_fields()
|
||
|
||
# actions — как и было
|
||
if self.context.get("include_actions") is not True:
|
||
view = self.context.get("view")
|
||
action = getattr(view, "action", None) if view else None
|
||
if action not in {"retrieve", "me"}:
|
||
fields.pop("actions", None)
|
||
|
||
# ⬇️ Новое: auth показываем только в /me или при include_auth=True
|
||
include_auth = self.context.get("include_auth") is True
|
||
view = self.context.get("view")
|
||
action = getattr(view, "action", None) if view else None
|
||
if not include_auth and action != "me":
|
||
fields.pop("auth", None)
|
||
|
||
return fields
|
||
|
||
def get_auth(self, obj):
|
||
"""
|
||
Возвращает только ГЛОБАЛЬНЫЕ права текущего (request.user),
|
||
уже с учётом должности (PositionRoleBackend).
|
||
"""
|
||
req = self.context.get("request")
|
||
if not req or not getattr(req.user, "is_authenticated", False):
|
||
return None
|
||
|
||
# Не светим auth другого сотрудника (кроме явного include_auth=True)
|
||
include_auth = self.context.get("include_auth") is True
|
||
if not include_auth and req.user.pk != getattr(obj, "pk", None):
|
||
return None
|
||
|
||
u = req.user
|
||
perms = sorted(u.get_all_permissions()) # уже включает Position-права
|
||
|
||
by_app = defaultdict(list)
|
||
for p in perms:
|
||
app, code = p.split(".", 1)
|
||
by_app[app].append(code)
|
||
perms_by_app = {app: sorted(codes) for app, codes in by_app.items()}
|
||
|
||
return {
|
||
"id": u.id,
|
||
"email": u.email or "",
|
||
"is_staff": u.is_staff,
|
||
"is_superuser": u.is_superuser,
|
||
"groups": list(u.groups.values_list("name", flat=True)),
|
||
"permissions": perms,
|
||
"permissions_by_app": perms_by_app,
|
||
}
|
||
|
||
|
||
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 = EmployeeBriefSerializer(read_only=True)
|
||
head_id = serializers.PrimaryKeyRelatedField(
|
||
source="head",
|
||
queryset=Employee.objects.all(),
|
||
write_only=True,
|
||
required=False,
|
||
allow_null=True,
|
||
)
|
||
employees_count = serializers.IntegerField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Department
|
||
fields = (
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"head", # nested read
|
||
"head_id", # write
|
||
"employees_count",
|
||
)
|
||
|
||
|
||
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)
|
||
position = PositionBriefSerializer(read_only=True)
|
||
departments = serializers.SerializerMethodField()
|
||
full_name = serializers.SerializerMethodField()
|
||
skills = SkillSerializer(many=True, read_only=True)
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = (
|
||
"id",
|
||
"email",
|
||
"phone_number",
|
||
"last_name",
|
||
"first_name",
|
||
"patronymic",
|
||
"full_name",
|
||
"avatar",
|
||
"position",
|
||
"departments",
|
||
"skills",
|
||
"created_at",
|
||
"display_name",
|
||
)
|
||
|
||
def get_departments(self, obj):
|
||
links = getattr(obj, "dept_links", None)
|
||
if links is None:
|
||
links = EmployeeDepartment.objects.select_related(
|
||
"department", "role"
|
||
).filter(employee=obj, is_active=True)
|
||
out = []
|
||
for link in links:
|
||
dept = link.department
|
||
out.append(
|
||
{
|
||
"id": link.department_id,
|
||
"name": dept.name if link.department_id else None,
|
||
"role_id": link.role_id,
|
||
"role_name": link.role.name if link.role_id else None,
|
||
"is_head": (dept.head_id == obj.id), # ✅ правильная проверка
|
||
}
|
||
)
|
||
return out
|
||
|
||
def get_full_name(self, obj):
|
||
fn = (obj.first_name or "").strip()
|
||
ln = (obj.last_name or "").strip()
|
||
pt = (obj.patronymic or "").strip()
|
||
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 групп, для чтения — подробная инфа
|
||
groups = serializers.PrimaryKeyRelatedField(
|
||
queryset=Group.objects.all(), many=True, required=False
|
||
)
|
||
groups_verbose = serializers.SerializerMethodField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Position
|
||
fields = ["id", "name", "description", "groups", "groups_verbose"]
|
||
|
||
def get_groups_verbose(self, obj):
|
||
return [{"id": g.id, "name": g.name} for g in obj.groups.all()]
|
||
|
||
|
||
class DepartmentRoleSerializer(serializers.ModelSerializer):
|
||
# Новая схема: скоуп-права внутри отдела
|
||
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",
|
||
# новые write-поля
|
||
"scoped_permissions",
|
||
"scoped_permission_codes",
|
||
# совместимость (read-only зеркала)
|
||
"permissions",
|
||
"permissions_verbose",
|
||
)
|
||
read_only_fields = ("id",)
|
||
|
||
# Поддержка записи 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, "code": p.code, "name": p.name}
|
||
for p in obj.scoped_permissions.order_by("code").all()
|
||
]
|
||
|
||
|
||
class GroupSerializer(serializers.ModelSerializer):
|
||
permissions = serializers.PrimaryKeyRelatedField(
|
||
queryset=Permission.objects.all(), many=True, required=False
|
||
)
|
||
permissions_verbose = serializers.SerializerMethodField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Group
|
||
fields = ["id", "name", "permissions", "permissions_verbose"]
|
||
|
||
def get_permissions_verbose(self, obj):
|
||
qs = obj.permissions.select_related("content_type").all()
|
||
return [
|
||
{
|
||
"id": p.id,
|
||
"codename": f"{p.content_type.app_label}.{p.codename}",
|
||
"name": p.name,
|
||
"app": p.content_type.app_label,
|
||
"model": p.content_type.model,
|
||
}
|
||
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()
|