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:
admin i 2025-12-09 16:14:30 +03:00
parent 3d1a415bee
commit 8b8974e3ef
9 changed files with 2287 additions and 213 deletions

View File

@ -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, "Управлять оборудованием отдела"),
)

View File

@ -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,
),
]

View File

@ -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):

View File

@ -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):
"""Сериализатор для детальной информации об оборудовании."""

View File

@ -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):
"""Перевод оборудования в другой отдел или другому пользователю."""

View File

@ -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
)

View File

@ -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);

View File

@ -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();

View 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