Corp/backend/api/v1/requests_app/serializers.py
Игорь Надеин 771b93feed fix(requests): Исправлена ошибка с employee_departments и добавлены тесты
- Заменено employee_departments на departments_links в notification_signals.py
- Добавлен импорт Department в serializers.py
- Создан комплексный тестовый скрипт test_recipients.py (7 тестов)
- Создан скрипт test_recipients_api.py для API тестирования
- Добавлены утилиты для создания тестовых данных

Результаты тестирования:
✓ 7/7 тестов модели прошли успешно (100%)
✓ Проверены все методы: add_recipient, is_recipient, all_recipients
✓ Проверена работа с ManyToMany полями
✓ Проверена сериализация через API
✓ Проверен флаг sent_to_all_department
2025-12-02 17:39:09 +03:00

401 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

from __future__ import annotations
from typing import Any, Mapping
from django.contrib.auth import get_user_model
from django.db import models
from employees.models import Department
from requests_app.enums import RequestStatus, RequestType
from requests_app.models import Request, RequestComment
from rest_framework import serializers
from ..employees.serializers import EmployeeBriefSerializer
from .validators import (RequestApproverNotEmployeeValidator,
RequestDatesByTypeValidator)
User = get_user_model()
class RecipientIDsField(serializers.ListField):
"""
Принимает recipient_ids в форматах:
- repeat params: ?recipient_ids=1&recipient_ids=2
- JSON string: {"recipient_ids": "[1,2,3]"}
- CSV: {"recipient_ids": "1,2,3"}
- JSON list: {"recipient_ids": [1,2,3]}
"""
child = serializers.IntegerField(min_value=1)
def to_internal_value(self, data):
"""Нормализует различные форматы в list[int]."""
if not data:
return []
# Если уже список
if isinstance(data, (list, tuple)):
return super().to_internal_value(data)
# Если строка JSON
if isinstance(data, str):
data = data.strip()
# JSON массив
if data.startswith('[') and data.endswith(']'):
import json
try:
parsed = json.loads(data)
if isinstance(parsed, list):
return super().to_internal_value(parsed)
except (json.JSONDecodeError, ValueError):
pass
# CSV
if ',' in data:
parts = [p.strip() for p in data.split(',') if p.strip()]
return super().to_internal_value(parts)
# Одно число
try:
return super().to_internal_value([int(data)])
except (ValueError, TypeError):
pass
# QueryDict.getlist() возвращает list - отдаём как есть
return super().to_internal_value(data)
class RequestReadSerializer(serializers.ModelSerializer):
"""Сериализатор чтения заявки с получателями.
Включает краткую информацию о сотрудниках и вычисляемые поля.
"""
employee = EmployeeBriefSerializer(read_only=True)
approver = EmployeeBriefSerializer(read_only=True)
display_title = serializers.CharField(read_only=True)
is_final = serializers.BooleanField(read_only=True)
# Новые поля для получателей
departments = serializers.PrimaryKeyRelatedField(
many=True, read_only=True
)
recipients = EmployeeBriefSerializer(many=True, read_only=True)
cc_users = EmployeeBriefSerializer(many=True, read_only=True)
# Вычисляемые поля
recipient_count = serializers.SerializerMethodField()
cc_count = serializers.SerializerMethodField()
is_recipient = serializers.SerializerMethodField()
class Meta:
model = Request
fields = (
"id",
"employee",
"approver",
"department", # Старое поле для обратной совместимости
"departments", # Новое поле
"type",
"title",
"date_from",
"date_to",
"comment",
"status",
"attachment",
"created_at",
"updated_at",
"decided_at",
"display_title",
"is_final",
# Новые поля
"recipients",
"cc_users",
"sent_to_all_department",
"recipient_count",
"cc_count",
"is_recipient",
)
read_only_fields = (
"employee",
"approver",
"status",
"created_at",
"updated_at",
"decided_at",
"display_title",
"is_final",
"recipients",
"cc_users",
"recipient_count",
"cc_count",
"is_recipient",
)
def get_recipient_count(self, obj):
"""Количество получателей"""
if obj.sent_to_all_department:
# Считаем всех сотрудников выбранных отделов
count = obj.departments.aggregate(
total=models.Count(
'employeedepartment',
filter=models.Q(employeedepartment__is_active=True),
distinct=True
)
)['total'] or 0
return count
return obj.recipients.count()
def get_cc_count(self, obj):
"""Количество пользователей в копии"""
return obj.cc_users.count()
def get_is_recipient(self, obj):
"""Является ли текущий пользователь получателем"""
request = self.context.get('request')
if not request or not hasattr(request, 'user'):
return False
user = request.user
if not user or not user.is_authenticated:
return False
return obj.is_recipient(user)
class RequestWriteSerializer(serializers.ModelSerializer):
"""Сериализатор записи заявки.
Обычным пользователям запрещено передавать `employee`, `status`, `approver` —
они будут проигнорированы. Админы/обладатели прав могут их указывать.
Поле `employee` умышленно НЕ делаем read_only, чтобы админ мог его задать.
Для обычных пользователей оно подставится автоматически в `create()`/`perform_create()`.
Raises:
serializers.ValidationError: При некорректных данных (кроме запрещённых полей у обычных пользователей — они вычищаются).
"""
# Важно: снять обязательность, чтобы отсутствие полей не роняло валидацию
employee = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), required=False, allow_null=True
)
status = serializers.ChoiceField(
choices=RequestStatus, required=False, allow_null=True
)
approver = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), required=False, allow_null=True
)
# Новые поля для получателей
department_ids = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Department.objects.all(),
source='departments',
required=False,
allow_empty=True
)
recipient_ids = RecipientIDsField(required=False, allow_empty=True)
cc_user_ids = RecipientIDsField(required=False, allow_empty=True)
sent_to_all_department = serializers.BooleanField(
required=False, default=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Импортируем здесь, чтобы избежать circular import
from employees.models import Department
self.fields['department_ids'].queryset = Department.objects.all()
class Meta:
model = Request
fields = (
"id",
"employee",
"approver",
"department", # Старое поле для обратной совместимости
"department_ids", # Новое поле
"type",
"title",
"date_from",
"date_to",
"comment",
"status",
"attachment",
# Новые поля
"recipient_ids",
"cc_user_ids",
"sent_to_all_department",
)
read_only_fields = ("id",)
validators = [
RequestDatesByTypeValidator(),
RequestApproverNotEmployeeValidator(),
]
def _is_power(self) -> bool:
"""Определяет, имеет ли пользователь расширенные права на создание/изменение.
Returns:
bool: True для staff или имеющих модельные права; иначе False.
"""
user = self.context["request"].user
return bool(
getattr(user, "is_staff", False)
or user.has_perm("requests_app.add_request")
or user.has_perm("requests_app.change_request")
or user.has_perm("requests_app.can_process_requests")
)
def to_internal_value(self, data: Mapping[str, Any]) -> dict[str, Any]:
"""Предвалидационная очистка входа.
Для обычного пользователя удаляет `employee`, `status`, `approver` до полевой валидации,
чтобы не получить 400 из-за, например, несуществующего PK в `employee`.
Args:
data (Mapping[str, Any]): Входные данные запроса.
Returns:
dict[str, Any]: Преобразованные данные для дальнейшей валидации.
"""
if not self._is_power():
# data может быть QueryDict — сделаем копию как обычный dict
data = dict(data)
data.pop("employee", None)
data.pop("status", None)
data.pop("approver", None)
return super().to_internal_value(data)
def validate(self, attrs):
"""Валидация получателей и отделов"""
sent_to_all = attrs.get('sent_to_all_department', False)
recipient_ids = attrs.get('recipient_ids', [])
departments = attrs.get('departments', [])
# Если sent_to_all_department=True, должны быть указаны отделы
if sent_to_all and not departments:
raise serializers.ValidationError({
'department_ids': (
'Укажите отделы для массовой рассылки'
)
})
# Если sent_to_all_department=False и нет отделов,
# должны быть получатели
if not sent_to_all and not departments and not recipient_ids:
raise serializers.ValidationError({
'recipient_ids': 'Укажите получателей или отделы'
})
# Автор не может быть в получателях
request = self.context.get('request')
if request and hasattr(request, 'user') and request.user:
employee_id = attrs.get('employee', request.user).id
if employee_id in recipient_ids:
raise serializers.ValidationError({
'recipient_ids': 'Автор не может быть получателем'
})
return attrs
def _set_recipients(self, request_obj, recipient_ids, is_cc=False):
"""Устанавливает получателей (фильтрует только активных)"""
if not recipient_ids:
if is_cc:
request_obj.cc_users.clear()
else:
request_obj.recipients.clear()
return
users = User.objects.filter(
id__in=recipient_ids,
is_active=True
)
if is_cc:
request_obj.cc_users.set(users)
else:
request_obj.recipients.set(users)
def create(self, validated_data: dict[str, Any]) -> Request:
"""Создание заявки.
Обычным пользователям проставляет текущего пользователя; статус берётся из default модели.
Args:
validated_data (dict[str, Any]): Провалидированные данные.
Returns:
Request: Созданная заявка.
Raises:
serializers.ValidationError: Если не удалось определить автора.
"""
request = self.context.get("request")
user = getattr(request, "user", None)
if not user or getattr(user, "is_anonymous", False):
raise serializers.ValidationError(
{"detail": "Требуется аутентификация для создания заявки."}
)
# Извлекаем данные получателей до создания
recipient_ids = validated_data.pop('recipient_ids', [])
cc_user_ids = validated_data.pop('cc_user_ids', [])
departments = validated_data.pop('departments', [])
if not self._is_power():
# обычный пользователь: запрещённые поля убираем/переписываем
validated_data.pop("status", None)
validated_data.pop("approver", None)
validated_data["employee"] = user
else:
# staff/менеджер: если employee не указан — ставим текущего
validated_data.setdefault("employee", user)
if not validated_data.get("employee"):
raise serializers.ValidationError(
{"employee": "Автор должен быть установлен сервером."}
)
# Создаем заявку
request_obj = super().create(validated_data)
# Устанавливаем связи ManyToMany
if departments:
request_obj.departments.set(departments)
self._set_recipients(request_obj, recipient_ids, is_cc=False)
self._set_recipients(request_obj, cc_user_ids, is_cc=True)
return request_obj
def update(self, instance, validated_data: dict[str, Any]) -> Request:
"""Обновление заявки с получателями"""
# Извлекаем данные получателей
recipient_ids = validated_data.pop('recipient_ids', None)
cc_user_ids = validated_data.pop('cc_user_ids', None)
departments = validated_data.pop('departments', None)
# Обновляем основные поля
instance = super().update(instance, validated_data)
# Обновляем связи только если переданы
if departments is not None:
instance.departments.set(departments)
if recipient_ids is not None:
self._set_recipients(instance, recipient_ids, is_cc=False)
if cc_user_ids is not None:
self._set_recipients(instance, cc_user_ids, is_cc=True)
return instance
class RequestCommentSerializer(serializers.ModelSerializer):
"""Сериализатор комментариев к заявке."""
author = EmployeeBriefSerializer(read_only=True)
class Meta:
model = RequestComment
fields = ("id", "request", "author", "text", "created_at")
read_only_fields = ("id", "author", "created_at", "request")