- 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 ✅
772 lines
24 KiB
Python
772 lines
24 KiB
Python
"""
|
||
Сериализаторы для модуля закупок.
|
||
"""
|
||
|
||
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']
|