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 ✅
This commit is contained in:
parent
3d1a415bee
commit
8b8974e3ef
@ -38,6 +38,7 @@ class DeptPerm:
|
||||
ADD_REQUESTCOMMENT = "add_requestcomment"
|
||||
VIEW_REQUEST = "view_request"
|
||||
CAN_PROCESS_REQUESTS = "can_process_requests"
|
||||
MANAGE_EQUIPMENT = "manage_department_equipment"
|
||||
|
||||
CHOICES = (
|
||||
(MANAGE, "Управлять отделом"),
|
||||
@ -50,4 +51,5 @@ class DeptPerm:
|
||||
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
|
||||
(VIEW_REQUEST, "Просмотр заявлений отдела"),
|
||||
(CAN_PROCESS_REQUESTS, "Рассмотрение заявлений отдела"),
|
||||
(MANAGE_EQUIPMENT, "Управлять оборудованием отдела"),
|
||||
)
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.4 on 2025-12-09 12:56
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def add_manage_equipment_permission(apps, schema_editor):
|
||||
"""Добавляет скоуп-право для управления оборудованием отдела."""
|
||||
DepartmentPermission = apps.get_model('employees', 'DepartmentPermission')
|
||||
DepartmentPermission.objects.get_or_create(
|
||||
code='manage_department_equipment',
|
||||
defaults={'name': 'Управлять оборудованием отдела'}
|
||||
)
|
||||
|
||||
|
||||
def remove_manage_equipment_permission(apps, schema_editor):
|
||||
"""Удаляет скоуп-право."""
|
||||
DepartmentPermission = apps.get_model('employees', 'DepartmentPermission')
|
||||
DepartmentPermission.objects.filter(
|
||||
code='manage_department_equipment'
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0034_alter_employee_telegram'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
add_manage_equipment_permission,
|
||||
remove_manage_equipment_permission,
|
||||
),
|
||||
]
|
||||
@ -4,7 +4,9 @@
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from employees.models import Employee
|
||||
from api.v1.permissions import has_dept_perm
|
||||
from employees.constants import DeptPerm
|
||||
from employees.models import Department, Employee, EmployeeDepartment
|
||||
|
||||
|
||||
class IsDepartmentHead(permissions.BasePermission):
|
||||
@ -129,36 +131,157 @@ class CanApproveProcurementRequest(permissions.BasePermission):
|
||||
|
||||
|
||||
class CanManageEquipment(permissions.BasePermission):
|
||||
"""Проверка права на управление оборудованием."""
|
||||
"""Проверка права на управление оборудованием.
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
Логика доступа:
|
||||
- Просмотр (SAFE_METHODS): любой аутентифицированный
|
||||
- Создание:
|
||||
- admin/staff/superuser → ✅
|
||||
- модельные права (add_equipment) → ✅
|
||||
- начальник отдела (department из request.data) → ✅
|
||||
- скоуп-право MANAGE_EQUIPMENT в отделе → ✅
|
||||
- Изменение:
|
||||
- admin/staff/superuser → ✅
|
||||
- модельные права (change_equipment) → ✅
|
||||
- начальник отдела оборудования → ✅
|
||||
- скоуп-право MANAGE_EQUIPMENT в отделе → ✅
|
||||
- ответственный за оборудование → ✅
|
||||
- Удаление:
|
||||
- admin/staff/superuser → ✅
|
||||
- модельные права (delete_equipment) → ✅
|
||||
- начальник отдела оборудования → ✅
|
||||
- НЕ уполномоченный, НЕ ответственный
|
||||
"""
|
||||
|
||||
def _is_admin(self, user) -> bool:
|
||||
"""Проверяет, является ли пользователь админом."""
|
||||
return (
|
||||
getattr(user, 'is_superuser', False) or
|
||||
getattr(user, 'is_staff', False)
|
||||
)
|
||||
|
||||
def _has_model_perm(self, user, action: str) -> bool:
|
||||
"""Проверяет модельные права на оборудование."""
|
||||
perm_map = {
|
||||
'create': 'procurement.add_equipment',
|
||||
'update': 'procurement.change_equipment',
|
||||
'partial_update': 'procurement.change_equipment',
|
||||
'destroy': 'procurement.delete_equipment',
|
||||
}
|
||||
perm = perm_map.get(action)
|
||||
return perm and user.has_perm(perm)
|
||||
|
||||
def _get_dept_id_from_request(self, request) -> int | None:
|
||||
"""Извлекает ID отдела из данных запроса."""
|
||||
dept_id = request.data.get('department')
|
||||
if dept_id is not None:
|
||||
try:
|
||||
return int(dept_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _is_dept_head(self, user, dept_id: int) -> bool:
|
||||
"""Проверяет, является ли пользователь начальником отдела."""
|
||||
return Department.objects.filter(id=dept_id, head_id=user.id).exists()
|
||||
|
||||
def _has_scoped_perm(self, user, dept_id: int) -> bool:
|
||||
"""Проверяет наличие скоуп-права MANAGE_EQUIPMENT в отделе."""
|
||||
return has_dept_perm(user, dept_id, DeptPerm.MANAGE_EQUIPMENT)
|
||||
|
||||
def _get_user_departments(self, user) -> list[int]:
|
||||
"""Возвращает список ID отделов, где пользователь состоит."""
|
||||
return list(
|
||||
EmployeeDepartment.objects.filter(
|
||||
employee_id=user.id,
|
||||
is_active=True
|
||||
).values_list('department_id', flat=True)
|
||||
)
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
"""Проверка доступа на уровне запроса."""
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Чтение доступно всем сотрудникам
|
||||
# Чтение доступно всем аутентифицированным
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Создание/изменение - только для staff или с правами
|
||||
return (
|
||||
request.user.is_staff or
|
||||
request.user.has_perm('procurement.add_equipment')
|
||||
)
|
||||
# Админы могут всё
|
||||
if self._is_admin(user):
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
action = getattr(view, 'action', None)
|
||||
|
||||
# Модельные права дают полный доступ
|
||||
if self._has_model_perm(user, action):
|
||||
return True
|
||||
|
||||
# Для создания нужен отдел из запроса
|
||||
if action == 'create':
|
||||
dept_id = self._get_dept_id_from_request(request)
|
||||
if dept_id is None:
|
||||
return False
|
||||
|
||||
# Начальник отдела может создавать
|
||||
if self._is_dept_head(user, dept_id):
|
||||
return True
|
||||
|
||||
# Проверяем скоуп-право в отделе
|
||||
if self._has_scoped_perm(user, dept_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Для update/delete проверка будет на уровне объекта
|
||||
if action in ('update', 'partial_update', 'destroy'):
|
||||
# Пропускаем на has_object_permission
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def has_object_permission(self, request, view, obj) -> bool:
|
||||
"""Проверка доступа к конкретному объекту."""
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Чтение доступно всем
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Изменение - только staff или ответственный
|
||||
return (
|
||||
request.user.is_staff or
|
||||
obj.responsible_person == request.user or
|
||||
request.user.has_perm('procurement.change_equipment')
|
||||
)
|
||||
# Админы могут всё
|
||||
if self._is_admin(user):
|
||||
return True
|
||||
|
||||
action = getattr(view, 'action', None)
|
||||
|
||||
# Модельные права
|
||||
if self._has_model_perm(user, action):
|
||||
return True
|
||||
|
||||
dept_id = obj.department_id
|
||||
|
||||
# Начальник отдела оборудования
|
||||
if self._is_dept_head(user, dept_id):
|
||||
return True
|
||||
|
||||
# Удаление — только админ, модельные права или начальник
|
||||
if action == 'destroy':
|
||||
return False
|
||||
|
||||
# Изменение: скоуп-право или ответственный
|
||||
if action in ('update', 'partial_update'):
|
||||
# Скоуп-право в отделе
|
||||
if self._has_scoped_perm(user, dept_id):
|
||||
return True
|
||||
|
||||
# Ответственный за оборудование
|
||||
if obj.responsible_person_id == user.id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CanManageBudget(permissions.BasePermission):
|
||||
|
||||
@ -336,6 +336,7 @@ class EquipmentListSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'inventory_number', # Генерируется автоматически
|
||||
'category_name',
|
||||
'department_name',
|
||||
'responsible_name',
|
||||
@ -343,6 +344,222 @@ class EquipmentListSerializer(serializers.ModelSerializer):
|
||||
'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):
|
||||
"""Сериализатор для детальной информации об оборудовании."""
|
||||
|
||||
@ -466,6 +466,49 @@ class EquipmentViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [CanManageEquipment]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Создание оборудования с поддержкой массового создания.
|
||||
|
||||
Если передан параметр quantity > 1, создаётся несколько единиц
|
||||
с автоматически сгенерированными инвентарными номерами.
|
||||
"""
|
||||
quantity = int(request.data.get('quantity', 1))
|
||||
quantity = max(1, min(quantity, 100)) # Ограничение от 1 до 100
|
||||
|
||||
if quantity == 1:
|
||||
# Стандартное создание одной единицы
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
# Массовое создание
|
||||
created_equipment = []
|
||||
errors = []
|
||||
|
||||
for i in range(quantity):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
self.perform_create(serializer)
|
||||
created_equipment.append(serializer.data)
|
||||
else:
|
||||
errors.append({'index': i, 'errors': serializer.errors})
|
||||
|
||||
if errors and not created_equipment:
|
||||
return Response(
|
||||
{
|
||||
'detail': 'Не удалось создать оборудование',
|
||||
'errors': errors
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'created_count': len(created_equipment),
|
||||
'equipment': created_equipment,
|
||||
'errors': errors if errors else None
|
||||
},
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_equipment(self, request):
|
||||
"""Оборудование, за которое отвечает текущий пользователь."""
|
||||
@ -492,6 +535,114 @@ class EquipmentViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='create-options')
|
||||
def create_options(self, request):
|
||||
"""Возвращает доступные опции для создания оборудования.
|
||||
|
||||
Определяет уровень прав пользователя и возвращает:
|
||||
- allowed_departments: список отделов для выбора
|
||||
- can_choose_department: может ли пользователь выбирать отдел
|
||||
- can_choose_responsible: может ли выбирать ответственного
|
||||
- default_responsible: ответственный по умолчанию (если нет выбора)
|
||||
"""
|
||||
from api.v1.permissions import has_dept_perm
|
||||
from employees.constants import DeptPerm
|
||||
from employees.models import EmployeeDepartment
|
||||
|
||||
user = request.user
|
||||
|
||||
# Определяем уровень прав
|
||||
if user.is_staff or user.is_superuser:
|
||||
perm_level = 'full'
|
||||
elif user.has_perm('procurement.add_equipment'):
|
||||
perm_level = 'full'
|
||||
elif Department.objects.filter(head_id=user.id).exists():
|
||||
perm_level = 'dept_head'
|
||||
else:
|
||||
# Проверяем скоуп-право
|
||||
user_dept_links = EmployeeDepartment.objects.filter(
|
||||
employee_id=user.id,
|
||||
is_active=True
|
||||
).select_related('department')
|
||||
|
||||
has_scoped = False
|
||||
for link in user_dept_links:
|
||||
if has_dept_perm(
|
||||
user, link.department_id, DeptPerm.MANAGE_EQUIPMENT
|
||||
):
|
||||
has_scoped = True
|
||||
break
|
||||
|
||||
perm_level = 'scoped' if has_scoped else None
|
||||
|
||||
# Формируем ответ в зависимости от уровня прав
|
||||
if perm_level == 'full':
|
||||
# Полный доступ — все отделы
|
||||
departments = Department.objects.all().values('id', 'name')
|
||||
return Response({
|
||||
'allowed_departments': list(departments),
|
||||
'can_choose_department': True,
|
||||
'can_choose_responsible': True,
|
||||
'default_responsible': None,
|
||||
'permission_level': 'full',
|
||||
})
|
||||
|
||||
elif perm_level == 'dept_head':
|
||||
# Начальник — только свои отделы
|
||||
departments = Department.objects.filter(
|
||||
head_id=user.id
|
||||
).values('id', 'name')
|
||||
return Response({
|
||||
'allowed_departments': list(departments),
|
||||
'can_choose_department': False,
|
||||
'can_choose_responsible': True,
|
||||
'default_responsible': {
|
||||
'id': user.id,
|
||||
'name': user.get_full_name(),
|
||||
},
|
||||
'permission_level': 'dept_head',
|
||||
})
|
||||
|
||||
elif perm_level == 'scoped':
|
||||
# Скоуп-право — отделы с правом, ответственный = начальник
|
||||
allowed_depts = []
|
||||
default_responsible = None
|
||||
|
||||
user_dept_links = EmployeeDepartment.objects.filter(
|
||||
employee_id=user.id,
|
||||
is_active=True
|
||||
).select_related('department', 'department__head')
|
||||
|
||||
for link in user_dept_links:
|
||||
if has_dept_perm(
|
||||
user, link.department_id, DeptPerm.MANAGE_EQUIPMENT
|
||||
):
|
||||
dept = link.department
|
||||
allowed_depts.append({'id': dept.id, 'name': dept.name})
|
||||
if default_responsible is None and dept.head:
|
||||
default_responsible = {
|
||||
'id': dept.head.id,
|
||||
'name': dept.head.get_full_name(),
|
||||
}
|
||||
|
||||
return Response({
|
||||
'allowed_departments': allowed_depts,
|
||||
'can_choose_department': False,
|
||||
'can_choose_responsible': False,
|
||||
'default_responsible': default_responsible,
|
||||
'permission_level': 'scoped',
|
||||
})
|
||||
|
||||
else:
|
||||
# Нет прав
|
||||
return Response({
|
||||
'allowed_departments': [],
|
||||
'can_choose_department': False,
|
||||
'can_choose_responsible': False,
|
||||
'default_responsible': None,
|
||||
'permission_level': None,
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def transfer(self, request, pk=None):
|
||||
"""Перевод оборудования в другой отдел или другому пользователю."""
|
||||
|
||||
@ -214,7 +214,9 @@ def request_create(request):
|
||||
|
||||
@login_required
|
||||
def equipment_list(request):
|
||||
"""Список оборудования."""
|
||||
"""Список оборудования с группировкой одинаковых позиций."""
|
||||
from collections import defaultdict
|
||||
|
||||
queryset = Equipment.objects.select_related(
|
||||
'category', 'department', 'responsible_person'
|
||||
)
|
||||
@ -239,14 +241,50 @@ def equipment_list(request):
|
||||
inventory_number__icontains=search
|
||||
)
|
||||
|
||||
queryset = queryset.order_by('-purchase_date')
|
||||
queryset = queryset.order_by('name', '-purchase_date')
|
||||
|
||||
# Группировка по (название, категория, отдел, статус)
|
||||
grouped_equipment = defaultdict(list)
|
||||
for item in queryset:
|
||||
key = (
|
||||
item.name,
|
||||
item.category_id,
|
||||
item.department_id,
|
||||
item.status
|
||||
)
|
||||
grouped_equipment[key].append(item)
|
||||
|
||||
# Преобразуем в список групп
|
||||
equipment_groups = []
|
||||
for key, items in grouped_equipment.items():
|
||||
# Первый элемент — представитель группы
|
||||
main_item = items[0]
|
||||
equipment_groups.append({
|
||||
'main': main_item,
|
||||
'items': items,
|
||||
'count': len(items),
|
||||
'is_group': len(items) > 1,
|
||||
})
|
||||
|
||||
# Сортировка по имени
|
||||
equipment_groups.sort(key=lambda x: x['main'].name.lower())
|
||||
|
||||
# Пагинация
|
||||
paginator = Paginator(queryset, 24)
|
||||
# Пагинация по группам
|
||||
paginator = Paginator(equipment_groups, 24)
|
||||
page_obj = paginator.get_page(request.GET.get('page'))
|
||||
|
||||
# Получаем первый активный отдел пользователя
|
||||
user_department = request.user.departments.filter(
|
||||
employeedepartment__is_active=True
|
||||
).first()
|
||||
|
||||
# Получаем руководителя отдела (если есть)
|
||||
department_head = None
|
||||
if user_department:
|
||||
department_head = user_department.head
|
||||
|
||||
context = {
|
||||
'equipment_list': page_obj,
|
||||
'equipment_groups': page_obj,
|
||||
'page_obj': page_obj,
|
||||
'filters': filters,
|
||||
'categories': EquipmentCategory.objects.all(),
|
||||
@ -255,6 +293,8 @@ def equipment_list(request):
|
||||
is_active=True
|
||||
).order_by('last_name', 'first_name'),
|
||||
'can_manage': request.user.is_staff,
|
||||
'user_department': user_department,
|
||||
'department_head': department_head,
|
||||
}
|
||||
return render(request, 'procurement/equipment_list.html', context)
|
||||
|
||||
@ -266,7 +306,7 @@ def equipment_detail(request, pk):
|
||||
Equipment.objects.select_related(
|
||||
'category', 'department', 'responsible_person'
|
||||
).prefetch_related(
|
||||
'maintenance_records', 'transfer_logs'
|
||||
'maintenance_history', 'transfer_logs'
|
||||
),
|
||||
pk=pk
|
||||
)
|
||||
|
||||
@ -261,7 +261,7 @@
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--feed-radius, 18px);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
transition: box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
@ -373,6 +373,194 @@
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
/* ===== Equipment List (Table-like) ===== */
|
||||
/* Разрешаем overflow для dropdown меню */
|
||||
#equipmentListContent {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Dropdown menu должен быть поверх всех элементов */
|
||||
.proc-equipment-actions .dropdown-menu {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.proc-equipment-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 60px 120px 140px 100px 50px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.proc-equipment-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 60px 120px 140px 100px 50px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.proc-equipment-item:hover {
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.proc-equipment-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.proc-equipment-item.has-children {
|
||||
background: color-mix(in srgb, var(--bs-primary) 3%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proc-equipment-item.has-children:hover {
|
||||
background: color-mix(in srgb, var(--bs-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
/* Кнопка раскрытия группы */
|
||||
.proc-expand-btn {
|
||||
color: var(--bs-secondary);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.proc-expand-btn[aria-expanded="true"] i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Вложенные элементы группы */
|
||||
.proc-equipment-group {
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.proc-equipment-subitem {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 60px 120px 140px 100px 50px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding-left: calc(var(--space-3) + 60px);
|
||||
border-bottom: 1px solid var(--bs-border-color-translucent);
|
||||
}
|
||||
|
||||
.proc-equipment-subitem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.proc-equipment-subitem:hover {
|
||||
background: color-mix(in srgb, var(--bs-primary) 5%, transparent);
|
||||
}
|
||||
|
||||
.proc-equipment-subitem .proc-equipment-name {
|
||||
font-weight: 500;
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Информация об оборудовании в списке */
|
||||
.proc-equipment-item .proc-equipment-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.proc-equipment-icon-sm {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bs-primary) 10%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
color: var(--bs-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proc-equipment-details {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.proc-equipment-item .proc-equipment-name {
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.proc-equipment-item .proc-equipment-name a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.proc-equipment-item .proc-equipment-name a:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.proc-equipment-item .proc-equipment-inv {
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--bs-font-monospace);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
/* Колонка количества */
|
||||
.proc-equipment-count {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.proc-equipment-category,
|
||||
.proc-equipment-dept,
|
||||
.proc-equipment-responsible {
|
||||
font-size: 0.875rem;
|
||||
color: var(--bs-body-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.proc-equipment-actions {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Responsive для списка оборудования */
|
||||
@media (max-width: 991.98px) {
|
||||
.proc-table-header {
|
||||
display: block !important;
|
||||
padding: var(--space-2) var(--space-3) !important;
|
||||
}
|
||||
|
||||
.proc-equipment-row {
|
||||
grid-template-columns: 1fr 60px 100px 50px;
|
||||
}
|
||||
|
||||
.proc-equipment-item {
|
||||
grid-template-columns: 1fr 60px 100px 50px;
|
||||
}
|
||||
|
||||
.proc-equipment-subitem {
|
||||
grid-template-columns: 1fr 60px 100px 50px;
|
||||
padding-left: calc(var(--space-3) + 40px);
|
||||
}
|
||||
|
||||
/* Скрываем категорию и отдел */
|
||||
.proc-equipment-category,
|
||||
.proc-equipment-dept {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Заголовок: скрываем категорию и отдел */
|
||||
.proc-equipment-row .proc-cell:nth-child(3),
|
||||
.proc-equipment-row .proc-cell:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Budget Card ===== */
|
||||
.proc-budget-card {
|
||||
background: var(--bs-body-bg);
|
||||
|
||||
@ -22,157 +22,231 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== СЕТКА ОБОРУДОВАНИЯ ===== #}
|
||||
<div class="proc-equipment-grid" id="equipmentGrid">
|
||||
{% if equipment_list %}
|
||||
{% for item in equipment_list %}
|
||||
<div class="proc-equipment-card" data-id="{{ item.id }}">
|
||||
<div class="proc-equipment-header">
|
||||
<div class="proc-equipment-icon">
|
||||
{% if item.category.name == 'Компьютеры' or 'ноутбук' in item.name|lower %}
|
||||
<i class="bi-laptop"></i>
|
||||
{% elif 'монитор' in item.name|lower %}
|
||||
<i class="bi-display"></i>
|
||||
{% elif 'принтер' in item.name|lower %}
|
||||
<i class="bi-printer"></i>
|
||||
{% elif 'телефон' in item.name|lower %}
|
||||
<i class="bi-phone"></i>
|
||||
{% elif 'сервер' in item.name|lower %}
|
||||
<i class="bi-hdd-rack"></i>
|
||||
{% else %}
|
||||
<i class="bi-pc-display-horizontal"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="proc-equipment-info">
|
||||
<div class="proc-equipment-name">
|
||||
<a href="{% url 'procurement_frontend:equipment_detail' item.id %}" class="text-decoration-none">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="proc-equipment-inv">{{ item.inventory_number }}</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-link text-muted" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'procurement_frontend:equipment_detail' item.id %}">
|
||||
<i class="bi-eye me-2"></i>Просмотр
|
||||
</a>
|
||||
</li>
|
||||
{% if can_manage %}
|
||||
<li>
|
||||
<a class="dropdown-item edit-equipment-btn" href="#" data-bs-toggle="modal" data-bs-target="#equipmentEditModal" data-id="{{ item.id }}">
|
||||
<i class="bi-pencil me-2"></i>Редактировать
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'api:v1:equipment-qr-code' item.id %}" target="_blank">
|
||||
<i class="bi-qr-code me-2"></i>QR-код
|
||||
</a>
|
||||
</li>
|
||||
{% if can_manage %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#transferModal" data-id="{{ item.id }}">
|
||||
<i class="bi-arrow-left-right me-2"></i>Перевести
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#maintenanceModal" data-id="{{ item.id }}">
|
||||
<i class="bi-wrench me-2"></i>Обслуживание
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proc-equipment-body">
|
||||
<div class="proc-equipment-meta">
|
||||
{% if item.category %}
|
||||
<div class="proc-equipment-meta-item">
|
||||
<i class="bi-tag"></i>
|
||||
<span>{{ item.category.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.department %}
|
||||
<div class="proc-equipment-meta-item">
|
||||
<i class="bi-building"></i>
|
||||
<span>{{ item.department.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.responsible_person %}
|
||||
<div class="proc-equipment-meta-item">
|
||||
<i class="bi-person"></i>
|
||||
<span>{{ item.responsible_person.get_full_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.location %}
|
||||
<div class="proc-equipment-meta-item">
|
||||
<i class="bi-geo-alt"></i>
|
||||
<span>{{ item.location }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proc-equipment-footer">
|
||||
{% include "procurement/_equipment_status.html" with status=item.status %}
|
||||
|
||||
<div class="text-muted small">
|
||||
{{ item.purchase_date|date:"d.m.Y" }}
|
||||
</div>
|
||||
</div>
|
||||
{# ===== СПИСОК ОБОРУДОВАНИЯ ===== #}
|
||||
<div class="card mb-4">
|
||||
<div class="proc-table-header d-none d-lg-block">
|
||||
<div class="proc-equipment-row">
|
||||
<div class="proc-cell"><h6 class="mb-0 text-secondary">Оборудование</h6></div>
|
||||
<div class="proc-cell"><h6 class="mb-0 text-secondary">Кол-во</h6></div>
|
||||
<div class="proc-cell"><h6 class="mb-0 text-secondary">Категория</h6></div>
|
||||
<div class="proc-cell"><h6 class="mb-0 text-secondary">Отдел</h6></div>
|
||||
<div class="proc-cell"><h6 class="mb-0 text-secondary">Статус</h6></div>
|
||||
<div class="proc-cell"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
</div>
|
||||
|
||||
<div id="equipmentListContent">
|
||||
{% if equipment_groups %}
|
||||
{% for group in equipment_groups %}
|
||||
{# Основная строка группы #}
|
||||
<div class="proc-equipment-item {% if group.is_group %}has-children{% endif %}" data-id="{{ group.main.id }}">
|
||||
<div class="proc-equipment-info">
|
||||
{% if group.is_group %}
|
||||
<button class="btn btn-sm btn-link p-0 me-1 proc-expand-btn" type="button" data-bs-toggle="collapse" data-bs-target="#group-{{ group.main.id }}">
|
||||
<i class="bi-chevron-right"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class="proc-equipment-icon-sm">
|
||||
{% if group.main.category.icon %}
|
||||
<i class="{{ group.main.category.icon }}"></i>
|
||||
{% else %}
|
||||
<i class="bi-box-seam"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="proc-equipment-details">
|
||||
<div class="proc-equipment-name">
|
||||
{% if group.is_group %}
|
||||
{{ group.main.name }}
|
||||
{% else %}
|
||||
<a href="{% url 'procurement_frontend:equipment_detail' group.main.id %}">{{ group.main.name }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="proc-equipment-inv">
|
||||
{% if group.is_group %}
|
||||
{{ group.count }} единиц
|
||||
{% else %}
|
||||
{{ group.main.inventory_number }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proc-equipment-count">
|
||||
<span class="badge {% if group.count > 1 %}bg-primary{% else %}bg-secondary{% endif %}">{{ group.count }}</span>
|
||||
</div>
|
||||
|
||||
<div class="proc-equipment-category">
|
||||
{% if group.main.category %}
|
||||
{{ group.main.category.name }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="proc-equipment-dept">
|
||||
{% if group.main.department %}
|
||||
{{ group.main.department.name }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="proc-equipment-status">
|
||||
{% include "procurement/_equipment_status.html" with status=group.main.status %}
|
||||
</div>
|
||||
|
||||
<div class="proc-equipment-actions">
|
||||
{% if not group.is_group %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-link text-muted p-0" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true">
|
||||
<i class="bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" style="position: absolute;">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'procurement_frontend:equipment_detail' group.main.id %}">
|
||||
<i class="bi-eye me-2"></i>Просмотр
|
||||
</a>
|
||||
</li>
|
||||
{% if can_manage %}
|
||||
<li>
|
||||
<a class="dropdown-item edit-equipment-btn" href="#" data-bs-toggle="modal" data-bs-target="#equipmentEditModal" data-id="{{ group.main.id }}">
|
||||
<i class="bi-pencil me-2"></i>Редактировать
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'procurement:equipment-qr-code' group.main.id %}" target="_blank">
|
||||
<i class="bi-qr-code me-2"></i>QR-код
|
||||
</a>
|
||||
</li>
|
||||
{% if can_manage %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#transferModal" data-id="{{ group.main.id }}">
|
||||
<i class="bi-arrow-left-right me-2"></i>Перевести
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#maintenanceModal" data-id="{{ group.main.id }}">
|
||||
<i class="bi-wrench me-2"></i>Обслуживание
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Развёрнутый список единиц группы #}
|
||||
{% if group.is_group %}
|
||||
<div class="collapse proc-equipment-group" id="group-{{ group.main.id }}">
|
||||
{% for item in group.items %}
|
||||
<div class="proc-equipment-subitem" data-id="{{ item.id }}">
|
||||
<div class="proc-equipment-info">
|
||||
<div class="proc-equipment-details">
|
||||
<div class="proc-equipment-name">
|
||||
<a href="{% url 'procurement_frontend:equipment_detail' item.id %}">{{ item.inventory_number }}</a>
|
||||
</div>
|
||||
<div class="proc-equipment-inv">
|
||||
{% if item.responsible_person %}{{ item.responsible_person.get_full_name }}{% else %}Не назначен{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proc-equipment-count"></div>
|
||||
<div class="proc-equipment-category"></div>
|
||||
<div class="proc-equipment-dept"></div>
|
||||
<div class="proc-equipment-status"></div>
|
||||
|
||||
<div class="proc-equipment-actions">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-link text-muted p-0" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true">
|
||||
<i class="bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" style="position: absolute;">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'procurement_frontend:equipment_detail' item.id %}">
|
||||
<i class="bi-eye me-2"></i>Просмотр
|
||||
</a>
|
||||
</li>
|
||||
{% if can_manage %}
|
||||
<li>
|
||||
<a class="dropdown-item edit-equipment-btn" href="#" data-bs-toggle="modal" data-bs-target="#equipmentEditModal" data-id="{{ item.id }}">
|
||||
<i class="bi-pencil me-2"></i>Редактировать
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'procurement:equipment-qr-code' item.id %}" target="_blank">
|
||||
<i class="bi-qr-code me-2"></i>QR-код
|
||||
</a>
|
||||
</li>
|
||||
{% if can_manage %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#transferModal" data-id="{{ item.id }}">
|
||||
<i class="bi-arrow-left-right me-2"></i>Перевести
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#maintenanceModal" data-id="{{ item.id }}">
|
||||
<i class="bi-wrench me-2"></i>Обслуживание
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi-inbox fs-1 d-block mb-2"></i>
|
||||
<p class="mb-0">Оборудование не найдено</p>
|
||||
<p class="mb-2">Оборудование не найдено</p>
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#equipmentCreateModal" class="btn btn-primary btn-sm">
|
||||
<i class="bi-plus-lg me-1"></i>Добавить оборудование
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Пагинация #}
|
||||
{% if page_obj %}
|
||||
<div class="card-footer">
|
||||
<nav aria-label="Страницы">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
|
||||
<i class="bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ num }}">{{ num }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
|
||||
<i class="bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Пагинация #}
|
||||
{% if page_obj %}
|
||||
<div class="d-flex justify-content-center mt-4">
|
||||
<nav aria-label="Страницы">
|
||||
<ul class="pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
|
||||
<i class="bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ num }}">{{ num }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
|
||||
<i class="bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== МОДАЛКА СОЗДАНИЯ ОБОРУДОВАНИЯ ===== #}
|
||||
<div class="modal fade" id="equipmentCreateModal" tabindex="-1" aria-hidden="true">
|
||||
@ -196,7 +270,7 @@
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Категория <span class="text-danger">*</span></label>
|
||||
<select name="category" class="form-select" required>
|
||||
<select name="category" id="eqCategory" class="form-select" required>
|
||||
<option value="">Выберите...</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat.id }}">{{ cat.name }}</option>
|
||||
@ -204,10 +278,11 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Инвентарный номер <span class="text-danger">*</span></label>
|
||||
<input type="text" name="inventory_number" class="form-control"
|
||||
placeholder="INV-2024-0001" required maxlength="50">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Количество</label>
|
||||
<input type="number" name="quantity" id="createQuantity" class="form-control"
|
||||
value="1" min="1" max="100">
|
||||
<small class="text-muted">При значении > 1 создаётся несколько единиц</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
@ -309,8 +384,9 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Инвентарный номер <span class="text-danger">*</span></label>
|
||||
<input type="text" name="inventory_number" id="editInventoryNumber" class="form-control" required maxlength="50">
|
||||
<label class="form-label">Инвентарный номер</label>
|
||||
<input type="text" name="inventory_number" id="editInventoryNumber" class="form-control" readonly>
|
||||
<small class="text-muted">Назначается автоматически при создании</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
@ -494,7 +570,7 @@
|
||||
<h6 class="mb-0">Добавить новую категорию</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="categoryCreateForm">
|
||||
<form id="categoryCreateForm" onsubmit="handleCategoryCreate(event); return false;">
|
||||
{% csrf_token %}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-8">
|
||||
@ -503,14 +579,14 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" id="catIcon"
|
||||
placeholder="Иконка (bi-*)" maxlength="50">
|
||||
placeholder="Иконка (по умолчанию: bi-tag)" maxlength="50">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<textarea class="form-control form-control-sm" id="catDescription" rows="2"
|
||||
placeholder="Описание (опционально)" maxlength="500"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-sm btn-primary">
|
||||
<button type="submit" class="btn btn-sm btn-primary" id="catSubmitBtn">
|
||||
<i class="bi-plus-lg me-1"></i>Добавить
|
||||
</button>
|
||||
</div>
|
||||
@ -550,16 +626,49 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Утилита debounce для задержки выполнения функции
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Обработка раскрытия групп оборудования
|
||||
document.querySelectorAll('.proc-expand-btn').forEach(btn => {
|
||||
const collapseEl = document.querySelector(btn.dataset.bsTarget);
|
||||
if (collapseEl) {
|
||||
collapseEl.addEventListener('show.bs.collapse', () => {
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
});
|
||||
collapseEl.addEventListener('hide.bs.collapse', () => {
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Поиск по оборудованию
|
||||
const searchInput = document.getElementById('eqSearch');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', debounce((e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.proc-equipment-card').forEach(card => {
|
||||
const name = card.querySelector('.proc-equipment-name')?.textContent.toLowerCase() || '';
|
||||
const inv = card.querySelector('.proc-equipment-inv')?.textContent.toLowerCase() || '';
|
||||
document.querySelectorAll('.proc-equipment-item').forEach(item => {
|
||||
const name = item.querySelector('.proc-equipment-name')?.textContent.toLowerCase() || '';
|
||||
const inv = item.querySelector('.proc-equipment-inv')?.textContent.toLowerCase() || '';
|
||||
const match = name.includes(query) || inv.includes(query);
|
||||
card.style.display = match ? '' : 'none';
|
||||
item.style.display = match ? '' : 'none';
|
||||
|
||||
// Скрываем связанную группу если основной элемент скрыт
|
||||
const groupId = item.dataset.id;
|
||||
const groupEl = document.getElementById('group-' + groupId);
|
||||
if (groupEl) {
|
||||
groupEl.style.display = match ? '' : 'none';
|
||||
}
|
||||
});
|
||||
}, 300));
|
||||
}
|
||||
@ -648,6 +757,79 @@
|
||||
});
|
||||
|
||||
// === СОЗДАНИЕ ОБОРУДОВАНИЯ ===
|
||||
|
||||
// Автоматическое заполнение отдела и ответственного при открытии модала
|
||||
document.getElementById('equipmentCreateModal')?.addEventListener('show.bs.modal', async () => {
|
||||
console.log('[Equipment Create] Modal opening...');
|
||||
const form = document.getElementById('equipmentCreateForm');
|
||||
if (!form) {
|
||||
console.error('[Equipment Create] Form not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Сбрасываем количество на 1
|
||||
const qtyInput = document.getElementById('createQuantity');
|
||||
if (qtyInput) qtyInput.value = '1';
|
||||
|
||||
{% if user_department %}
|
||||
// Устанавливаем отдел пользователя
|
||||
const deptSelect = form.querySelector('[name="department"]');
|
||||
if (deptSelect) {
|
||||
deptSelect.value = '{{ user_department.id }}';
|
||||
console.log('[Equipment Create] Set department to:', '{{ user_department.id }}', '({{ user_department.name }})');
|
||||
}
|
||||
{% else %}
|
||||
console.log('[Equipment Create] No user department found');
|
||||
{% endif %}
|
||||
|
||||
{% if department_head %}
|
||||
// Устанавливаем руководителя отдела как ответственного
|
||||
const respSelect = form.querySelector('[name="responsible_person"]');
|
||||
if (respSelect) {
|
||||
respSelect.value = '{{ department_head.id }}';
|
||||
console.log('[Equipment Create] Set responsible to:', '{{ department_head.id }}', '({{ department_head.get_full_name }})');
|
||||
}
|
||||
{% else %}
|
||||
console.log('[Equipment Create] No department head found');
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
// Обработчик смены отдела - автоматически меняем ответственного на руководителя отдела
|
||||
const createDeptSelect = document.querySelector('#equipmentCreateForm [name="department"]');
|
||||
if (createDeptSelect) {
|
||||
createDeptSelect.addEventListener('change', async (e) => {
|
||||
const departmentId = e.target.value;
|
||||
console.log('[Equipment Create] Department changed to:', departmentId);
|
||||
|
||||
if (!departmentId) return;
|
||||
|
||||
const respSelect = document.querySelector('#equipmentCreateForm [name="responsible_person"]');
|
||||
if (!respSelect) return;
|
||||
|
||||
try {
|
||||
// Получаем информацию об отделе
|
||||
const resp = await fetch(`/api/v1/departments/${departmentId}/`);
|
||||
if (resp.ok) {
|
||||
const dept = await resp.json();
|
||||
console.log('[Equipment Create] Department data:', dept);
|
||||
|
||||
if (dept.head) {
|
||||
// API может возвращать либо ID, либо объект с информацией о руководителе
|
||||
const headId = typeof dept.head === 'object' ? dept.head.id : dept.head;
|
||||
respSelect.value = headId;
|
||||
console.log('[Equipment Create] Set responsible to department head:', headId);
|
||||
} else {
|
||||
// Если в отделе нет руководителя, очищаем поле ответственного
|
||||
respSelect.value = '';
|
||||
console.log('[Equipment Create] Department has no head, clearing responsible');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Equipment Create] Error fetching department:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('equipmentCreateForm')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
@ -655,11 +837,18 @@
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
// Убираем CSRF токен из данных, он будет в заголовке
|
||||
const csrfToken = data.csrfmiddlewaretoken;
|
||||
delete data.csrfmiddlewaretoken;
|
||||
|
||||
// Убираем пустые значения
|
||||
Object.keys(data).forEach(key => {
|
||||
if (data[key] === '') delete data[key];
|
||||
});
|
||||
|
||||
const quantity = parseInt(data.quantity) || 1;
|
||||
console.log('[Equipment Create] Submitting data:', data, 'Quantity:', quantity);
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Создание...';
|
||||
|
||||
@ -668,7 +857,7 @@
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
@ -677,15 +866,33 @@
|
||||
const result = await resp.json();
|
||||
bootstrap.Modal.getInstance(document.getElementById('equipmentCreateModal')).hide();
|
||||
form.reset();
|
||||
// Перезагрузить страницу или добавить в список
|
||||
window.location.href = `/procurement/equipment/${result.id}/`;
|
||||
|
||||
// Если создано несколько единиц (массовое создание)
|
||||
if (result.created_count && result.created_count > 1) {
|
||||
alert(`Успешно создано ${result.created_count} единиц оборудования!`);
|
||||
window.location.reload();
|
||||
} else {
|
||||
// Одна единица - переходим к ней
|
||||
const equipmentId = result.id || (result.equipment && result.equipment[0]?.id);
|
||||
if (equipmentId) {
|
||||
window.location.href = `/procurement/equipment/${equipmentId}/`;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
let errorMsg = 'Ошибка создания';
|
||||
if (err.inventory_number) {
|
||||
errorMsg = 'Инвентарный номер уже существует';
|
||||
} else if (err.detail) {
|
||||
errorMsg = err.detail;
|
||||
let errorMsg = 'Ошибка создания:\n';
|
||||
if (typeof err === 'object') {
|
||||
Object.entries(err).forEach(([field, messages]) => {
|
||||
if (Array.isArray(messages)) {
|
||||
errorMsg += `${field}: ${messages.join(', ')}\n`;
|
||||
} else {
|
||||
errorMsg += `${field}: ${messages}\n`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
errorMsg = err.detail || JSON.stringify(err);
|
||||
}
|
||||
alert(errorMsg);
|
||||
}
|
||||
@ -787,17 +994,65 @@
|
||||
|
||||
// === УПРАВЛЕНИЕ КАТЕГОРИЯМИ ===
|
||||
// Загрузка списка категорий
|
||||
async function loadCategories() {
|
||||
const list = document.getElementById('categoriesList');
|
||||
list.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>';
|
||||
|
||||
// Обновление всех выпадающих списков категорий на странице
|
||||
async function updateCategorySelects() {
|
||||
console.log('[updateCategorySelects] Starting...');
|
||||
try {
|
||||
const resp = await fetch('/api/procurement/equipment-categories/');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
const categories = Array.isArray(data) ? data : (data.results || []);
|
||||
console.log('[updateCategorySelects] Loaded categories:', categories.length);
|
||||
|
||||
// Найти все селекты категорий на странице
|
||||
const selects = document.querySelectorAll('#eqCategory, #editCategory');
|
||||
console.log('[updateCategorySelects] Found selects:', selects.length);
|
||||
|
||||
selects.forEach((select, index) => {
|
||||
console.log(`[updateCategorySelects] Updating select ${index + 1}:`, select.id);
|
||||
const currentValue = select.value;
|
||||
|
||||
// Очистить и заново заполнить
|
||||
select.innerHTML = '<option value="">-- Выберите категорию --</option>';
|
||||
categories.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat.id;
|
||||
option.textContent = cat.name;
|
||||
if (cat.id == currentValue) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
});
|
||||
console.log('[updateCategorySelects] Successfully updated all selects');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[updateCategorySelects] Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
console.log('[loadCategories] Starting...');
|
||||
const list = document.getElementById('categoriesList');
|
||||
if (!list) {
|
||||
console.error('[loadCategories] Element categoriesList not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div></div>';
|
||||
|
||||
try {
|
||||
console.log('[loadCategories] Fetching from API...');
|
||||
const resp = await fetch('/api/procurement/equipment-categories/');
|
||||
console.log('[loadCategories] Response status:', resp.status);
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
console.log('[loadCategories] Data received:', data);
|
||||
|
||||
// API может возвращать либо массив, либо объект с results
|
||||
const categories = Array.isArray(data) ? data : (data.results || []);
|
||||
console.log('[loadCategories] Categories count:', categories.length);
|
||||
|
||||
if (categories.length === 0) {
|
||||
list.innerHTML = '<div class="text-center text-muted py-3">Категорий пока нет</div>';
|
||||
@ -808,69 +1063,106 @@
|
||||
categories.forEach(cat => {
|
||||
html += `
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="flex-grow-1" style="cursor: pointer;" onclick="editCategory(${cat.id}, '${cat.name.replace(/'/g, "\\'")}', '${cat.icon || 'bi-tag'}', '${(cat.description || '').replace(/'/g, "\\'")}')">
|
||||
<i class="${cat.icon || 'bi-tag'}"></i>
|
||||
<strong class="ms-2">${cat.name}</strong>
|
||||
${cat.description ? `<br><small class="text-muted ms-4">${cat.description}</small>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteCategory(${cat.id}, '${cat.name}')">
|
||||
<i class="bi-trash"></i>
|
||||
</button>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="editCategory(${cat.id}, '${cat.name.replace(/'/g, "\\'")}', '${cat.icon || 'bi-tag'}', '${(cat.description || '').replace(/'/g, "\\'")}')">
|
||||
<i class="bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" onclick="deleteCategory(${cat.id}, '${cat.name.replace(/'/g, "\\'")}')">
|
||||
<i class="bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
list.innerHTML = html;
|
||||
console.log('[loadCategories] Categories rendered successfully');
|
||||
} else {
|
||||
console.error('[loadCategories] Response not OK:', resp.status);
|
||||
list.innerHTML = '<div class="alert alert-danger m-2">Ошибка загрузки</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error('[loadCategories] Error:', e);
|
||||
list.innerHTML = '<div class="alert alert-danger m-2">Ошибка сети</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка при открытии модала
|
||||
document.getElementById('categoryManageModal')?.addEventListener('show.bs.modal', loadCategories);
|
||||
console.log('[Init] Setting up modal event listener...');
|
||||
const categoryModal = document.getElementById('categoryManageModal');
|
||||
console.log('[Init] Modal element:', categoryModal);
|
||||
|
||||
// Создание новой категории
|
||||
document.getElementById('categoryCreateForm')?.addEventListener('submit', async (e) => {
|
||||
if (categoryModal) {
|
||||
categoryModal.addEventListener('show.bs.modal', () => {
|
||||
console.log('[Modal Event] show.bs.modal fired!');
|
||||
loadCategories();
|
||||
});
|
||||
console.log('[Init] Event listener attached successfully');
|
||||
} else {
|
||||
console.error('[Init] Modal element not found!');
|
||||
}
|
||||
|
||||
// Обработчик создания категории (вызывается из onsubmit формы)
|
||||
async function handleCategoryCreate(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const name = document.getElementById('catName').value.trim();
|
||||
const icon = document.getElementById('catIcon').value.trim();
|
||||
const description = document.getElementById('catDescription').value.trim();
|
||||
|
||||
if (!name) return;
|
||||
if (!name) return false;
|
||||
|
||||
const submitBtn = e.target.querySelector('[type="submit"]');
|
||||
const submitBtn = document.getElementById('catSubmitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Создание...';
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
|
||||
// Если иконка не указана, используем стандартную
|
||||
const payload = {
|
||||
name,
|
||||
icon: icon || 'bi-tag',
|
||||
description: description || ''
|
||||
};
|
||||
|
||||
console.log('Creating category:', payload);
|
||||
console.log('CSRF Token:', csrfToken);
|
||||
|
||||
const resp = await fetch('/api/procurement/equipment-categories/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('[name="csrfmiddlewaretoken"]').value
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ name, icon: icon || null, description: description || '' })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
console.log('Response status:', resp.status);
|
||||
|
||||
if (resp.ok) {
|
||||
// Очистить форму
|
||||
document.getElementById('catName').value = '';
|
||||
document.getElementById('catIcon').value = '';
|
||||
document.getElementById('catDescription').value = '';
|
||||
|
||||
// Перезагрузить список
|
||||
// Перезагрузить список в модале
|
||||
await loadCategories();
|
||||
|
||||
// Обновить выпадающие списки категорий в формах
|
||||
location.reload();
|
||||
// Обновить все выпадающие списки на странице
|
||||
await updateCategorySelects();
|
||||
|
||||
// Показать сообщение об успехе
|
||||
alert('Категория успешно создана и добавлена в формы!');
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert(err.name?.[0] || err.detail || 'Ошибка создания категории');
|
||||
console.error('Server error:', err);
|
||||
alert(err.name?.[0] || err.detail || JSON.stringify(err));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@ -879,7 +1171,120 @@
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="bi-plus-lg me-1"></i>Добавить';
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Редактирование категории
|
||||
function editCategory(id, name, icon, description) {
|
||||
// Заполняем форму данными категории
|
||||
document.getElementById('catName').value = name;
|
||||
document.getElementById('catIcon').value = icon;
|
||||
document.getElementById('catDescription').value = description;
|
||||
|
||||
// Меняем заголовок и кнопку
|
||||
const form = document.getElementById('categoryCreateForm');
|
||||
const submitBtn = document.getElementById('catSubmitBtn');
|
||||
const cardHeader = form.closest('.card').querySelector('.card-header h6');
|
||||
|
||||
cardHeader.textContent = 'Редактировать категорию';
|
||||
submitBtn.innerHTML = '<i class="bi-check-lg me-1"></i>Сохранить';
|
||||
|
||||
// Меняем обработчик формы на обновление
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const newName = document.getElementById('catName').value.trim();
|
||||
const newIcon = document.getElementById('catIcon').value.trim();
|
||||
const newDescription = document.getElementById('catDescription').value.trim();
|
||||
|
||||
if (!newName) return false;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Обновление...';
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
|
||||
const payload = {
|
||||
name: newName,
|
||||
icon: newIcon || 'bi-tag',
|
||||
description: newDescription || ''
|
||||
};
|
||||
|
||||
const resp = await fetch(`/api/procurement/equipment-categories/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
// Сбросить форму
|
||||
cancelEdit();
|
||||
|
||||
// Перезагрузить список в модале
|
||||
await loadCategories();
|
||||
|
||||
// Обновить все выпадающие списки на странице
|
||||
await updateCategorySelects();
|
||||
|
||||
alert('Категория успешно обновлена!');
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
console.error('Server error:', err);
|
||||
alert(err.name?.[0] || err.detail || JSON.stringify(err));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Ошибка сети');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="bi-check-lg me-1"></i>Сохранить';
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Добавляем кнопку отмены если её ещё нет
|
||||
if (!document.getElementById('catCancelBtn')) {
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.id = 'catCancelBtn';
|
||||
cancelBtn.className = 'btn btn-sm btn-secondary ms-2';
|
||||
cancelBtn.innerHTML = '<i class="bi-x-lg me-1"></i>Отмена';
|
||||
cancelBtn.onclick = cancelEdit;
|
||||
submitBtn.parentNode.appendChild(cancelBtn);
|
||||
}
|
||||
}
|
||||
|
||||
// Отмена редактирования
|
||||
function cancelEdit() {
|
||||
const form = document.getElementById('categoryCreateForm');
|
||||
const submitBtn = document.getElementById('catSubmitBtn');
|
||||
const cardHeader = form.closest('.card').querySelector('.card-header h6');
|
||||
const cancelBtn = document.getElementById('catCancelBtn');
|
||||
|
||||
// Очистить форму
|
||||
document.getElementById('catName').value = '';
|
||||
document.getElementById('catIcon').value = '';
|
||||
document.getElementById('catDescription').value = '';
|
||||
|
||||
// Вернуть заголовок и кнопку
|
||||
cardHeader.textContent = 'Добавить новую категорию';
|
||||
submitBtn.innerHTML = '<i class="bi-plus-lg me-1"></i>Добавить';
|
||||
|
||||
// Удалить кнопку отмены
|
||||
if (cancelBtn) {
|
||||
cancelBtn.remove();
|
||||
}
|
||||
|
||||
// Вернуть оригинальный обработчик
|
||||
form.onsubmit = (e) => handleCategoryCreate(e);
|
||||
}
|
||||
|
||||
|
||||
// Удаление категории
|
||||
async function deleteCategory(id, name) {
|
||||
@ -897,6 +1302,7 @@
|
||||
|
||||
if (resp.ok || resp.status === 204) {
|
||||
await loadCategories();
|
||||
await updateCategorySelects(); // Обновить выпадающие списки
|
||||
alert('Категория удалена');
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
|
||||
913
backend/tests/procurement/test_equipment_permissions.py
Normal file
913
backend/tests/procurement/test_equipment_permissions.py
Normal file
@ -0,0 +1,913 @@
|
||||
"""
|
||||
Тесты прав доступа для API оборудования.
|
||||
|
||||
Сценарии:
|
||||
1. Создание оборудования:
|
||||
- Админ/staff → может выбирать любой отдел и ответственного
|
||||
- Модельные права (add_equipment) → может выбирать любой отдел и ответственного
|
||||
- Начальник отдела → только свой отдел, ответственный по умолчанию он сам (может выбрать из отдела)
|
||||
- Уполномоченный (скоуп-право) → только свой отдел, ответственный = начальник (без выбора)
|
||||
- Обычный сотрудник → нет доступа
|
||||
|
||||
2. Изменение/удаление:
|
||||
- Админ/staff → любое оборудование
|
||||
- Модельные права → любое оборудование
|
||||
- Начальник отдела → только своего отдела
|
||||
- Уполномоченный → только своего отдела
|
||||
- Ответственный → только изменение (не удаление)
|
||||
|
||||
3. Валидация:
|
||||
- Ответственный ДОЛЖЕН состоять в отделе оборудования
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentPermission,
|
||||
DepartmentRole,
|
||||
EmployeeDepartment,
|
||||
)
|
||||
from employees.constants import DeptPerm
|
||||
from procurement.models import Equipment, EquipmentCategory
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Код права для управления оборудованием в отделе
|
||||
MANAGE_EQUIPMENT_CODE = DeptPerm.MANAGE_EQUIPMENT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
_phone_counter = 0
|
||||
|
||||
|
||||
def unique_phone() -> str:
|
||||
"""Генерирует уникальный телефон."""
|
||||
global _phone_counter
|
||||
_phone_counter += 1
|
||||
return f"+7999{_phone_counter:07d}"
|
||||
|
||||
|
||||
def make_user(
|
||||
email: str,
|
||||
staff: bool = False,
|
||||
superuser: bool = False,
|
||||
verified: bool = True,
|
||||
) -> User:
|
||||
"""Создаёт пользователя."""
|
||||
return User.objects.create_user(
|
||||
email=email,
|
||||
password="testpass123",
|
||||
phone_number=unique_phone(),
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
is_staff=staff,
|
||||
is_superuser=superuser,
|
||||
is_active=True,
|
||||
email_verified=verified,
|
||||
send_activation_email=False,
|
||||
)
|
||||
|
||||
|
||||
def make_department(name: str, head: User = None) -> Department:
|
||||
"""Создаёт отдел."""
|
||||
return Department.objects.create(name=name, head=head)
|
||||
|
||||
|
||||
def ensure_dept_perm(code: str, name: str = None) -> DepartmentPermission:
|
||||
"""Получает или создаёт скоуп-право отдела."""
|
||||
perm, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=code,
|
||||
defaults={"name": name or code}
|
||||
)
|
||||
return perm
|
||||
|
||||
|
||||
def make_role(
|
||||
dept: Department,
|
||||
name: str,
|
||||
codes: list[str] = None
|
||||
) -> DepartmentRole:
|
||||
"""Создаёт роль в отделе с указанными скоуп-правами."""
|
||||
role = DepartmentRole.objects.create(department=dept, name=name)
|
||||
if codes:
|
||||
perms = [ensure_dept_perm(c) for c in codes]
|
||||
role.scoped_permissions.add(*perms)
|
||||
return role
|
||||
|
||||
|
||||
def add_user_to_dept(
|
||||
user: User,
|
||||
dept: Department,
|
||||
role: DepartmentRole = None,
|
||||
is_active: bool = True
|
||||
) -> EmployeeDepartment:
|
||||
"""Добавляет пользователя в отдел с ролью."""
|
||||
link, _ = EmployeeDepartment.objects.update_or_create(
|
||||
employee=user,
|
||||
department=dept,
|
||||
defaults={"is_active": is_active, "role": role}
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
def grant_model_permission(user: User, perm_codename: str, model_class) -> None:
|
||||
"""Выдаёт модельное право пользователю."""
|
||||
ct = ContentType.objects.get_for_model(model_class)
|
||||
# Используем get() чтобы получить существующий permission
|
||||
# Permissions создаются Django при миграциях
|
||||
perm = Permission.objects.get(content_type=ct, codename=perm_codename)
|
||||
user.user_permissions.add(perm)
|
||||
# Очищаем кэш прав пользователя
|
||||
if hasattr(user, '_perm_cache'):
|
||||
del user._perm_cache
|
||||
if hasattr(user, '_user_perm_cache'):
|
||||
del user._user_perm_cache
|
||||
|
||||
|
||||
def make_category(name: str = "Компьютеры") -> EquipmentCategory:
|
||||
"""Создаёт категорию оборудования."""
|
||||
cat, _ = EquipmentCategory.objects.get_or_create(
|
||||
name=name,
|
||||
defaults={"icon": "bi-laptop"}
|
||||
)
|
||||
return cat
|
||||
|
||||
|
||||
def make_equipment(
|
||||
name: str,
|
||||
dept: Department,
|
||||
category: EquipmentCategory,
|
||||
responsible: User = None,
|
||||
inventory_number: str = None
|
||||
) -> Equipment:
|
||||
"""Создаёт оборудование."""
|
||||
if not inventory_number:
|
||||
count = Equipment.objects.count()
|
||||
inventory_number = f"INV-2025-{count + 1:04d}"
|
||||
|
||||
return Equipment.objects.create(
|
||||
name=name,
|
||||
inventory_number=inventory_number,
|
||||
category=category,
|
||||
department=dept,
|
||||
responsible_person=responsible,
|
||||
purchase_date=date.today(),
|
||||
purchase_cost=Decimal("10000.00"),
|
||||
)
|
||||
|
||||
|
||||
def equipment_create_url() -> str:
|
||||
"""URL для создания оборудования."""
|
||||
return reverse("procurement:equipment-list")
|
||||
|
||||
|
||||
def equipment_detail_url(pk: int) -> str:
|
||||
"""URL для конкретного оборудования."""
|
||||
return reverse("procurement:equipment-detail", args=[pk])
|
||||
|
||||
|
||||
def valid_equipment_data(
|
||||
category: EquipmentCategory,
|
||||
dept: Department,
|
||||
responsible: User = None,
|
||||
name: str = "Ноутбук Dell"
|
||||
) -> dict:
|
||||
"""Валидные данные для создания оборудования."""
|
||||
data = {
|
||||
"name": name,
|
||||
"category": category.id,
|
||||
"department": dept.id,
|
||||
"purchase_date": date.today().isoformat(),
|
||||
"purchase_cost": "50000.00",
|
||||
}
|
||||
if responsible:
|
||||
data["responsible_person"] = responsible.id
|
||||
return data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def api_client() -> APIClient:
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def category(db) -> EquipmentCategory:
|
||||
return make_category()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db) -> User:
|
||||
"""Администратор (staff)."""
|
||||
return make_user("admin@example.com", staff=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def superuser(db) -> User:
|
||||
"""Суперпользователь."""
|
||||
return make_user("super@example.com", superuser=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_perm_user(db) -> User:
|
||||
"""Пользователь с модельными правами на оборудование."""
|
||||
user = make_user("modelperm@example.com")
|
||||
grant_model_permission(user, "add_equipment", Equipment)
|
||||
grant_model_permission(user, "change_equipment", Equipment)
|
||||
grant_model_permission(user, "delete_equipment", Equipment)
|
||||
# Перезагружаем из БД для обновления кэша прав
|
||||
user = User.objects.get(pk=user.pk)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dept_it(db) -> Department:
|
||||
"""IT отдел (без начальника изначально)."""
|
||||
return make_department("IT отдел")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dept_hr(db) -> Department:
|
||||
"""HR отдел."""
|
||||
return make_department("HR отдел")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dept_head(db, dept_it) -> User:
|
||||
"""Начальник IT отдела."""
|
||||
user = make_user("head@example.com")
|
||||
dept_it.head = user
|
||||
dept_it.save()
|
||||
add_user_to_dept(user, dept_it)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dept_member(db, dept_it) -> User:
|
||||
"""Обычный сотрудник IT отдела (без особых прав)."""
|
||||
user = make_user("member@example.com")
|
||||
add_user_to_dept(user, dept_it)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authorized_member(db, dept_it) -> User:
|
||||
"""Уполномоченный сотрудник IT отдела (со скоуп-правом)."""
|
||||
user = make_user("authorized@example.com")
|
||||
role = make_role(dept_it, "Ответственный за оборудование", [MANAGE_EQUIPMENT_CODE])
|
||||
add_user_to_dept(user, dept_it, role=role)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_dept_head(db, dept_hr) -> User:
|
||||
"""Начальник HR отдела."""
|
||||
user = make_user("hr_head@example.com")
|
||||
dept_hr.head = user
|
||||
dept_hr.save()
|
||||
add_user_to_dept(user, dept_hr)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def random_user(db) -> User:
|
||||
"""Случайный пользователь без отдела."""
|
||||
return make_user("random@example.com")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: Создание оборудования - кто может
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentCreateAccess:
|
||||
"""Тесты: кто может создавать оборудование."""
|
||||
|
||||
def test_unauthenticated_cannot_create(self, api_client, category, dept_it):
|
||||
"""Неаутентифицированный пользователь не может создавать."""
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_admin_can_create(self, api_client, admin_user, category, dept_it):
|
||||
"""Админ может создавать оборудование."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_superuser_can_create(self, api_client, superuser, category, dept_it):
|
||||
"""Суперпользователь может создавать оборудование."""
|
||||
api_client.force_authenticate(user=superuser)
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_model_perm_user_can_create(
|
||||
self, api_client, model_perm_user, category, dept_it
|
||||
):
|
||||
"""Пользователь с модельными правами может создавать."""
|
||||
api_client.force_authenticate(user=model_perm_user)
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_dept_head_can_create_in_own_dept(
|
||||
self, api_client, dept_head, category, dept_it
|
||||
):
|
||||
"""Начальник отдела может создавать в своём отделе."""
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_dept_head_cannot_create_in_other_dept(
|
||||
self, api_client, dept_head, category, dept_hr
|
||||
):
|
||||
"""Начальник отдела НЕ может создавать в чужом отделе."""
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
data = valid_equipment_data(category, dept_hr)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_authorized_member_can_create_in_own_dept(
|
||||
self, api_client, authorized_member, category, dept_it
|
||||
):
|
||||
"""Уполномоченный сотрудник может создавать в своём отделе."""
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_authorized_member_cannot_create_in_other_dept(
|
||||
self, api_client, authorized_member, category, dept_hr
|
||||
):
|
||||
"""Уполномоченный НЕ может создавать в чужом отделе."""
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
data = valid_equipment_data(category, dept_hr)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_regular_member_cannot_create(
|
||||
self, api_client, dept_member, category, dept_it
|
||||
):
|
||||
"""Обычный сотрудник БЕЗ прав не может создавать."""
|
||||
api_client.force_authenticate(user=dept_member)
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_random_user_cannot_create(
|
||||
self, api_client, random_user, category, dept_it
|
||||
):
|
||||
"""Пользователь без отдела не может создавать."""
|
||||
api_client.force_authenticate(user=random_user)
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: Создание - выбор отдела
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentCreateDepartmentChoice:
|
||||
"""Тесты: кто может выбирать отдел при создании."""
|
||||
|
||||
def test_admin_can_choose_any_department(
|
||||
self, api_client, admin_user, category, dept_it, dept_hr
|
||||
):
|
||||
"""Админ может указать любой отдел."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
# Создаём в IT отделе
|
||||
data1 = valid_equipment_data(category, dept_it, name="Оборудование IT")
|
||||
response1 = api_client.post(equipment_create_url(), data1, format="json")
|
||||
assert response1.status_code == status.HTTP_201_CREATED
|
||||
assert response1.data["department"] == dept_it.id
|
||||
|
||||
# Создаём в HR отделе
|
||||
data2 = valid_equipment_data(category, dept_hr, name="Оборудование HR")
|
||||
response2 = api_client.post(equipment_create_url(), data2, format="json")
|
||||
assert response2.status_code == status.HTTP_201_CREATED
|
||||
assert response2.data["department"] == dept_hr.id
|
||||
|
||||
def test_model_perm_user_can_choose_any_department(
|
||||
self, api_client, model_perm_user, category, dept_it, dept_hr
|
||||
):
|
||||
"""Пользователь с модельными правами может указать любой отдел."""
|
||||
api_client.force_authenticate(user=model_perm_user)
|
||||
|
||||
data = valid_equipment_data(category, dept_hr)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["department"] == dept_hr.id
|
||||
|
||||
def test_dept_head_department_is_forced_to_own(
|
||||
self, api_client, dept_head, category, dept_it, dept_hr
|
||||
):
|
||||
"""Начальник отдела: попытка указать чужой отдел → запрет или замена на свой."""
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
|
||||
# Пытаемся создать в HR отделе, но мы начальник IT
|
||||
data = valid_equipment_data(category, dept_hr)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
# Должно быть либо 403, либо автозамена на свой отдел
|
||||
# Выбираем вариант с 403
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_authorized_member_department_is_forced(
|
||||
self, api_client, authorized_member, category, dept_it, dept_hr
|
||||
):
|
||||
"""Уполномоченный: отдел автоматически = его отдел, попытка указать другой → ошибка."""
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
|
||||
data = valid_equipment_data(category, dept_hr)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: Создание - выбор ответственного
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentCreateResponsibleChoice:
|
||||
"""Тесты: кто может выбирать ответственного при создании."""
|
||||
|
||||
def test_admin_can_choose_any_responsible_in_dept(
|
||||
self, api_client, admin_user, category, dept_it, dept_member
|
||||
):
|
||||
"""Админ может указать любого сотрудника из отдела как ответственного."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
data = valid_equipment_data(category, dept_it, responsible=dept_member)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["responsible_person"] == dept_member.id
|
||||
|
||||
def test_dept_head_can_choose_responsible_from_own_dept(
|
||||
self, api_client, dept_head, category, dept_it, dept_member
|
||||
):
|
||||
"""Начальник может выбрать ответственного из своего отдела."""
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
|
||||
data = valid_equipment_data(category, dept_it, responsible=dept_member)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["responsible_person"] == dept_member.id
|
||||
|
||||
def test_dept_head_default_responsible_is_self(
|
||||
self, api_client, dept_head, category, dept_it
|
||||
):
|
||||
"""Начальник: если ответственный не указан, по умолчанию он сам."""
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
# Не указываем responsible_person
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["responsible_person"] == dept_head.id
|
||||
|
||||
def test_authorized_member_responsible_is_forced_to_head(
|
||||
self, api_client, authorized_member, category, dept_it, dept_head, dept_member
|
||||
):
|
||||
"""Уполномоченный: ответственный автоматически = начальник, нельзя выбрать другого."""
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
|
||||
# Пытаемся указать другого ответственного
|
||||
data = valid_equipment_data(category, dept_it, responsible=dept_member)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
# Должно либо заменить на начальника, либо вернуть ошибку
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
# Автозамена на начальника
|
||||
assert response.data["responsible_person"] == dept_head.id
|
||||
else:
|
||||
# Или ошибка валидации
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_authorized_member_default_responsible_is_head(
|
||||
self, api_client, authorized_member, category, dept_it, dept_head
|
||||
):
|
||||
"""Уполномоченный: если ответственный не указан, это начальник."""
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["responsible_person"] == dept_head.id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: Валидация ответственного - принадлежность к отделу
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentResponsibleValidation:
|
||||
"""Тесты: ответственный должен состоять в отделе оборудования."""
|
||||
|
||||
def test_responsible_must_be_in_department(
|
||||
self, api_client, admin_user, category, dept_it, other_dept_head
|
||||
):
|
||||
"""Ответственный ДОЛЖЕН быть в том же отделе, что и оборудование."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
# other_dept_head состоит в HR, а мы создаём в IT
|
||||
data = valid_equipment_data(category, dept_it, responsible=other_dept_head)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "responsible_person" in response.data or "responsible" in str(response.data)
|
||||
|
||||
def test_responsible_in_same_department_is_valid(
|
||||
self, api_client, admin_user, category, dept_it, dept_member
|
||||
):
|
||||
"""Ответственный из того же отдела — валидно."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
data = valid_equipment_data(category, dept_it, responsible=dept_member)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_dept_head_is_valid_responsible(
|
||||
self, api_client, admin_user, category, dept_it, dept_head
|
||||
):
|
||||
"""Начальник отдела — валидный ответственный."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
data = valid_equipment_data(category, dept_it, responsible=dept_head)
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: Изменение оборудования
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentUpdateAccess:
|
||||
"""Тесты: кто может изменять оборудование."""
|
||||
|
||||
def test_admin_can_update_any_equipment(
|
||||
self, api_client, admin_user, category, dept_it, dept_head
|
||||
):
|
||||
"""Админ может изменять любое оборудование."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
response = api_client.patch(
|
||||
equipment_detail_url(equipment.id),
|
||||
{"name": "Updated Laptop"},
|
||||
format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["name"] == "Updated Laptop"
|
||||
|
||||
def test_model_perm_user_can_update_any(
|
||||
self, api_client, model_perm_user, category, dept_it, dept_head
|
||||
):
|
||||
"""Пользователь с модельными правами может изменять любое оборудование."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=model_perm_user)
|
||||
response = api_client.patch(
|
||||
equipment_detail_url(equipment.id),
|
||||
{"name": "Updated"},
|
||||
format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_dept_head_can_update_own_dept_equipment(
|
||||
self, api_client, dept_head, category, dept_it
|
||||
):
|
||||
"""Начальник отдела может изменять оборудование своего отдела."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
response = api_client.patch(
|
||||
equipment_detail_url(equipment.id),
|
||||
{"name": "Updated"},
|
||||
format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_dept_head_cannot_update_other_dept_equipment(
|
||||
self, api_client, dept_head, category, dept_hr, other_dept_head
|
||||
):
|
||||
"""Начальник НЕ может изменять оборудование чужого отдела."""
|
||||
equipment = make_equipment("Laptop HR", dept_hr, category, other_dept_head)
|
||||
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
response = api_client.patch(
|
||||
equipment_detail_url(equipment.id),
|
||||
{"name": "Hacked"},
|
||||
format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_authorized_member_can_update_own_dept(
|
||||
self, api_client, authorized_member, category, dept_it, dept_head
|
||||
):
|
||||
"""Уполномоченный может изменять оборудование своего отдела."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
response = api_client.patch(
|
||||
equipment_detail_url(equipment.id),
|
||||
{"name": "Updated by authorized"},
|
||||
format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_responsible_person_can_update(
|
||||
self, api_client, dept_member, category, dept_it
|
||||
):
|
||||
"""Ответственный может изменять своё оборудование."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_member)
|
||||
|
||||
api_client.force_authenticate(user=dept_member)
|
||||
response = api_client.patch(
|
||||
equipment_detail_url(equipment.id),
|
||||
{"location": "Офис 305"},
|
||||
format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_regular_member_cannot_update_not_their_equipment(
|
||||
self, api_client, dept_member, category, dept_it, dept_head
|
||||
):
|
||||
"""Обычный сотрудник НЕ может изменять чужое оборудование."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=dept_member)
|
||||
response = api_client.patch(
|
||||
equipment_detail_url(equipment.id),
|
||||
{"name": "Hacked"},
|
||||
format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: Удаление оборудования
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentDeleteAccess:
|
||||
"""Тесты: кто может удалять оборудование."""
|
||||
|
||||
def test_admin_can_delete(
|
||||
self, api_client, admin_user, category, dept_it, dept_head
|
||||
):
|
||||
"""Админ может удалять любое оборудование."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
response = api_client.delete(equipment_detail_url(equipment.id))
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
def test_model_perm_user_can_delete(
|
||||
self, api_client, model_perm_user, category, dept_it, dept_head
|
||||
):
|
||||
"""Пользователь с модельными правами может удалять."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=model_perm_user)
|
||||
response = api_client.delete(equipment_detail_url(equipment.id))
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
def test_dept_head_can_delete_own_dept_equipment(
|
||||
self, api_client, dept_head, category, dept_it
|
||||
):
|
||||
"""Начальник может удалять оборудование своего отдела."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
response = api_client.delete(equipment_detail_url(equipment.id))
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
def test_dept_head_cannot_delete_other_dept(
|
||||
self, api_client, dept_head, category, dept_hr, other_dept_head
|
||||
):
|
||||
"""Начальник НЕ может удалять оборудование чужого отдела."""
|
||||
equipment = make_equipment("Laptop", dept_hr, category, other_dept_head)
|
||||
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
response = api_client.delete(equipment_detail_url(equipment.id))
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_authorized_member_cannot_delete(
|
||||
self, api_client, authorized_member, category, dept_it, dept_head
|
||||
):
|
||||
"""Уполномоченный НЕ может удалять оборудование (только создание/изменение)."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
response = api_client.delete(equipment_detail_url(equipment.id))
|
||||
|
||||
# Уполномоченный может только создавать и изменять, но не удалять
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_responsible_cannot_delete(
|
||||
self, api_client, dept_member, category, dept_it
|
||||
):
|
||||
"""Ответственный НЕ может удалять своё оборудование."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_member)
|
||||
|
||||
api_client.force_authenticate(user=dept_member)
|
||||
response = api_client.delete(equipment_detail_url(equipment.id))
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: Просмотр оборудования
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentViewAccess:
|
||||
"""Тесты: просмотр оборудования."""
|
||||
|
||||
def test_unauthenticated_cannot_view(self, api_client, category, dept_it, dept_head):
|
||||
"""Неаутентифицированный не может просматривать."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
response = api_client.get(equipment_detail_url(equipment.id))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_any_authenticated_can_view_list(
|
||||
self, api_client, dept_member, category, dept_it, dept_head
|
||||
):
|
||||
"""Любой аутентифицированный сотрудник может просматривать список."""
|
||||
make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=dept_member)
|
||||
response = api_client.get(equipment_create_url())
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_any_authenticated_can_view_detail(
|
||||
self, api_client, random_user, category, dept_it, dept_head
|
||||
):
|
||||
"""Любой аутентифицированный может просматривать детали."""
|
||||
equipment = make_equipment("Laptop", dept_it, category, dept_head)
|
||||
|
||||
api_client.force_authenticate(user=random_user)
|
||||
response = api_client.get(equipment_detail_url(equipment.id))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: API для получения доступных опций
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentCreateOptions:
|
||||
"""Тесты: эндпоинт для получения доступных опций при создании."""
|
||||
|
||||
def test_admin_gets_all_departments(
|
||||
self, api_client, admin_user, dept_it, dept_hr
|
||||
):
|
||||
"""Админ получает все отделы для выбора."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
url = reverse("procurement:equipment-create-options")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["can_choose_department"] is True
|
||||
assert response.data["can_choose_responsible"] is True
|
||||
dept_ids = [d["id"] for d in response.data["allowed_departments"]]
|
||||
assert dept_it.id in dept_ids
|
||||
assert dept_hr.id in dept_ids
|
||||
|
||||
def test_dept_head_gets_only_own_department(
|
||||
self, api_client, dept_head, dept_it, dept_hr
|
||||
):
|
||||
"""Начальник получает только свой отдел."""
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
|
||||
url = reverse("procurement:equipment-create-options")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["can_choose_department"] is False
|
||||
assert response.data["can_choose_responsible"] is True
|
||||
dept_ids = [d["id"] for d in response.data["allowed_departments"]]
|
||||
assert dept_ids == [dept_it.id]
|
||||
|
||||
def test_authorized_member_gets_limited_options(
|
||||
self, api_client, authorized_member, dept_it, dept_head
|
||||
):
|
||||
"""Уполномоченный получает свой отдел и НЕ может выбирать ответственного."""
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
|
||||
url = reverse("procurement:equipment-create-options")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["can_choose_department"] is False
|
||||
assert response.data["can_choose_responsible"] is False
|
||||
assert response.data["default_responsible"]["id"] == dept_head.id
|
||||
|
||||
def test_regular_member_cannot_access(self, api_client, dept_member):
|
||||
"""Обычный сотрудник без прав не получает опции (или получает пустой ответ)."""
|
||||
api_client.force_authenticate(user=dept_member)
|
||||
|
||||
url = reverse("procurement:equipment-create-options")
|
||||
response = api_client.get(url)
|
||||
|
||||
# Либо 403, либо пустой список отделов
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
assert response.data["allowed_departments"] == []
|
||||
else:
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests: Массовое создание
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEquipmentBulkCreate:
|
||||
"""Тесты: массовое создание оборудования."""
|
||||
|
||||
def test_admin_can_bulk_create(
|
||||
self, api_client, admin_user, category, dept_it, dept_head
|
||||
):
|
||||
"""Админ может массово создавать."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
data = valid_equipment_data(category, dept_it, responsible=dept_head)
|
||||
data["quantity"] = 3
|
||||
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["created_count"] == 3
|
||||
|
||||
def test_bulk_create_generates_unique_inventory_numbers(
|
||||
self, api_client, admin_user, category, dept_it, dept_head
|
||||
):
|
||||
"""При массовом создании генерируются уникальные инвентарные номера."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
data = valid_equipment_data(category, dept_it, responsible=dept_head)
|
||||
data["quantity"] = 3
|
||||
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
inventory_numbers = [eq["inventory_number"] for eq in response.data["equipment"]]
|
||||
assert len(set(inventory_numbers)) == 3 # Все уникальные
|
||||
|
||||
def test_authorized_member_bulk_create_uses_dept_head(
|
||||
self, api_client, authorized_member, category, dept_it, dept_head
|
||||
):
|
||||
"""Уполномоченный при массовом создании — ответственный всегда начальник."""
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
|
||||
data = valid_equipment_data(category, dept_it)
|
||||
data["quantity"] = 2
|
||||
|
||||
response = api_client.post(equipment_create_url(), data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
for eq in response.data["equipment"]:
|
||||
assert eq["responsible_person"] == dept_head.id
|
||||
Loading…
x
Reference in New Issue
Block a user