Corp/backend/api/v1/requests_app/serializers.py
Игорь Надеин ba0aaccfd8 fix: prevent QueryDict conversion breaking form data for non-admin users
Problem: Regular users couldn't create requests while admins could
Root cause: to_internal_value() used dict(QueryDict) which converts
all values to lists, breaking validation

Solution:
- For QueryDict: use _mutable flag to modify in-place
- For regular dict: use dict() conversion as before
- Avoids ['value'] instead of 'value' conversion

This fixes 400 errors for non-admin users when creating requests
with multipart/form-data.
2025-12-08 12:41:56 +03:00

424 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)
# URL для вложенного файла (аналогично file_url в DocumentReadSerializer)
attachment_url = serializers.FileField(source="attachment", 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()
# Используем аннотированное поле из queryset
comments_count = serializers.IntegerField(read_only=True)
class Meta:
model = Request
fields = (
"id",
"employee",
"approver",
"department", # Старое поле для обратной совместимости
"departments", # Новое поле
"type",
"title",
"date_from",
"date_to",
"comment",
"status",
"attachment",
"attachment_url", # Полный URL к файлу
"created_at",
"updated_at",
"decided_at",
"display_title",
"is_final",
# Новые поля
"recipients",
"cc_users",
"sent_to_all_department",
"recipient_count",
"cc_count",
"is_recipient",
"comments_count",
)
read_only_fields = (
"employee",
"approver",
"status",
"created_at",
"updated_at",
"decided_at",
"display_title",
"is_final",
"attachment_url",
"recipients",
"cc_users",
"recipient_count",
"cc_count",
"is_recipient",
"comments_count",
)
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 из DRF
# dict(QueryDict) превращает все значения в списки!
# Используем _mutable для безопасного удаления полей
if hasattr(data, '_mutable'):
# Это QueryDict, делаем его mutable
data._mutable = True
data.pop("employee", None)
data.pop("status", None)
data.pop("approver", None)
data._mutable = False
else:
# Обычный 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")