Corp/backend/procurement/serializers.py
Игорь Надеин 8b8974e3ef feat: refactor equipment API permissions
- Add DeptPerm.MANAGE_EQUIPMENT scoped permission constant
- Create migration 0035 for new DepartmentPermission
- Refactor CanManageEquipment permission class with:
  * Admin/staff/superuser → full access
  * Model permissions → full access (add/change/delete_equipment)
  * Department head → own department, can choose responsible
  * Scoped permission → own department, responsible forced to head
  * Regular members → no access
- Update EquipmentListSerializer with:
  * Permission level detection (full/dept_head/scoped/None)
  * Auto-fill department and responsible based on permission level
  * Validation that responsible is in same department
- Add create_options endpoint returning:
  * allowed_departments, can_choose_department, can_choose_responsible
  * default_responsible, permission_level
- Add bulk equipment creation support with auto-generated inventory numbers
- Implement 45 comprehensive permission tests (all passing)
- Fix is_active=True in test make_user() fixture

Tests: 45/45 passing ✅
2025-12-09 16:14:30 +03:00

772 lines
24 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 rest_framework import serializers
from .models import (
Approval,
Budget,
Equipment,
EquipmentCategory,
MaintenanceRecord,
ProcurementItem,
ProcurementRequest,
Supplier,
)
class ProcurementItemSerializer(serializers.ModelSerializer):
"""Сериализатор для позиций заявки."""
total_price = serializers.DecimalField(
max_digits=12,
decimal_places=2,
read_only=True
)
class Meta:
model = ProcurementItem
fields = [
'id',
'request',
'name',
'description',
'quantity',
'unit',
'estimated_unit_price',
'total_price',
'supplier_info',
'equipment',
'created_at',
]
read_only_fields = ['id', 'created_at', 'total_price']
class ProcurementItemCreateSerializer(serializers.ModelSerializer):
"""Сериализатор для создания позиций (без request)."""
class Meta:
model = ProcurementItem
fields = [
'name',
'description',
'quantity',
'unit',
'estimated_unit_price',
'supplier_info',
]
class ApprovalSerializer(serializers.ModelSerializer):
"""Сериализатор для согласований."""
approver_name = serializers.CharField(
source='approver.get_full_name',
read_only=True
)
role_display = serializers.CharField(
source='get_role_display',
read_only=True
)
status_display = serializers.CharField(
source='get_status_display',
read_only=True
)
class Meta:
model = Approval
fields = [
'id',
'request',
'approver',
'approver_name',
'role',
'role_display',
'status',
'status_display',
'comment',
'created_at',
'updated_at',
]
read_only_fields = [
'id',
'created_at',
'updated_at',
'approver_name',
'role_display',
'status_display',
]
class ProcurementRequestListSerializer(serializers.ModelSerializer):
"""Сериализатор для списка заявок (краткий)."""
department_name = serializers.CharField(
source='department.name',
read_only=True
)
requestor_name = serializers.CharField(
source='requestor.get_full_name',
read_only=True
)
status_display = serializers.CharField(
source='get_status_display',
read_only=True
)
urgency_display = serializers.CharField(
source='get_urgency_display',
read_only=True
)
items_count = serializers.IntegerField(read_only=True)
class Meta:
model = ProcurementRequest
fields = [
'id',
'title',
'department',
'department_name',
'requestor',
'requestor_name',
'status',
'status_display',
'urgency',
'urgency_display',
'estimated_cost',
'items_count',
'created_at',
'submitted_at',
]
read_only_fields = [
'id',
'created_at',
'submitted_at',
'department_name',
'requestor_name',
'status_display',
'urgency_display',
'items_count',
]
class ProcurementRequestDetailSerializer(serializers.ModelSerializer):
"""Сериализатор для детальной информации о заявке."""
department_name = serializers.CharField(
source='department.name',
read_only=True
)
requestor_name = serializers.CharField(
source='requestor.get_full_name',
read_only=True
)
requestor_email = serializers.CharField(
source='requestor.email',
read_only=True
)
status_display = serializers.CharField(
source='get_status_display',
read_only=True
)
urgency_display = serializers.CharField(
source='get_urgency_display',
read_only=True
)
items = ProcurementItemSerializer(many=True, read_only=True)
approvals = ApprovalSerializer(many=True, read_only=True)
required_approvals = serializers.ListField(
source='get_required_approvals',
read_only=True
)
is_editable = serializers.BooleanField(read_only=True)
budget_available = serializers.SerializerMethodField()
class Meta:
model = ProcurementRequest
fields = [
'id',
'title',
'description',
'department',
'department_name',
'requestor',
'requestor_name',
'requestor_email',
'status',
'status_display',
'urgency',
'urgency_display',
'estimated_cost',
'actual_cost',
'items',
'approvals',
'required_approvals',
'is_editable',
'budget_available',
'created_at',
'updated_at',
'submitted_at',
'completed_at',
]
read_only_fields = [
'id',
'created_at',
'updated_at',
'submitted_at',
'completed_at',
'is_editable',
'budget_available',
]
def get_budget_available(self, obj):
"""Получить информацию о доступности бюджета."""
available, remaining = obj.check_budget_available()
return {
'available': available,
'remaining': float(remaining),
}
class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
"""Сериализатор для создания заявки с позициями."""
items = ProcurementItemCreateSerializer(many=True, required=False)
class Meta:
model = ProcurementRequest
fields = [
'id',
'title',
'description',
'department',
'requestor',
'urgency',
'estimated_cost',
'items',
'status',
'created_at',
]
read_only_fields = ['id', 'requestor', 'status', 'created_at']
def create(self, validated_data):
"""Создать заявку с позициями."""
items_data = validated_data.pop('items', [])
request = self.context['request']
# Создаем заявку
procurement_request = ProcurementRequest.objects.create(
requestor=request.user,
**validated_data
)
# Создаем позиции (если они переданы)
for item_data in items_data:
ProcurementItem.objects.create(
request=procurement_request,
**item_data
)
return procurement_request
class EquipmentCategorySerializer(serializers.ModelSerializer):
"""Сериализатор для категорий оборудования."""
full_path = serializers.CharField(read_only=True)
children_count = serializers.SerializerMethodField()
class Meta:
model = EquipmentCategory
fields = [
'id',
'name',
'parent',
'description',
'icon',
'full_path',
'children_count',
'created_at',
]
read_only_fields = ['id', 'created_at', 'full_path']
def get_children_count(self, obj):
"""Количество подкатегорий."""
return obj.children.count()
class EquipmentListSerializer(serializers.ModelSerializer):
"""Сериализатор для списка оборудования."""
category_name = serializers.CharField(
source='category.name',
read_only=True
)
department_name = serializers.CharField(
source='department.name',
read_only=True
)
responsible_name = serializers.CharField(
source='responsible_person.get_full_name',
read_only=True
)
status_display = serializers.CharField(
source='get_status_display',
read_only=True
)
is_under_warranty = serializers.BooleanField(read_only=True)
class Meta:
model = Equipment
fields = [
'id',
'name',
'inventory_number',
'category',
'category_name',
'status',
'status_display',
'department',
'department_name',
'responsible_person',
'responsible_name',
'purchase_date',
'purchase_cost',
'is_under_warranty',
]
read_only_fields = [
'id',
'inventory_number', # Генерируется автоматически
'category_name',
'department_name',
'responsible_name',
'status_display',
'is_under_warranty',
]
def create(self, validated_data):
"""Автоматическая генерация инвентарного номера при создании."""
import re
from datetime import datetime
year = datetime.now().year
prefix = f"INV-{year}-"
# Находим максимальный номер с текущим префиксом
last_equipment = Equipment.objects.filter(
inventory_number__startswith=prefix
).order_by('-inventory_number').first()
if last_equipment:
match = re.search(r'(\d+)$', last_equipment.inventory_number)
if match:
next_num = int(match.group(1)) + 1
else:
next_num = 1
else:
next_num = 1
validated_data['inventory_number'] = f"{prefix}{next_num:04d}"
return super().create(validated_data)
def _get_user_permission_level(self, user):
"""Определяет уровень прав пользователя.
Возвращает:
'full' - админ/модельные права (свободный выбор отдела/ответственного)
'dept_head' - начальник отдела (свой отдел, выбор ответственного)
'scoped' - скоуп-право (свой отдел, ответственный = начальник)
None - нет прав на создание
"""
from api.v1.permissions import has_dept_perm
from employees.constants import DeptPerm
from employees.models import Department, EmployeeDepartment
if not user or not user.is_authenticated:
return None
# Админ или модельные права = полный доступ
if (user.is_staff or user.is_superuser or
user.has_perm('procurement.add_equipment')):
return 'full'
# Проверяем, является ли начальником какого-то отдела
headed_depts = Department.objects.filter(head_id=user.id)
if headed_depts.exists():
return 'dept_head'
# Проверяем скоуп-право в активных отделах
user_dept_links = EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True
).select_related('department', 'role')
for link in user_dept_links:
if has_dept_perm(user, link.department_id, DeptPerm.MANAGE_EQUIPMENT):
return 'scoped'
return None
def _get_user_allowed_departments(self, user, perm_level):
"""Возвращает отделы, в которых пользователь может создать оборудование."""
from employees.models import Department, EmployeeDepartment
if perm_level == 'full':
return list(Department.objects.all())
if perm_level == 'dept_head':
return list(Department.objects.filter(head_id=user.id))
if perm_level == 'scoped':
from api.v1.permissions import has_dept_perm
from employees.constants import DeptPerm
result = []
user_dept_links = EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True
).select_related('department')
for link in user_dept_links:
if has_dept_perm(
user, link.department_id, DeptPerm.MANAGE_EQUIPMENT
):
result.append(link.department)
return result
return []
def validate(self, attrs):
"""Проверка и автозаполнение полей в зависимости от прав.
Уровни прав:
- full (админ/модельные права): свободный выбор отдела и ответственного
- dept_head (начальник): только свой отдел, выбор ответственного из отдела
- scoped (скоуп-право): только свой отдел, ответственный = начальник
"""
from employees.models import EmployeeDepartment
request = self.context.get('request')
user = request.user if request else None
# При update (есть instance) используем облегчённую валидацию
if self.instance is not None:
return self._validate_update(attrs, user)
# Создание — полная проверка прав
perm_level = self._get_user_permission_level(user)
# Проверяем отдел
department = attrs.get('department')
if perm_level == 'full':
# Полный доступ — можно указать любой отдел
pass
elif perm_level == 'dept_head':
# Начальник — только свои отделы
allowed_depts = self._get_user_allowed_departments(user, perm_level)
allowed_ids = [d.id for d in allowed_depts]
if department and department.id not in allowed_ids:
raise serializers.ValidationError({
'department': 'Вы можете создавать оборудование '
'только в своём отделе.'
})
# Если отдел не указан — берём первый
if not department and allowed_depts:
attrs['department'] = allowed_depts[0]
department = allowed_depts[0]
# Ответственный по умолчанию — сам начальник
resp = attrs.get('responsible_person')
if resp is None:
attrs['responsible_person'] = user
elif perm_level == 'scoped':
# Скоуп-право — только свой отдел, ответственный = начальник
allowed_depts = self._get_user_allowed_departments(user, perm_level)
allowed_ids = [d.id for d in allowed_depts]
if department and department.id not in allowed_ids:
raise serializers.ValidationError({
'department': 'Вы можете создавать оборудование '
'только в своём отделе.'
})
# Если отдел не указан — берём первый
if not department and allowed_depts:
attrs['department'] = allowed_depts[0]
department = allowed_depts[0]
# Ответственный ВСЕГДА = начальник отдела (без выбора)
if department and department.head:
attrs['responsible_person'] = department.head
else:
attrs['responsible_person'] = None
else:
# Нет прав — ошибка
raise serializers.ValidationError({
'non_field_errors': 'У вас нет прав на создание оборудования.'
})
# Валидация ответственного
return self._validate_responsible_in_department(attrs)
def _validate_update(self, attrs, user):
"""Валидация при обновлении (облегчённая)."""
# При update не проверяем права создания,
# только валидацию ответственного если он меняется
department = attrs.get('department', self.instance.department)
responsible = attrs.get('responsible_person')
# Если меняется ответственный — проверяем принадлежность к отделу
if responsible is not None:
attrs['department'] = department # Для валидации
attrs['responsible_person'] = responsible
return self._validate_responsible_in_department(attrs)
return attrs
def _validate_responsible_in_department(self, attrs):
"""Проверяет, что ответственный состоит в отделе оборудования."""
from employees.models import EmployeeDepartment
department = attrs.get('department')
responsible = attrs.get('responsible_person')
if department and responsible:
# Проверяем, что ответственный в этом отделе
is_in_dept = (
# Он начальник отдела
department.head_id == responsible.id or
# Или состоит в отделе
EmployeeDepartment.objects.filter(
employee_id=responsible.id,
department_id=department.id,
is_active=True
).exists()
)
if not is_in_dept:
raise serializers.ValidationError({
'responsible_person': (
'Ответственный должен состоять в том же отделе, '
'что и оборудование.'
)
})
return attrs
class EquipmentDetailSerializer(serializers.ModelSerializer):
"""Сериализатор для детальной информации об оборудовании."""
category_name = serializers.CharField(
source='category.name',
read_only=True
)
department_name = serializers.CharField(
source='department.name',
read_only=True
)
responsible_name = serializers.CharField(
source='responsible_person.get_full_name',
read_only=True
)
status_display = serializers.CharField(
source='get_status_display',
read_only=True
)
is_under_warranty = serializers.BooleanField(read_only=True)
maintenance_count = serializers.SerializerMethodField()
class Meta:
model = Equipment
fields = [
'id',
'name',
'inventory_number',
'serial_number',
'category',
'category_name',
'status',
'status_display',
'department',
'department_name',
'responsible_person',
'responsible_name',
'location',
'purchase_date',
'warranty_until',
'is_under_warranty',
'purchase_cost',
'notes',
'maintenance_count',
'created_at',
'updated_at',
]
read_only_fields = [
'id',
'created_at',
'updated_at',
'is_under_warranty',
'maintenance_count',
]
def get_maintenance_count(self, obj):
"""Количество записей обслуживания."""
return obj.maintenance_history.count()
class MaintenanceRecordSerializer(serializers.ModelSerializer):
"""Сериализатор для записей обслуживания."""
equipment_name = serializers.CharField(
source='equipment.name',
read_only=True
)
equipment_inventory = serializers.CharField(
source='equipment.inventory_number',
read_only=True
)
performed_by_name = serializers.CharField(
source='performed_by.get_full_name',
read_only=True
)
type_display = serializers.CharField(
source='get_type_display',
read_only=True
)
class Meta:
model = MaintenanceRecord
fields = [
'id',
'equipment',
'equipment_name',
'equipment_inventory',
'date',
'type',
'type_display',
'description',
'cost',
'performed_by',
'performed_by_name',
'next_maintenance_date',
'created_at',
]
read_only_fields = [
'id',
'created_at',
'equipment_name',
'equipment_inventory',
'performed_by_name',
'type_display',
]
class BudgetSerializer(serializers.ModelSerializer):
"""Сериализатор для бюджетов."""
department_name = serializers.CharField(
source='department.name',
read_only=True
)
remaining_amount = serializers.DecimalField(
max_digits=14,
decimal_places=2,
read_only=True
)
reserved_amount = serializers.DecimalField(
max_digits=14,
decimal_places=2,
read_only=True
)
available_amount = serializers.DecimalField(
max_digits=14,
decimal_places=2,
read_only=True
)
utilization_percentage = serializers.DecimalField(
max_digits=5,
decimal_places=2,
read_only=True
)
quarter_display = serializers.SerializerMethodField()
class Meta:
model = Budget
fields = [
'id',
'department',
'department_name',
'year',
'quarter',
'quarter_display',
'allocated_amount',
'spent_amount',
'remaining_amount',
'reserved_amount',
'available_amount',
'utilization_percentage',
'created_at',
'updated_at',
]
read_only_fields = [
'id',
'created_at',
'updated_at',
'remaining_amount',
'reserved_amount',
'available_amount',
'utilization_percentage',
]
def get_quarter_display(self, obj):
"""Отображение квартала."""
return f"Q{obj.quarter} {obj.year}"
class BudgetDetailSerializer(BudgetSerializer):
"""Расширенный сериализатор для my_department с pending заявками."""
pending_requests = serializers.SerializerMethodField()
class Meta(BudgetSerializer.Meta):
fields = BudgetSerializer.Meta.fields + ['pending_requests']
def get_pending_requests(self, obj):
"""Получить список pending заявок этого бюджета."""
pending = obj.department.procurement_requests.filter(
status='pending',
created_at__year=obj.year,
created_at__month__in=obj._quarter_months()
).values('id', 'title', 'estimated_cost', 'status')[:10]
return list(pending)
class SupplierSerializer(serializers.ModelSerializer):
"""Сериализатор для поставщиков."""
class Meta:
model = Supplier
fields = [
'id',
'name',
'contact_person',
'phone',
'email',
'address',
'website',
'inn',
'rating',
'is_active',
'notes',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']