Corp/backend/api/v1/employees/serializers.py
Игорь Надеин 026bc46816 feat(department-roles): complete LDAP architecture redesign with RoleAssignment model
BREAKING CHANGES:
- Removed OU=Roles from LDAP structure, role groups now created directly in department OU
- Role groups use ROLE_<Name> naming convention (similar to DEP_, POS_)

New features:
- RoleAssignment model: allows assigning roles to ANY employee, not just department members
- New API endpoints: /assign/, /revoke/, /assignments/ on DepartmentRoleViewSet
- Permission system updated: has_dept_perm() checks RoleAssignment first
- Employee profile shows external roles with 'внешняя роль' badge
- Employee list filter includes role-only employees with department_relation field
- Role assignment modal now searches all employees via AJAX

LDAP changes:
- _ensure_role_group(): creates ROLE_<Name> group in department OU
- assign_role(), revoke_role(): manage RoleAssignment + LDAP sync
- rename_role_group(), delete_role_group(): full lifecycle support
- Removed all references to OU=Roles

Migrations:
- 0035: Create RoleAssignment model
- 0036: Data migration from EmployeeDepartment.role

Tests:
- 7 new tests for role assignment (all passing)

Docs:
- 9 detailed reports on architecture, models, API, LDAP, tests
2025-12-30 10:35:34 +03:00

893 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from collections import defaultdict
import re
from typing import Any, Dict, Optional
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from employees.constants import ACTION_CHOICES
from employees.models import (Department, DepartmentPermission, DepartmentRole,
EmployeeAction, EmployeeDepartment, Position,
Skill)
from eusrr_backend.auth_backends import PHONE_FIELD as DETECTED_PHONE_FIELD
from rest_framework import serializers
from api.v1.serializers import Base64ImageField
from employees.utils import _normalize_phone
Employee = get_user_model()
class EmailSerializer(serializers.Serializer):
email = serializers.EmailField()
class EmailVerifySerializer(serializers.Serializer):
email = serializers.EmailField()
code = serializers.CharField(max_length=6)
class RegisterSerializer(serializers.Serializer):
"""Сериализатор регистрации.
Проверяет контакты, нормализует телефон до E.164 и ограничивает допустимые
значения некоторых полей.
Raises:
serializers.ValidationError: При нарушении правил валидации.
"""
first_name = serializers.CharField(max_length=150)
last_name = serializers.CharField(max_length=150)
phone_number = serializers.CharField(
max_length=100, required=False, allow_blank=True
)
# alias, если прислали "phone": положим в phone_number
phone = serializers.CharField(
max_length=100, required=False, allow_blank=True, write_only=True
)
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=True)
patronymic = serializers.CharField(required=False, allow_blank=True, default="")
gender = serializers.ChoiceField(
required=True,
choices=((1, "Мужской"), (2, "Женский")),
error_messages={
'required': 'Поле "Пол" обязательно для заполнения.',
'invalid_choice': 'Укажите пол: 1 - Мужской, 2 - Женский.'
}
)
position = serializers.IntegerField(required=False, allow_null=True)
skills = serializers.ListField(child=serializers.IntegerField(), required=False)
def validate_first_name(self, value: str) -> str:
"""Валидация имени: только буквы, пробелы и дефисы."""
value = value.strip()
if not value:
raise serializers.ValidationError(
"Имя обязательно для заполнения."
)
if re.search(r'\d', value):
raise serializers.ValidationError(
"Имя не должно содержать цифры."
)
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
raise serializers.ValidationError(
"Имя может содержать только буквы, пробелы, "
"дефисы и апострофы."
)
return value
def validate_last_name(self, value: str) -> str:
"""Валидация фамилии: только буквы, пробелы и дефисы."""
value = value.strip()
if not value:
raise serializers.ValidationError(
"Фамилия обязательна для заполнения."
)
if re.search(r'\d', value):
raise serializers.ValidationError(
"Фамилия не должна содержать цифры."
)
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
raise serializers.ValidationError(
"Фамилия может содержать только буквы, пробелы, "
"дефисы и апострофы."
)
return value
def validate_patronymic(self, value: str) -> str:
"""Валидация отчества: только буквы, пробелы и дефисы."""
value = value.strip()
if not value:
return value # Отчество опциональное
if re.search(r'\d', value):
raise serializers.ValidationError(
"Отчество не должно содержать цифры."
)
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
raise serializers.ValidationError(
"Отчество может содержать только буквы, пробелы, "
"дефисы и апострофы."
)
return value
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
"""Глобальная валидация: контакты + телефон (alias) + нормализация E.164.
Args:
attrs: Входные атрибуты.
Returns:
Отвалидированные атрибуты.
Raises:
serializers.ValidationError: Если нет контактов или телефон невалиден.
"""
# контакты: нужен хотя бы один
if not (
(attrs.get("telegram") or "").strip()
or (attrs.get("whatsapp") or "").strip()
or (attrs.get("wechat") or "").strip()
):
raise serializers.ValidationError(
{
"non_field_errors": [
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
]
}
)
# alias "phone" → phone_number (если не задан)
if not attrs.get("phone_number") and attrs.get("phone"):
attrs["phone_number"] = attrs["phone"]
# нормализация телефона
norm = _normalize_phone(attrs.get("phone_number"))
if not norm:
raise serializers.ValidationError(
{"phone_number": "Неверный номер телефона (требуется формат E.164)."}
)
attrs["phone_number"] = norm
# (опционально) проверим, что position существует — чтобы не молча проигнорировать
pos_id = attrs.get("position")
if pos_id is not None:
exists = Position.objects.filter(pk=pos_id).exists()
if not exists:
raise serializers.ValidationError(
{"position": "Указанная должность не найдена."}
)
return attrs
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 validate_avatar(self, value):
"""
Игнорируем пустые строки в поле avatar.
Они приходят из FormData без файла.
"""
if value == '':
# Пустая строка означает, что файл не выбран
return None
return value
def update(self, instance, validated_data):
skills = validated_data.pop("skills_ids", None)
# Если avatar=None (пустая строка из FormData), не обновляем его
if "avatar" in validated_data and validated_data["avatar"] is None:
validated_data.pop("avatar")
instance = super().update(instance, validated_data)
if skills is not None:
instance.skills.set(skills)
return instance
def get_departments(self, obj):
"""
Возвращает список отделов сотрудника.
Включает:
1. Отделы через EmployeeDepartment (членство)
2. Отделы через RoleAssignment (роли без членства) — помечены via_assignment=True
"""
from employees.models import RoleAssignment
# 1. Отделы через членство (EmployeeDepartment)
links = getattr(obj, "dept_links", None)
if links is None:
links = EmployeeDepartment.objects.select_related(
"department", "role"
).filter(employee=obj, is_active=True)
out = []
member_dept_ids = set()
for link in links:
dept = link.department
member_dept_ids.add(link.department_id)
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),
"via_assignment": False, # через членство
}
)
# 2. Роли через RoleAssignment (без членства в отделе)
role_assignments = RoleAssignment.objects.select_related(
"role__department"
).filter(employee=obj, is_active=True)
for ra in role_assignments:
dept = ra.role.department
# Пропускаем отделы, где сотрудник уже член
if dept.id in member_dept_ids:
continue
# Проверяем, не добавлен ли уже этот отдел через другую роль
existing = next((d for d in out if d["id"] == dept.id and d.get("via_assignment")), None)
if existing:
# Добавляем роль к существующей записи (несколько ролей в одном отделе)
existing["role_name"] = f"{existing['role_name']}, {ra.role.name}" if existing.get("role_name") else ra.role.name
else:
out.append(
{
"id": dept.id,
"name": dept.name,
"role_id": ra.role_id,
"role_name": ra.role.name,
"is_head": False,
"via_assignment": True, # через RoleAssignment (не член отдела)
}
)
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()
avatar = serializers.ImageField(read_only=True)
full_name = serializers.SerializerMethodField()
class Meta:
model = Employee
fields = (
"id",
"first_name",
"last_name",
"patronymic",
"email",
"phone_number",
"display_name",
"full_name",
"avatar",
)
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}"
def get_full_name(self, obj: Employee) -> str:
"""Полное имя для совместимости с тестами документов."""
parts = [
obj.last_name or "",
obj.first_name or "",
getattr(obj, "patronymic", "") or "",
]
return " ".join(p.strip() for p in parts if p)
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",
"head_id",
"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 = serializers.ImageField(read_only=True)
position = PositionBriefSerializer(read_only=True)
departments = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
skills = SkillSerializer(many=True, read_only=True)
# Поля для фильтра по отделу — информация о типе связи
department_relation = serializers.SerializerMethodField()
class Meta:
model = Employee
fields = (
"id",
"email",
"phone_number",
"last_name",
"first_name",
"patronymic",
"full_name",
"avatar",
"position",
"departments",
"skills",
"created_at",
"display_name",
"department_relation",
)
def get_department_relation(self, obj):
"""
Возвращает информацию о типе связи сотрудника с отфильтрованным отделом.
Поля:
- is_member: True если член отдела через EmployeeDepartment
- is_head: True если руководитель отдела
- has_role_only: True если только роль через RoleAssignment (не член)
- role_names: список названий ролей через RoleAssignment
Возвращает None если не применён фильтр по отделу.
"""
# Проверяем аннотации от фильтра по отделу
is_member = getattr(obj, "_is_dept_member", None)
is_head = getattr(obj, "_is_dept_head", None)
has_role = getattr(obj, "_has_role_assignment", None)
# Если аннотаций нет — фильтр по отделу не применён
if is_member is None:
return None
# Получаем названия ролей через RoleAssignment
role_names = []
request = self.context.get("request")
dept_id = getattr(request, "_department_filter_id", None) if request else None
if dept_id and has_role:
from employees.models import RoleAssignment
assignments = RoleAssignment.objects.filter(
employee=obj,
role__department_id=dept_id,
is_active=True
).select_related("role")
role_names = [a.role.name for a in assignments]
return {
"is_member": bool(is_member),
"is_head": bool(is_head),
"has_role_only": bool(has_role) and not bool(is_member),
"role_names": role_names,
}
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 AddMemberInput(serializers.Serializer):
"""
Входные данные для добавления сотрудника в отдел.
Роль НЕ назначается здесь (для этого есть set_member_role).
"""
employee_id = serializers.IntegerField(required=False)
employee = serializers.IntegerField(required=False)
def validate(self, attrs):
emp = (
attrs.get("employee_id")
if "employee_id" in attrs
else attrs.get("employee")
)
if emp is None:
raise serializers.ValidationError(
{"employee_id": "This field is required."}
)
attrs["employee_id"] = emp
return attrs
class RemoveMemberInput(serializers.Serializer):
"""
Входные данные для удаления (деактивации) сотрудника из отдела.
"""
employee_id = serializers.IntegerField(required=False)
employee = serializers.IntegerField(required=False)
def validate(self, attrs):
emp = attrs.get("employee_id", attrs.get("employee"))
if emp is None:
raise serializers.ValidationError(
{"employee_id": "This field is required."}
)
attrs["employee_id"] = emp
return attrs
class DepartmentBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Department
fields = ("id", "name")
class ProfilePatchSerializer(serializers.Serializer):
"""Сериализатор частичного обновления профиля с write-back в LDAP.
Разрешены только whitelisted поля. Телефон — в фактическое поле модели.
"""
first_name = serializers.CharField(required=False, allow_blank=True)
last_name = serializers.CharField(required=False, allow_blank=True)
# реальное имя телефонного поля в модели (phone_number)
_phone_field = DETECTED_PHONE_FIELD or "phone_number"
locals()[_phone_field] = serializers.CharField(required=False, allow_blank=True)
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
"""Общая валидация.
Raises:
serializers.ValidationError: Если нет ни одного поддерживаемого поля.
"""
if not attrs:
raise serializers.ValidationError("No fields to update")
return attrs