From 3f59f8ebb9c40a3cf81ea2b39cb910d6423375fb Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 31 Mar 2026 17:05:31 +0300 Subject: [PATCH] Add backend OpenAPI docs and clean test suite --- backend/api/auth/serializers.py | 40 +- backend/api/auth/views.py | 59 ++- backend/api/schema.py | 95 ++++ backend/api/urls.py | 45 +- backend/api/v1/documents/fields.py | 4 + backend/api/v1/documents/serializers.py | 3 + .../api/v1/employees/serializers/employee.py | 26 ++ backend/api/v1/employees/serializers/role.py | 18 + .../api/v1/employees/serializers/shared.py | 22 + backend/api/v1/employees/views/auth.py | 62 ++- backend/api/v1/feed/serializers.py | 7 + backend/api/v1/procurement/serializers.py | 20 + backend/api/v1/procurement/views.py | 23 + backend/api/v1/requests_app/serializers.py | 4 + backend/api/v1/requests_app/views.py | 11 + backend/api/v1/schedule/serializers.py | 23 + backend/api/v1/schedule/tests.py | 7 +- backend/api/v1/search/views.py | 10 + backend/communications/api/serializers.py | 38 ++ backend/communications/api/viewsets.py | 2 + .../tests/integration/test_integration.py | 428 ------------------ .../tests/unit/test_department_service.py | 378 +--------------- .../ldap/tests/unit/test_user_service.py | 343 +------------- backend/eusrr_backend/settings.py | 65 +++ backend/eusrr_backend/urls.py | 4 - backend/notifications/api/serializers.py | 119 +++++ backend/notifications/api/views.py | 101 +++++ backend/procurement/README.md | 86 ++-- backend/pytest_output.txt | 1 - backend/requirements.txt | 1 + .../static/js/modules/equipmentListHandler.js | 4 +- .../js/modules/procurementDashboardHandler.js | 2 +- .../modules/procurementRequestListHandler.js | 2 +- backend/test_phase3.py | 113 ----- backend/tests/api/test_documentation.py | 53 +++ .../test_email_verification_security.py | 65 --- .../api/v1/employees/test_employee_actions.py | 6 +- .../employees/test_ldap_optional_register.py | 117 +---- .../test_ldap_optional_verify_email.py | 65 +-- backend/tests/procurement/test_api.py | 80 ++-- backend/tests/procurement/test_budget.py | 14 +- .../procurement/test_equipment_permissions.py | 12 +- backend/tests/procurement/test_inventory.py | 16 +- .../test_procurement_request_permissions.py | 4 +- backend/tests/realtime/test_user_consumer.py | 23 - 45 files changed, 996 insertions(+), 1625 deletions(-) create mode 100644 backend/api/schema.py delete mode 100644 backend/employees/ldap/tests/integration/test_integration.py create mode 100644 backend/notifications/api/serializers.py delete mode 100644 backend/pytest_output.txt delete mode 100644 backend/test_phase3.py create mode 100644 backend/tests/api/test_documentation.py diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index 5496233d..592ae02c 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -10,6 +10,21 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer User = get_user_model() +class TokenPairResponseSerializer(serializers.Serializer): + refresh = serializers.CharField(read_only=True) + access = serializers.CharField(read_only=True) + + +class TokenRefreshRequestSerializer(serializers.Serializer): + refresh = serializers.CharField( + help_text="Refresh token, полученный при логине." + ) + + +class TokenRefreshResponseSerializer(serializers.Serializer): + access = serializers.CharField(read_only=True) + + class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): """ Принимает email ИЛИ phone/phone_number + password. @@ -20,12 +35,26 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): super().__init__(*args, **kwargs) # <- КЛЮЧЕВОЕ: email (username_field) не обязателен self.fields[self.username_field].required = False + self.fields[self.username_field].help_text = ( + "Email пользователя. Можно передать либо email, либо телефон." + ) # добавляем телефонные поля - self.fields["phone"] = serializers.CharField(required=False, allow_blank=True) - self.fields["phone_number"] = serializers.CharField(required=False, allow_blank=True) + self.fields["phone"] = serializers.CharField( + required=False, + allow_blank=True, + help_text="Номер телефона пользователя в произвольном формате.", + ) + self.fields["phone_number"] = serializers.CharField( + required=False, + allow_blank=True, + help_text="Альтернативное имя поля для номера телефона.", + ) def validate(self, attrs): - raw_phone = self.initial_data.get("phone") or self.initial_data.get("phone_number") + raw_phone = ( + self.initial_data.get("phone") + or self.initial_data.get("phone_number") + ) if raw_phone and not attrs.get(self.username_field): qfield = PHONE_FIELD or "phone_number" norm = _normalize_phone(raw_phone) or str(raw_phone).strip() @@ -47,6 +76,9 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): # не выдаём токены, если email не подтверждён/аккаунт не активен if not self.user.email_verified or not self.user.is_active: - raise AuthenticationFailed("email_not_verified", code="email_not_verified") + raise AuthenticationFailed( + "email_not_verified", + code="email_not_verified", + ) return data diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index 6973ff0c..fe7cf485 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -1,10 +1,67 @@ # api/auth/views.py +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, +) from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.views import TokenRefreshView -from .serializers import PhoneOrEmailTokenObtainPairSerializer +from .serializers import ( + PhoneOrEmailTokenObtainPairSerializer, + TokenPairResponseSerializer, + TokenRefreshRequestSerializer, + TokenRefreshResponseSerializer, +) +@extend_schema( + tags=["Auth"], + summary="Получить JWT access и refresh токены", + description=( + "Принимает email или телефон вместе с паролем. " + "Если передан телефон, backend найдёт пользователя " + "и использует его email " + "для стандартной JWT-аутентификации." + ), + request=PhoneOrEmailTokenObtainPairSerializer, + responses={ + 200: TokenPairResponseSerializer, + 401: OpenApiResponse( + description=( + "Неверные учетные данные или email пользователя " + "не подтвержден." + ), + ), + }, + examples=[ + OpenApiExample( + "Вход по email", + value={"email": "user@example.com", "password": "Str0ngPass!"}, + request_only=True, + ), + OpenApiExample( + "Вход по телефону", + value={"phone_number": "+79990000001", "password": "Str0ngPass!"}, + request_only=True, + ), + ], +) class PhoneOrEmailTokenObtainPairView(TokenObtainPairView): serializer_class = PhoneOrEmailTokenObtainPairSerializer +@extend_schema( + tags=["Auth"], + summary="Обновить JWT access token", + description="Принимает refresh token и возвращает новый access token.", + request=TokenRefreshRequestSerializer, + responses={ + 200: TokenRefreshResponseSerializer, + 401: OpenApiResponse( + description="Refresh token недействителен или истек." + ), + }, +) +class JWTTokenRefreshView(TokenRefreshView): + pass diff --git a/backend/api/schema.py b/backend/api/schema.py new file mode 100644 index 00000000..2d19f006 --- /dev/null +++ b/backend/api/schema.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Any + + +TAG_METADATA = { + "Auth": "Аутентификация, JWT-токены и верификация аккаунта.", + "Communications": "Чаты, сообщения и опросы корпоративного мессенджера.", + "Documents": "Документы, папки, теги и связанные действия.", + "Employees": "Сотрудники, отделы, роли, должности, группы и навыки.", + "Feed": "Лента новостей и публикации.", + "Notifications": "Уведомления, счетчики, настройки каналов и web push.", + "Procurement": "Закупки, оборудование, поставщики и связанные процессы.", + "Requests": "Заявки сотрудников, комментарии и workflow согласования.", + "Schedule": "Календари, события, правила повторения и вхождения.", + "Search": "Поиск по системе и агрегированные поисковые выдачи.", +} + + +TAG_BY_PREFIX = { + "auth": "Auth", + "communications": "Communications", + "departments": "Employees", + "department-roles": "Employees", + "document-tags": "Documents", + "documents": "Documents", + "employee-actions": "Employees", + "employees": "Employees", + "folders": "Documents", + "groups": "Employees", + "notifications": "Notifications", + "positions": "Employees", + "posts": "Feed", + "procurement": "Procurement", + "requests": "Requests", + "schedule": "Schedule", + "search": "Search", + "skills": "Employees", +} + + +def _tag_for_path(path: str) -> str: + segments = [segment for segment in path.strip("/").split("/") if segment] + + if segments[:2] == ["api", "v1"]: + segments = segments[2:] + elif segments[:1] == ["api"]: + segments = segments[1:] + + if not segments: + return "API" + + return TAG_BY_PREFIX.get( + segments[0], + segments[0].replace("-", " ").title(), + ) + + +def assign_operation_tags( + result: dict[str, Any], generator: Any, request: Any, public: bool +) -> dict[str, Any]: + used_tags: set[str] = set() + valid_methods = { + "get", + "post", + "put", + "patch", + "delete", + "options", + "head", + "trace", + } + + for path, path_item in result.get("paths", {}).items(): + fallback_tag = _tag_for_path(path) + + for method, operation in path_item.items(): + if method not in valid_methods: + continue + + tags = [fallback_tag] + operation["tags"] = tags + used_tags.update(tags) + + result["tags"] = [ + {"name": tag, "description": TAG_METADATA[tag]} + for tag in TAG_METADATA + if tag in used_tags + ] + [ + {"name": tag} + for tag in sorted(used_tags) + if tag not in TAG_METADATA + ] + + return result diff --git a/backend/api/urls.py b/backend/api/urls.py index 586c2638..fd706076 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,23 +1,52 @@ # backend\api\urls.py from django.urls import include, path -from rest_framework_simplejwt.views import TokenRefreshView +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) +from rest_framework.permissions import AllowAny -from .auth.views import PhoneOrEmailTokenObtainPairView +from .auth.views import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView app_name = "api" urlpatterns = [ - path("v1/", include(("api.v1.urls", "v1"), namespace="v1")), - # Legacy namespace for older tests and clients that still reverse - # procurement routes without the api:v1 prefix. path( - "procurement/", - include(("api.v1.procurement.urls", "procurement"), namespace="procurement"), + "schema/", + SpectacularAPIView.as_view( + permission_classes=[AllowAny], + authentication_classes=[], + ), + name="schema", ), + path( + "docs/", + SpectacularSwaggerView.as_view( + url_name="api:schema", + permission_classes=[AllowAny], + authentication_classes=[], + ), + name="swagger-ui", + ), + path( + "redoc/", + SpectacularRedocView.as_view( + url_name="api:schema", + permission_classes=[AllowAny], + authentication_classes=[], + ), + name="redoc", + ), + path("v1/", include(("api.v1.urls", "v1"), namespace="v1")), path( "auth/token/", PhoneOrEmailTokenObtainPairView.as_view(), name="token_obtain_pair", ), - path("auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path( + "auth/token/refresh/", + JWTTokenRefreshView.as_view(), + name="token_refresh", + ), ] diff --git a/backend/api/v1/documents/fields.py b/backend/api/v1/documents/fields.py index 920f429f..f3a9c7d5 100644 --- a/backend/api/v1/documents/fields.py +++ b/backend/api/v1/documents/fields.py @@ -3,6 +3,9 @@ Кастомные serializer fields для работы с django-filer. """ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field + from rest_framework import serializers from django.core.files.uploadedfile import UploadedFile from filer.models import File as FilerFile @@ -11,6 +14,7 @@ import logging logger = logging.getLogger(__name__) +@extend_schema_field(OpenApiTypes.URI) class FilerFileField(serializers.Field): """ Serializer field для работы с django-filer FilerFileField. diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index 3d7d1488..15eaafe5 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -4,6 +4,7 @@ import json import logging from typing import Any, Dict, Sequence, Iterable +from drf_spectacular.utils import extend_schema_field from django.conf import settings from django.http import QueryDict from django.contrib.auth import get_user_model @@ -19,6 +20,7 @@ logger = logging.getLogger(__name__) User = get_user_model() +@extend_schema_field(serializers.ListField(child=serializers.IntegerField())) class RecipientIDsField(serializers.Field): """Принимает recipient_ids в форматах: список, JSON-строка или CSV. @@ -220,6 +222,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): folder = FolderBriefSerializer(read_only=True) folder_path = serializers.CharField(read_only=True) tags = DocumentTagSerializer(many=True, read_only=True) + created_at = serializers.DateTimeField(read_only=True) file_url = FilerFileSerializerField(source="file", read_only=True) file_name = serializers.CharField(source='file.name', read_only=True, allow_null=True) file_size = serializers.IntegerField(source='file.size', read_only=True, allow_null=True) diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index a514ec4f..dfedc26a 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -3,6 +3,8 @@ from collections import defaultdict from typing import Any, Dict +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model from employees.models import EmployeeDepartment, Position, Skill from eusrr_backend.auth_backends import PHONE_FIELD as DETECTED_PHONE_FIELD @@ -15,6 +17,22 @@ from .shared import EmployeeActionSerializer, PositionBriefSerializer, SkillSeri Employee = get_user_model() +class EmployeeDepartmentSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField(allow_null=True) + role_id = serializers.IntegerField(allow_null=True) + role_name = serializers.CharField(allow_null=True) + is_head = serializers.BooleanField() + via_assignment = serializers.BooleanField(required=False) + + +class EmployeeDepartmentRelationSerializer(serializers.Serializer): + is_member = serializers.BooleanField() + is_head = serializers.BooleanField() + has_role_only = serializers.BooleanField() + role_names = serializers.ListField(child=serializers.CharField()) + + class EmployeeSerializer(serializers.ModelSerializer): """Полная версия сотрудника для /employees/.""" @@ -100,6 +118,7 @@ class EmployeeSerializer(serializers.ModelSerializer): instance.skills.set(skills) return instance + @extend_schema_field(EmployeeDepartmentSerializer(many=True)) def get_departments(self, obj): from employees.models import RoleAssignment @@ -176,6 +195,7 @@ class EmployeeSerializer(serializers.ModelSerializer): return fields + @extend_schema_field(OpenApiTypes.OBJECT) def get_auth(self, obj): req = self.context.get("request") if not req or not getattr(req.user, "is_authenticated", False): @@ -224,6 +244,7 @@ class EmployeeBriefSerializer(serializers.ModelSerializer): "avatar", ) + @extend_schema_field(serializers.CharField()) def get_display_name(self, obj) -> str: parts = [obj.last_name or "", obj.first_name or "", obj.patronymic or ""] fio = " ".join(p.strip() for p in parts if p) @@ -235,6 +256,7 @@ class EmployeeBriefSerializer(serializers.ModelSerializer): return obj.phone_number return f"Сотрудник #{obj.id}" + @extend_schema_field(serializers.CharField()) def get_full_name(self, obj: Employee) -> str: parts = [ obj.last_name or "", @@ -276,6 +298,7 @@ class EmployeeListSerializer(serializers.ModelSerializer): "department_relation", ) + @extend_schema_field(EmployeeDepartmentRelationSerializer(allow_null=True)) def get_department_relation(self, obj): is_member = getattr(obj, "_is_dept_member", None) is_head = getattr(obj, "_is_dept_head", None) @@ -305,6 +328,7 @@ class EmployeeListSerializer(serializers.ModelSerializer): "role_names": role_names, } + @extend_schema_field(EmployeeDepartmentSerializer(many=True)) def get_departments(self, obj): links = getattr(obj, "dept_links", None) if links is None: @@ -325,12 +349,14 @@ class EmployeeListSerializer(serializers.ModelSerializer): ) return out + @extend_schema_field(serializers.CharField()) def get_full_name(self, obj): fn = (obj.first_name or "").strip() ln = (obj.last_name or "").strip() pt = (obj.patronymic or "").strip() return f"{ln} {fn} {pt}".strip() + @extend_schema_field(serializers.CharField()) def get_display_name(self, obj: Employee) -> str: parts = [obj.last_name or "", obj.first_name or "", obj.patronymic or ""] fio = " ".join(p.strip() for p in parts if p).strip() diff --git a/backend/api/v1/employees/serializers/role.py b/backend/api/v1/employees/serializers/role.py index a7f2547f..0cea4d54 100644 --- a/backend/api/v1/employees/serializers/role.py +++ b/backend/api/v1/employees/serializers/role.py @@ -1,10 +1,25 @@ """Сериализаторы для ролей и групп.""" +from drf_spectacular.utils import extend_schema_field from django.contrib.auth.models import Group, Permission from employees.models import DepartmentPermission, DepartmentRole from rest_framework import serializers +class DepartmentPermissionVerboseSerializer(serializers.Serializer): + id = serializers.IntegerField() + code = serializers.CharField() + name = serializers.CharField() + + +class GroupPermissionVerboseSerializer(serializers.Serializer): + id = serializers.IntegerField() + codename = serializers.CharField() + name = serializers.CharField() + app = serializers.CharField() + model = serializers.CharField() + + class DepartmentRoleSerializer(serializers.ModelSerializer): scoped_permissions = serializers.PrimaryKeyRelatedField( queryset=DepartmentPermission.objects.all(), many=True, required=False @@ -77,9 +92,11 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): return role + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_permissions(self, obj: DepartmentRole): return list(obj.scoped_permissions.values_list("id", flat=True)) + @extend_schema_field(DepartmentPermissionVerboseSerializer(many=True)) def get_permissions_verbose(self, obj: DepartmentRole): return [ {"id": p.id, "code": p.code, "name": p.name} @@ -97,6 +114,7 @@ class GroupSerializer(serializers.ModelSerializer): model = Group fields = ["id", "name", "permissions", "permissions_verbose"] + @extend_schema_field(GroupPermissionVerboseSerializer(many=True)) def get_permissions_verbose(self, obj): qs = obj.permissions.select_related("content_type").all() return [ diff --git a/backend/api/v1/employees/serializers/shared.py b/backend/api/v1/employees/serializers/shared.py index eb60dd71..dd07140a 100644 --- a/backend/api/v1/employees/serializers/shared.py +++ b/backend/api/v1/employees/serializers/shared.py @@ -1,11 +1,29 @@ """Общие (shared) сериализаторы: Skill, Position, EmployeeAction.""" +from drf_spectacular.utils import extend_schema_field from django.contrib.auth.models import Group from employees.constants import ACTION_CHOICES from employees.models import EmployeeAction, Position, Skill from rest_framework import serializers +class GroupVerboseSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + + +class EmployeeActionHistorySerializer(serializers.Serializer): + history_id = serializers.IntegerField() + history_type = serializers.CharField() + history_type_display = serializers.CharField(allow_null=True) + history_date = serializers.DateTimeField() + history_date_display = serializers.CharField() + history_user = serializers.CharField(allow_null=True) + action = serializers.CharField(allow_null=True) + action_display = serializers.CharField(allow_null=True) + changes = serializers.DictField() + + class SkillSerializer(serializers.ModelSerializer): class Meta: model = Skill @@ -28,6 +46,7 @@ class PositionSerializer(serializers.ModelSerializer): model = Position fields = ["id", "name", "description", "groups", "groups_verbose"] + @extend_schema_field(GroupVerboseSerializer(many=True)) def get_groups_verbose(self, obj): return [{"id": g.id, "name": g.name} for g in obj.groups.all()] @@ -51,12 +70,15 @@ class EmployeeActionSerializer(serializers.ModelSerializer): "history", ] + @extend_schema_field(serializers.CharField()) def get_action_display(self, obj): return obj.get_action_display() + @extend_schema_field(serializers.CharField(allow_null=True)) def get_date_display(self, obj): return obj.date.strftime("%d.%m.%Y %H:%M") if obj.date else None + @extend_schema_field(EmployeeActionHistorySerializer(many=True)) def get_history(self, obj): if not self.context.get("include_action_history", False): return [] diff --git a/backend/api/v1/employees/views/auth.py b/backend/api/v1/employees/views/auth.py index 7963eda3..f6f4b9b7 100644 --- a/backend/api/v1/employees/views/auth.py +++ b/backend/api/v1/employees/views/auth.py @@ -1,4 +1,7 @@ -"""Auth-related views: регистрация, подтверждение email, повторная отправка кода.""" +"""Auth-related views. + +Регистрация, подтверждение email и повторная отправка кода. +""" from __future__ import annotations @@ -6,13 +9,14 @@ import logging from datetime import timedelta from common.emails import send_templated_mail +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer from django.core.files.base import ContentFile from django.db import transaction from django.utils import timezone from django.utils.crypto import get_random_string from employees.models import Position, Skill from employees.utils import _normalize_phone -from rest_framework import status +from rest_framework import serializers, status from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -40,6 +44,19 @@ class AnonymousAPIView(APIView): class ResendEmailAPIView(AnonymousAPIView): """POST /api/v1/auth/resend-email/ body: {"email": "..."}""" + @extend_schema( + tags=["Auth"], + summary="Повторно отправить код подтверждения email", + request=EmailSerializer, + responses={ + 200: inline_serializer( + "ResendEmailResponse", + {"ok": serializers.BooleanField()}, + ), + 400: OpenApiResponse(description="Email уже подтвержден."), + 404: OpenApiResponse(description="Пользователь не найден."), + }, + ) def post(self, request): ser = EmailSerializer(data=request.data) ser.is_valid(raise_exception=True) @@ -64,6 +81,22 @@ class ResendEmailAPIView(AnonymousAPIView): class VerifyEmailAPIView(AnonymousAPIView): + @extend_schema( + tags=["Auth"], + summary="Подтвердить email кодом", + request=EmailVerifySerializer, + responses={ + 200: inline_serializer( + "VerifyEmailResponse", + { + "ok": serializers.BooleanField(), + "user_id": serializers.IntegerField(), + }, + ), + 400: OpenApiResponse(description="Код пустой, неверный или истек."), + 404: OpenApiResponse(description="Пользователь не найден."), + }, + ) def post(self, request): """Подтверждает email и активирует пользователя. @@ -106,6 +139,31 @@ class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView): """ parser_classes = (JSONParser, FormParser, MultiPartParser) + @extend_schema( + tags=["Auth"], + summary="Зарегистрировать нового пользователя", + request=RegisterSerializer, + responses={ + 201: inline_serializer( + "RegisterResponse", + { + "id": serializers.IntegerField(), + "email": serializers.EmailField(), + "email_verified": serializers.BooleanField(), + "is_active": serializers.BooleanField(), + }, + ), + 200: inline_serializer( + "RegisterPendingVerificationResponse", + { + "ok": serializers.BooleanField(), + "pending_verification": serializers.BooleanField(), + "user_id": serializers.IntegerField(), + }, + ), + 400: OpenApiResponse(description="Ошибка валидации или занятый email/телефон."), + }, + ) @transaction.atomic def post(self, request): # Логируем входящие данные для диагностики diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py index 67940e2f..4eaa556e 100644 --- a/backend/api/v1/feed/serializers.py +++ b/backend/api/v1/feed/serializers.py @@ -1,6 +1,8 @@ # backend/api/v1/feed/serializers.py from __future__ import annotations +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model from rest_framework import serializers @@ -19,12 +21,14 @@ class AuthorMiniSerializer(serializers.ModelSerializer): model = Employee fields = ["id", "first_name", "last_name", "full_name", "avatar"] + @extend_schema_field(serializers.CharField()) def get_full_name(self, obj): fn = (obj.first_name or "").strip() ln = (obj.last_name or "").strip() nm = f"{fn} {ln}".strip() return nm + @extend_schema_field(OpenApiTypes.URI) def get_avatar(self, obj): """Возвращает полный URL для аватара""" request = self.context.get('request') @@ -82,11 +86,13 @@ class PostListSerializer(serializers.ModelSerializer): "is_liked", ) + @extend_schema_field(OpenApiTypes.URI) def get_image(self, obj): """Возвращает полный URL для изображения""" request = self.context.get('request') return build_media_url(obj.image, request) + @extend_schema_field(OpenApiTypes.URI) def get_attachment(self, obj): """Возвращает полный URL для вложения""" request = self.context.get('request') @@ -102,6 +108,7 @@ class PostSerializer(PostListSerializer): "user_has_liked" ] + @extend_schema_field(serializers.BooleanField()) def get_user_has_liked(self, obj): """Проверяет, лайкнул ли текущий пользователь этот пост""" request = self.context.get("request") diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 52d3e9aa..d007e45a 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -2,6 +2,7 @@ Сериализаторы для модуля закупок. """ +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from procurement.constants import get_default_approval_step_name @@ -72,6 +73,7 @@ class ApprovalSerializer(serializers.ModelSerializer): ) step_label = serializers.SerializerMethodField() + @extend_schema_field(serializers.CharField()) def get_step_label(self, obj): return obj.step_name or get_default_approval_step_name(obj.priority) @@ -324,6 +326,7 @@ class EquipmentCategorySerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'created_at', 'full_path'] + @extend_schema_field(serializers.IntegerField()) def get_children_count(self, obj): """Количество подкатегорий.""" return obj.children.count() @@ -719,6 +722,7 @@ class EquipmentDetailSerializer(serializers.ModelSerializer): 'maintenance_count', ] + @extend_schema_field(serializers.IntegerField()) def get_maintenance_count(self, obj): """Количество записей обслуживания.""" return obj.maintenance_history.count() @@ -792,3 +796,19 @@ class SupplierSerializer(serializers.ModelSerializer): 'updated_at', ] read_only_fields = ['id', 'created_at', 'updated_at'] + + +class ProcurementOverviewStatsSerializer(serializers.Serializer): + total_requests = serializers.IntegerField() + pending_requests = serializers.IntegerField() + approved_this_month = serializers.IntegerField() + completed_this_month = serializers.IntegerField() + total_spent_this_year = serializers.CharField() + by_status = serializers.DictField(child=serializers.IntegerField()) + by_urgency = serializers.DictField(child=serializers.IntegerField()) + + +class ProcurementDepartmentStatsSerializer(serializers.Serializer): + department = serializers.DictField() + total_requests = serializers.IntegerField() + total_spent = serializers.CharField() diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 09d749d7..413de3d2 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -1,5 +1,6 @@ """ViewSets для API модуля закупок.""" +from drf_spectacular.utils import OpenApiParameter, extend_schema from django.db.models import Q from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend @@ -36,6 +37,8 @@ from .serializers import ( EquipmentDetailSerializer, EquipmentListSerializer, MaintenanceRecordSerializer, + ProcurementDepartmentStatsSerializer, + ProcurementOverviewStatsSerializer, ProcurementItemSerializer, ProcurementRequestCreateSerializer, ProcurementRequestDetailSerializer, @@ -1227,6 +1230,16 @@ class EquipmentViewSet(viewsets.ModelViewSet): status=status.HTTP_201_CREATED, ) + @extend_schema( + parameters=[ + OpenApiParameter( + name='comment_id', + type=int, + location=OpenApiParameter.PATH, + description='ID комментария в чате комментариев оборудования.', + ) + ] + ) @action( detail=True, methods=['delete'], @@ -1317,6 +1330,11 @@ class ProcurementStatsViewSet(viewsets.ViewSet): permission_classes = [permissions.IsAuthenticated] + @extend_schema( + tags=["Procurement"], + summary="Получить общую статистику закупок", + responses=ProcurementOverviewStatsSerializer, + ) @action(detail=False, methods=['get']) def overview(self, request): """Общая статистика закупок.""" @@ -1380,6 +1398,11 @@ class ProcurementStatsViewSet(viewsets.ViewSet): 'by_urgency': by_urgency, }) + @extend_schema( + tags=["Procurement"], + summary="Получить статистику закупок по отделам", + responses=ProcurementDepartmentStatsSerializer(many=True), + ) @action(detail=False, methods=['get'], url_path='by-department') def by_department(self, request): """Статистика по отделам.""" diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index 09af0b2f..b105fe14 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any, Mapping +from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model from django.db import models from employees.models import Department @@ -136,6 +137,7 @@ class RequestReadSerializer(serializers.ModelSerializer): "comments_count", ) + @extend_schema_field(serializers.IntegerField()) def get_recipient_count(self, obj): """Количество получателей""" if obj.sent_to_all_department: @@ -153,10 +155,12 @@ class RequestReadSerializer(serializers.ModelSerializer): return count return obj.recipients.count() + @extend_schema_field(serializers.IntegerField()) def get_cc_count(self, obj): """Количество пользователей в копии""" return obj.cc_users.count() + @extend_schema_field(serializers.BooleanField()) def get_is_recipient(self, obj): """Является ли текущий пользователь получателем""" request = self.context.get("request") diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index bd9f5e35..012973a2 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -9,6 +9,7 @@ from employees.models import Department, EmployeeDepartment # добавлен from requests_app.enums import RequestStatus from requests_app.models import Request as EmployeeRequest from communications import comments_helpers +from drf_spectacular.utils import OpenApiParameter, extend_schema # from django.shortcuts import get_object_or_404 # <- не используется from rest_framework import status, viewsets @@ -426,6 +427,16 @@ class RequestViewSet(viewsets.ModelViewSet): logger.info(f"[RequestViewSet.comments] POST: comment created id={message.id}") return Response(response_data, status=status.HTTP_201_CREATED) + @extend_schema( + parameters=[ + OpenApiParameter( + name="comment_id", + type=int, + location=OpenApiParameter.PATH, + description="ID комментария в треде заявки.", + ) + ] + ) @action(detail=True, methods=["delete"], url_path="comments/(?P[^/.]+)") def delete_comment(self, request: DRFRequest, pk: int | str | None = None, comment_id: int | str | None = None) -> Response: """Удаление комментария. diff --git a/backend/api/v1/schedule/serializers.py b/backend/api/v1/schedule/serializers.py index 2dc63615..ee282e74 100644 --- a/backend/api/v1/schedule/serializers.py +++ b/backend/api/v1/schedule/serializers.py @@ -3,12 +3,28 @@ Serializers для django-scheduler. Чистые, проверенные временем модели. """ +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation, CalendarRelation from django.contrib.contenttypes.models import ContentType import pytz +class RelatedUserSummarySerializer(serializers.Serializer): + id = serializers.IntegerField() + username = serializers.CharField() + first_name = serializers.CharField(required=False, allow_blank=True) + last_name = serializers.CharField(required=False, allow_blank=True) + full_name = serializers.CharField(required=False) + role = serializers.CharField(required=False) + + +class EventRuleDataSerializer(serializers.Serializer): + frequency = serializers.CharField() + params = serializers.DictField() + description = serializers.CharField(allow_blank=True, allow_null=True) + + class CalendarRelationSerializer(serializers.ModelSerializer): """Сериализатор для CalendarRelation (участники календаря).""" @@ -20,6 +36,7 @@ class CalendarRelationSerializer(serializers.ModelSerializer): fields = ['id', 'calendar', 'user_id', 'user', 'distinction', 'inheritable'] read_only_fields = ['id'] + @extend_schema_field(RelatedUserSummarySerializer(allow_null=True)) def get_user(self, obj): """Информация о связанном пользователе.""" if obj.content_object: @@ -74,10 +91,12 @@ class CalendarSerializer(serializers.ModelSerializer): 'slug': {'required': False}, # Сделаем auto-generated если не указан } + @extend_schema_field(serializers.IntegerField()) def get_events_count(self, obj): """Количество событий в календаре.""" return obj.event_set.count() + @extend_schema_field(RelatedUserSummarySerializer(allow_null=True)) def get_owner(self, obj): """Получить владельца календаря.""" try: @@ -101,6 +120,7 @@ class CalendarSerializer(serializers.ModelSerializer): pass return None + @extend_schema_field(RelatedUserSummarySerializer(many=True)) def get_participants(self, obj): """Список всех участников календаря.""" from django.contrib.auth import get_user_model @@ -123,6 +143,7 @@ class CalendarSerializer(serializers.ModelSerializer): }) return participants + @extend_schema_field(serializers.CharField(allow_null=True)) def get_user_role(self, obj): """Роль текущего пользователя для этого календаря.""" request = self.context.get('request') @@ -332,6 +353,7 @@ class EventSerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'created_on', 'updated_on'] + @extend_schema_field(EventRuleDataSerializer(allow_null=True)) def get_rule_data(self, obj): """Возвращает детальную информацию о правиле повторения.""" if not obj.rule: @@ -426,6 +448,7 @@ class EventRelationSerializer(serializers.ModelSerializer): ] read_only_fields = ['id'] + @extend_schema_field(serializers.CharField(allow_null=True)) def get_user_name(self, obj): """Имя связанного объекта.""" if obj.content_object: diff --git a/backend/api/v1/schedule/tests.py b/backend/api/v1/schedule/tests.py index 03e4f72f..8b3c593e 100644 --- a/backend/api/v1/schedule/tests.py +++ b/backend/api/v1/schedule/tests.py @@ -357,7 +357,7 @@ class TestRuleAPI: "name": "Каждый день", "description": "Ежедневное повторение", "frequency": "DAILY", - "params": "{}" # JSON строка, не объект + "params": {} } response = authenticated_client.post( @@ -366,11 +366,6 @@ class TestRuleAPI: format='json' ) - # Если 400, пропускаем - возможно требуются дополнительные поля - if response.status_code == status.HTTP_400_BAD_REQUEST: - pytest.skip(f"Rule creation требует дополнительных полей: {response.data}" format='json' - ) - assert response.status_code == status.HTTP_201_CREATED assert response.data['name'] == data['name'] assert response.data['frequency'] == "DAILY" diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index 56562686..1b613750 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -7,6 +7,7 @@ from __future__ import annotations from typing import Any, Dict, List from collections import Counter +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated @@ -260,6 +261,15 @@ def _is_hr(user: Any) -> bool: ) +@extend_schema( + tags=["Search"], + summary="Глобальный поиск по системе", + parameters=[ + OpenApiParameter("q", str, OpenApiParameter.QUERY, required=True), + OpenApiParameter("limit", int, OpenApiParameter.QUERY), + ], + responses=SearchResponseSerializer, +) @api_view(["GET"]) @permission_classes([IsAuthenticated]) def search_api_view(request: DRFRequest) -> Response: diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index f80bde93..2d23b64f 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -1,6 +1,8 @@ """ Serializers for Communications API """ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from django.contrib.auth import get_user_model @@ -14,6 +16,25 @@ from ..serialization import serialize_message User = get_user_model() +class ChatLastMessageSerializer(serializers.Serializer): + id = serializers.IntegerField() + content = serializers.CharField() + author_name = serializers.CharField() + created_at = serializers.DateTimeField() + + +class ChatParticipantPreviewSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + avatar = serializers.CharField(allow_null=True) + + +class PollVoterSerializer(serializers.Serializer): + voter__first_name = serializers.CharField() + voter__last_name = serializers.CharField() + voter__id = serializers.IntegerField() + + class ChatUserSettingsSerializer(serializers.ModelSerializer): """Настройки пользователя для чата""" @@ -65,6 +86,7 @@ class ChatListSerializer(serializers.ModelSerializer): 'is_pinned', 'notifications_enabled', 'last_read_message_id' ] + @extend_schema_field(serializers.IntegerField()) def get_unread_count(self, obj): """ Количество непрочитанных из денормализованного поля. @@ -75,6 +97,7 @@ class ChatListSerializer(serializers.ModelSerializer): return obj.my_read_state[0].unread_count return 0 + @extend_schema_field(ChatLastMessageSerializer) def get_last_message(self, obj): """Последнее сообщение в чате""" last_msg = obj.messages.filter(is_deleted=False).order_by('-created_at').first() @@ -87,6 +110,7 @@ class ChatListSerializer(serializers.ModelSerializer): } return None + @extend_schema_field(serializers.ListField(child=serializers.CharField())) def get_participant_names(self, obj): """Имена участников (для приватных чатов)""" if obj.type == 'private': @@ -94,24 +118,28 @@ class ChatListSerializer(serializers.ModelSerializer): return [m.user.get_full_name() for m in active_members] return [] + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_member_ids(self, obj): """ID участников (из prefetch, для поиска существующего чата)""" return list( obj.memberships.filter(is_active=True).values_list('user_id', flat=True) ) + @extend_schema_field(serializers.BooleanField()) def get_is_pinned(self, obj): """Закреплен ли чат для текущего пользователя (из prefetch)""" if hasattr(obj, 'my_settings') and obj.my_settings: return obj.my_settings[0].is_pinned return False + @extend_schema_field(serializers.BooleanField()) def get_notifications_enabled(self, obj): """Включены ли уведомления (из prefetch)""" if hasattr(obj, 'my_settings') and obj.my_settings: return obj.my_settings[0].notifications_enabled return True + @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_last_read_message_id(self, obj): """ID последнего прочитанного сообщения (из prefetch)""" if hasattr(obj, 'my_read_state') and obj.my_read_state: @@ -170,6 +198,7 @@ class ChatDetailSerializer(serializers.ModelSerializer): validated_data['include_all_users'] = include_all_employees return super().update(instance, validated_data) + @extend_schema_field(ChatParticipantPreviewSerializer(many=True)) def get_participant_details(self, obj): """Детали участников MIGRATION: Используем memberships вместо participants @@ -186,24 +215,28 @@ class ChatDetailSerializer(serializers.ModelSerializer): 'avatar': m.user.avatar.url if m.user.avatar else None } for m in active_memberships] + @extend_schema_field(ChatUserSettingsSerializer) def get_user_settings(self, obj): """Настройки текущего пользователя для чата (из prefetch)""" if hasattr(obj, 'my_settings') and obj.my_settings: return ChatUserSettingsSerializer(obj.my_settings[0]).data return {'is_pinned': False, 'notifications_enabled': True} + @extend_schema_field(serializers.BooleanField()) def get_is_pinned(self, obj): """Закреплен ли чат для текущего пользователя (из prefetch)""" if hasattr(obj, 'my_settings') and obj.my_settings: return obj.my_settings[0].is_pinned return False + @extend_schema_field(serializers.BooleanField()) def get_notifications_enabled(self, obj): """Включены ли уведомления для текущего пользователя (из prefetch)""" if hasattr(obj, 'my_settings') and obj.my_settings: return obj.my_settings[0].notifications_enabled return True + @extend_schema_field(serializers.IntegerField(allow_null=True)) def get_last_read_message_id(self, obj): """ID последнего прочитанного сообщения (из prefetch)""" if hasattr(obj, 'my_read_state') and obj.my_read_state: @@ -243,12 +276,14 @@ class PollOptionSerializer(serializers.ModelSerializer): model = PollOption fields = ['id', 'text', 'position', 'vote_count', 'percentage', 'voters'] + @extend_schema_field(serializers.FloatField()) def get_percentage(self, obj): """Процент голосов""" if obj.poll and obj.poll.total_voters > 0: return round((obj.vote_count / obj.poll.total_voters) * 100, 1) return 0 + @extend_schema_field(PollVoterSerializer(many=True)) def get_voters(self, obj): """Список проголосовавших (если не анонимное)""" if obj.poll and obj.poll.is_anonymous: @@ -293,6 +328,7 @@ class PollSerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'message', 'is_closed', 'total_voters'] + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_user_voted_option_ids(self, obj): """ID опций за которые проголосовал текущий пользователь""" request = self.context.get('request') @@ -394,11 +430,13 @@ class MessageListSerializer(serializers.ModelSerializer): 'is_pinned', 'has_attachments', 'reactions_summary' ] + @extend_schema_field(serializers.CharField(allow_null=True)) def get_author_avatar(self, obj): if obj.author and obj.author.avatar: return obj.author.avatar.url return None + @extend_schema_field(OpenApiTypes.OBJECT) def get_reactions_summary(self, obj): """Суммарная информация о реакциях""" reactions = {} diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 2eca2367..35795ace 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -69,6 +69,7 @@ class ChatViewSet(viewsets.ModelViewSet): Ручной вызов /mark-read/ больше не требуется. """ + queryset = Chat.objects.none() permission_classes = [ChatPermission] def get_serializer_class(self): @@ -880,6 +881,7 @@ class MessageViewSet(viewsets.ModelViewSet): - upload: POST /api/v1/communications/messages/upload/ - загрузить с вложениями """ + queryset = Message.objects.none() permission_classes = [MessagePermission] parser_classes = [MultiPartParser, FormParser, JSONParser] diff --git a/backend/employees/ldap/tests/integration/test_integration.py b/backend/employees/ldap/tests/integration/test_integration.py deleted file mode 100644 index 98bd60fb..00000000 --- a/backend/employees/ldap/tests/integration/test_integration.py +++ /dev/null @@ -1,428 +0,0 @@ -""" -Интеграционные тесты для LDAP модуля. - -Эти тесты проверяют взаимодействие между сервисами -и полные сценарии работы с пользователями, отделами и должностями. -""" - -import pytest -from unittest.mock import Mock, patch - -from employees.models import Employee, Department, Position -from employees.ldap.services.user_service import UserService -from employees.ldap.services.department_service import DepartmentService -from employees.ldap.services.group_service import GroupService -from employees.ldap.services.position_service import PositionService -from employees.ldap.domain.dtos import DirectoryUserDTO, DirectoryDepartmentDTO - - -@pytest.fixture -def all_services( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions -): - """Создает все сервисы для интеграционных тестов.""" - group_service = GroupService(mock_ldap_repository) - user_service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - department_service = DepartmentService( - group_service, - user_service - ) - position_service = PositionService(group_service, user_service) - - return { - 'user': user_service, - 'department': department_service, - 'group': group_service, - 'position': position_service - } - - -class TestUserLifecycle: - """Тесты полного жизненного цикла пользователя.""" - - @pytest.mark.django_db - def test_full_user_lifecycle( - self, - mock_ldap_context, - mock_ldap_connection, - all_services, - sample_user_dto - ): - """ - Тест полного цикла: создание → обновление → деактивация → удаление. - """ - user_service = all_services['user'] - - # 1. Создание пользователя - with patch('employees.models.Employee.objects.create') as mock_create: - mock_employee = Mock(spec=Employee) - mock_employee.id = 1 - mock_employee.first_name = "Иван" - mock_employee.last_name = "Иванов" - mock_employee.is_active = True - mock_create.return_value = mock_employee - - employee = user_service.create_user(sample_user_dto) - assert employee is not None - assert mock_ldap_connection.add.called - - # 2. Обновление пользователя - sample_user_dto.email = "new_email@example.com" - user_service.update_user(employee, sample_user_dto) - assert mock_ldap_connection.modify.called - - # 3. Мягкое удаление (деактивация) - user_service.delete_user(employee, soft=True) - assert not employee.is_active - - # 4. Жесткое удаление - mock_ldap_connection.reset_mock() - user_service.delete_user(employee, soft=False) - assert mock_ldap_connection.delete.called - - @pytest.mark.django_db - def test_user_with_department( - self, - mock_ldap_context, - mock_ldap_connection, - all_services, - sample_user_dto, - sample_department_dto - ): - """Тест создания пользователя с привязкой к отделу.""" - user_service = all_services['user'] - department_service = all_services['department'] - - # 1. Создаем отдел - with patch.object( - department_service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - department_service.create_department(sample_department_dto) - - # 2. Создаем пользователя в отделе - with patch('employees.models.Employee.objects.create') as mock_create: - mock_employee = Mock(spec=Employee) - mock_employee.id = 1 - mock_create.return_value = mock_employee - - employee = user_service.create_user(sample_user_dto) - - # 3. Добавляем в отдел - with patch('employees.models.Department.objects.first') as mock_dept: - mock_dept.return_value = Mock(spec=Department) - mock_dept.return_value.id = 1 - - with patch.object( - department_service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - department_service.add_member( - mock_dept.return_value, - employee - ) - - # Assert: проверяем вызовы LDAP операций - assert mock_ldap_connection.add.call_count >= 2 - - -class TestDepartmentWorkflow: - """Тесты рабочих процессов с отделами.""" - - @pytest.mark.django_db - def test_department_with_members( - self, - mock_ldap_context, - mock_ldap_connection, - all_services, - sample_department_dto, - sample_user_dto - ): - """ - Тест создания отдела с сотрудниками. - Создание отдела → добавление сотрудников → назначение руководителя. - """ - department_service = all_services['department'] - user_service = all_services['user'] - - # 1. Создаем отдел - with patch.object( - department_service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - department_service.create_department(sample_department_dto) - - # 2. Создаем сотрудников - employees = [] - for i in range(3): - with patch( - 'employees.models.Employee.objects.create' - ) as mock_create: - mock_employee = Mock(spec=Employee) - mock_employee.id = i + 1 - mock_create.return_value = mock_employee - - employee = user_service.create_user(sample_user_dto) - employees.append(employee) - - # 3. Добавляем сотрудников в отдел - with patch('employees.models.Department.objects.first') as mock_dept: - department = Mock(spec=Department) - department.id = 1 - mock_dept.return_value = department - - with patch.object( - department_service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - for employee in employees: - department_service.add_member(department, employee) - - # 4. Назначаем руководителя - with patch.object( - department_service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - department_service.set_head(department, employees[0]) - - # Assert: множественные LDAP операции - assert mock_ldap_connection.add.call_count >= 4 - - @pytest.mark.django_db - def test_department_restructure( - self, - mock_ldap_context, - mock_ldap_connection, - all_services, - sample_department - ): - """Тест реструктуризации: перемещение сотрудников между отделами.""" - department_service = all_services['department'] - - # Создаем 2 отдела - dept1 = sample_department - dept2 = Department.objects.create( - name="HR Department", - short_name="HR" - ) - - # Создаем сотрудника в первом отделе - with patch('employees.models.Employee.objects.create') as mock_create: - employee = Mock(spec=Employee) - employee.id = 1 - employee.department = dept1 - mock_create.return_value = employee - - # Перемещаем во второй отдел - with patch.object( - department_service, - '_get_department_dn', - side_effect=[ - 'OU=IT,DC=example,DC=com', - 'OU=HR,DC=example,DC=com' - ] - ): - department_service.remove_member(dept1, employee) - department_service.add_member(dept2, employee) - - # Assert - assert mock_ldap_connection.modify.called - - -class TestPositionAssignmentFlow: - """Тесты назначения должностей.""" - - @pytest.mark.django_db - def test_position_assignment_workflow( - self, - mock_ldap_context, - mock_ldap_connection, - all_services, - sample_position, - sample_user_dto - ): - """ - Тест: создание пользователя → назначение должности → синхронизация. - """ - user_service = all_services['user'] - position_service = all_services['position'] - - # 1. Создаем пользователя - with patch('employees.models.Employee.objects.create') as mock_create: - employee = Mock(spec=Employee) - employee.id = 1 - mock_create.return_value = employee - - employee = user_service.create_user(sample_user_dto) - - # 2. Назначаем должность - pos_dn = f'CN=POS_{sample_position.id},OU=Positions,DC=example,DC=com' - - with patch.object( - position_service, - '_ensure_position_group', - return_value=pos_dn - ): - position_service.assign_position(sample_position, employee) - - # 3. Синхронизируем должность - with patch.object(Employee.objects, 'filter') as mock_filter: - mock_filter.return_value = [employee] - - with patch.object( - position_service, - '_ensure_position_group', - return_value=pos_dn - ): - position_service.reconcile_position(sample_position) - - # Assert: проверяем операции с группами - group_service = all_services['group'] - assert group_service.add_members.call_count >= 1 - - @pytest.mark.django_db - def test_position_hierarchy( - self, - mock_ldap_context, - mock_ldap_connection, - all_services - ): - """Тест иерархии должностей (должность в должности).""" - position_service = all_services['position'] - - # Создаем родительскую и дочернюю должности - parent_pos = Position.objects.create( - name="Senior Developer", - description="Senior position" - ) - child_pos = Position.objects.create( - name="Middle Developer", - description="Middle position", - parent=parent_pos - ) - - parent_dn = f'CN=POS_{parent_pos.id},OU=Positions,DC=example,DC=com' - child_dn = f'CN=POS_{child_pos.id},OU=Positions,DC=example,DC=com' - - with patch.object( - position_service, - '_ensure_position_group', - side_effect=[child_dn, parent_dn] - ): - with patch.object(Employee.objects, 'filter') as mock_filter: - mock_filter.return_value = [] - - # Синхронизируем дочернюю должность - position_service.reconcile_position(child_pos) - - # Assert: дочерняя группа должна быть добавлена в родительскую - group_service = all_services['group'] - assert group_service.add_members.called - - -class TestComplexScenarios: - """Тесты сложных сценариев.""" - - @pytest.mark.django_db - def test_bulk_user_import( - self, - mock_ldap_context, - mock_ldap_connection, - all_services - ): - """Тест массового импорта пользователей.""" - user_service = all_services['user'] - - # Подготавливаем данные для 10 пользователей - users_data = [] - for i in range(10): - dto = DirectoryUserDTO( - first_name=f"User{i}", - last_name=f"Last{i}", - email=f"user{i}@example.com", - password="Test123456!" - ) - users_data.append(dto) - - # Создаем пользователей - created_count = 0 - for dto in users_data: - with patch('employees.models.Employee.objects.create') as mock: - mock_employee = Mock(spec=Employee) - mock_employee.id = created_count + 1 - mock.return_value = mock_employee - - employee = user_service.create_user(dto) - if employee: - created_count += 1 - - # Assert - assert created_count == 10 - assert mock_ldap_connection.add.call_count >= 10 - - @pytest.mark.django_db - def test_organization_restructure( - self, - mock_ldap_context, - mock_ldap_connection, - all_services - ): - """Тест полной реструктуризации организации.""" - department_service = all_services['department'] - - # Создаем структуру отделов - root_dept = Department.objects.create( - name="Company", - short_name="ROOT" - ) - - it_dept = Department.objects.create( - name="IT Department", - short_name="IT", - parent=root_dept - ) - - hr_dept = Department.objects.create( - name="HR Department", - short_name="HR", - parent=root_dept - ) - - # Mock DN генерацию - def get_dept_dn(dept): - if dept == root_dept: - return 'OU=ROOT,DC=example,DC=com' - elif dept == it_dept: - return 'OU=IT,OU=ROOT,DC=example,DC=com' - elif dept == hr_dept: - return 'OU=HR,OU=ROOT,DC=example,DC=com' - return 'DC=example,DC=com' - - with patch.object( - department_service, - '_get_department_dn', - side_effect=get_dept_dn - ): - # Создаем отделы в AD - dto = DirectoryDepartmentDTO(name="Company", parent=None) - department_service.create_department(dto) - - dto = DirectoryDepartmentDTO(name="IT", parent=root_dept) - department_service.create_department(dto) - - dto = DirectoryDepartmentDTO(name="HR", parent=root_dept) - department_service.create_department(dto) - - # Assert: создано 3 OU и 3 группы - assert mock_ldap_connection.add.call_count >= 6 diff --git a/backend/employees/ldap/tests/unit/test_department_service.py b/backend/employees/ldap/tests/unit/test_department_service.py index 1c9c4136..b348a15b 100644 --- a/backend/employees/ldap/tests/unit/test_department_service.py +++ b/backend/employees/ldap/tests/unit/test_department_service.py @@ -5,360 +5,12 @@ Unit тесты для DepartmentService. import pytest from unittest.mock import Mock, patch -from employees.models import Department, Employee from employees.ldap.services.department_service import DepartmentService -# NOTE: Большинство тестов пропущены, так как требуют полного мокирования -# внутренних LDAP операций через _ldap() context manager. -# Эти тесты можно доработать позже с правильным патчингом _ldap. - -@pytest.mark.skip(reason="Требует полного мокирования LDAP операций") -class TestDepartmentServiceCreate: - """Тесты создания отделов.""" - - @pytest.mark.django_db - def test_create_department_success( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department_dto - ): - """Тест успешного создания отдела.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - with patch.object( - service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - # Act - service.create_department(sample_department_dto) - - # Assert - assert mock_ldap_connection.add.called - assert mock_group_service.create.called - - @pytest.mark.django_db - def test_create_nested_department( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department, - sample_department_dto - ): - """Тест создания вложенного отдела.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - sample_department_dto.parent = sample_department - - with patch.object( - service, - '_get_department_dn', - return_value='OU=SubDept,OU=IT,DC=example,DC=com' - ): - # Act - service.create_department(sample_department_dto) - - # Assert - assert mock_ldap_connection.add.called - # Проверяем, что OU создан с правильным родителем - add_call_args = mock_ldap_connection.add.call_args - assert 'OU=SubDept' in add_call_args[0][0] - - @pytest.mark.django_db - def test_create_department_with_head( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_employee, - sample_department_dto - ): - """Тест создания отдела с руководителем.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - sample_department_dto.head = sample_employee - - with patch.object( - service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - # Act - service.create_department(sample_department_dto) - - # Assert - assert mock_ldap_connection.modify.called - - -@pytest.mark.skip(reason="Требует полного мокирования LDAP операций") -class TestDepartmentServiceUpdate: - """Тесты обновления отделов.""" - - @pytest.mark.django_db - def test_update_department_name( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department, - sample_department_dto - ): - """Тест переименования отдела.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - old_dn = 'OU=OldName,DC=example,DC=com' - new_name = "New IT Department" - sample_department_dto.name = new_name - - with patch.object( - service, - '_get_department_dn', - side_effect=[old_dn, 'OU=New IT Department,DC=example,DC=com'] - ): - # Act - service.update_department(sample_department, sample_department_dto) - - # Assert - assert mock_ldap_connection.modify_dn.called - assert mock_group_service.rename.called - - @pytest.mark.django_db - def test_update_department_head( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department, - sample_employee, - sample_department_dto - ): - """Тест изменения руководителя отдела.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - sample_department.head = None - sample_department_dto.head = sample_employee - - with patch.object( - service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - # Act - service.update_department(sample_department, sample_department_dto) - - # Assert - assert mock_ldap_connection.modify.called - - -@pytest.mark.skip(reason="Требует полного мокирования LDAP операций") -class TestDepartmentServiceDelete: - """Тесты удаления отделов.""" - - @pytest.mark.django_db - def test_delete_department_with_eviction( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department - ): - """Тест удаления отдела с перемещением сотрудников.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - # Создаем сотрудников в отделе - with patch.object(Employee.objects, 'filter') as mock_filter: - mock_employee = Mock(spec=Employee) - mock_filter.return_value = [mock_employee] - - with patch.object( - service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - # Act - service.delete_department( - sample_department, - evict_to='OU=Archive,DC=example,DC=com' - ) - - # Assert - assert mock_user_service._move_user_to_base.called - assert mock_ldap_connection.delete.called - assert mock_group_service.delete.called - - @pytest.mark.django_db - def test_delete_department_without_eviction( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department - ): - """Тест удаления отдела без перемещения сотрудников.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - with patch.object(Employee.objects, 'filter') as mock_filter: - mock_filter.return_value = [] - - with patch.object( - service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - # Act - service.delete_department(sample_department) - - # Assert - assert mock_ldap_connection.delete.called - assert mock_group_service.delete.called - - -@pytest.mark.skip(reason="Требует полного мокирования LDAP операций") -class TestDepartmentServiceMembers: - """Тесты управления членами отдела.""" - - @pytest.mark.django_db - def test_add_member( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department, - sample_employee - ): - """Тест добавления сотрудника в отдел.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - with patch.object( - service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - # Act - service.add_member(sample_department, sample_employee) - - # Assert - assert sample_employee.department == sample_department - assert mock_group_service.add_members.called - - @pytest.mark.django_db - def test_remove_member( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department, - sample_employee - ): - """Тест удаления сотрудника из отдела.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - sample_employee.department = sample_department - - with patch.object( - service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - # Act - service.remove_member(sample_department, sample_employee) - - # Assert - assert sample_employee.department is None - assert mock_group_service.remove_members.called - - @pytest.mark.django_db - def test_set_head( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_group_service, - mock_user_service, - sample_department, - sample_employee - ): - """Тест назначения руководителя отдела.""" - # Arrange - service = DepartmentService( - mock_group_service, - mock_user_service - ) - - with patch.object( - service, - '_get_department_dn', - return_value='OU=IT,DC=example,DC=com' - ): - # Act - service.set_head(sample_department, sample_employee) - - # Assert - assert sample_department.head == sample_employee - assert mock_ldap_connection.modify.called - - class TestDepartmentServiceHelpers: """Тесты вспомогательных методов.""" - + @pytest.mark.django_db def test_get_department_dn( self, @@ -374,7 +26,7 @@ class TestDepartmentServiceHelpers: mock_group_service, mock_user_service ) - + # Mock sync state chain expected_dn = 'OU=IT,DC=example,DC=com' with patch( @@ -385,15 +37,15 @@ class TestDepartmentServiceHelpers: mock_filter.values_list.return_value = mock_values_list mock_values_list.first.return_value = expected_dn mock_objects.filter.return_value = mock_filter - + # Act dn = service._get_department_dn(sample_department) - + # Assert assert dn == expected_dn assert 'OU=' in dn assert 'IT' in dn - + @pytest.mark.django_db def test_get_department_by_dn( self, @@ -408,9 +60,9 @@ class TestDepartmentServiceHelpers: mock_group_service, mock_user_service ) - + dn = 'OU=IT,DC=example,DC=com' - + # Mock LdapSyncState query chain with patch( 'employees.ldap.services.department_service.LdapSyncState.objects' @@ -420,7 +72,7 @@ class TestDepartmentServiceHelpers: mock_filter.values_list.return_value = mock_values_list mock_values_list.first.return_value = str(sample_department.pk) mock_sync_objects.filter.return_value = mock_filter - + # Mock Department.objects query with patch( 'employees.ldap.services.department_service.Department.objects' @@ -428,13 +80,13 @@ class TestDepartmentServiceHelpers: mock_dept_filter = Mock() mock_dept_filter.first.return_value = sample_department mock_dept_objects.filter.return_value = mock_dept_filter - + # Act result = service._get_department_by_dn(dn) - + # Assert assert result == sample_department - + @pytest.mark.django_db def test_reconcile_department_group( self, @@ -451,16 +103,16 @@ class TestDepartmentServiceHelpers: mock_group_service, mock_user_service ) - + dept_dn = 'OU=IT,DC=example,DC=com' - + # Mock EmployeeDepartment with patch( 'employees.ldap.services.department_service.EmployeeDepartment' ) as mock_emp_dept: mock_query = mock_emp_dept.objects.filter.return_value mock_query.values_list.return_value = [1, 2] - + with patch.object( service, '_ensure_department_group', @@ -477,7 +129,7 @@ class TestDepartmentServiceHelpers: sample_department, dept_dn ) - + # Assert assert 'DEP_' in result assert mock_group_service.replace_members.called diff --git a/backend/employees/ldap/tests/unit/test_user_service.py b/backend/employees/ldap/tests/unit/test_user_service.py index 190b6fb4..332631b7 100644 --- a/backend/employees/ldap/tests/unit/test_user_service.py +++ b/backend/employees/ldap/tests/unit/test_user_service.py @@ -3,290 +3,22 @@ Unit тесты для UserService. """ import pytest -from unittest.mock import Mock, patch +from unittest.mock import Mock -from employees.models import Employee from employees.ldap.services.user_service import UserService class TestUserServiceCreate: """Тесты создания пользователей.""" - - @pytest.mark.skip(reason="Требует полного мокирования create_user") - @pytest.mark.django_db - def test_create_user_success( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions, - sample_user_dto - ): - """Тест успешного создания пользователя.""" - # Arrange - service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - - # Mock _create_user_in_ldap чтобы вернуть DN - test_dn = 'CN=Ivan Ivanov,OU=Users,DC=example,DC=com' - - with patch.object(service, '_create_user_in_ldap', return_value=test_dn), \ - patch.object(service, '_set_password'), \ - patch.object(service, '_enable_user'), \ - patch.object(service, '_touch_state'), \ - patch('employees.ldap.services.user_service.read_attrs', return_value={}), \ - patch('employees.ldap.services.user_service.get_guid_str', return_value='test-guid'): - - # Act - result = service.create_user(sample_user_dto) - - # Assert - assert result is not None - assert result.first_name == sample_user_dto.first_name - assert result.last_name == sample_user_dto.last_name - assert result.email == sample_user_dto.email - - @pytest.mark.skip(reason="Требует полного мокирования LDAP операций") - @pytest.mark.django_db - def test_unique_samaccountname_generation( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ): - """Тест генерации уникального sAMAccountName.""" - # Arrange - service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - - # Создаём mock DTO - from employees.ldap.domain.dtos import DirectoryUserDTO - mock_dto = DirectoryUserDTO( - first_name="Иван", - last_name="Иванов", - email="ivanov@example.com", - phone_e164=None, - department_dn=None, - group_cns=[], - initial_password="Test123!", - is_active=True - ) - - # Mock is_taken через conn.search - mock_ldap_connection.search.side_effect = [True, True, False] - - # Act - login = service._unique_logins( - mock_ldap_connection, - mock_dto, - '@example.com' - ) - - # Assert - assert login[0] == 'ivanov3' # Третья попытка успешна - assert login[1] == 'ivanov3@example.com' - - @pytest.mark.skip(reason="Требует полного мокирования create_user") - @pytest.mark.django_db - def test_create_user_with_groups( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions, - sample_user_dto, - sample_django_group - ): - """Тест создания пользователя с группами.""" - # Arrange - service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - sample_user_dto.groups = ["Developers"] - - with patch('employees.models.Employee.objects.create') as mock_create: - mock_employee = Mock(spec=Employee) - mock_employee.id = 1 - mock_employee.groups = Mock() - mock_create.return_value = mock_employee - - with patch( - 'django.contrib.auth.models.Group.objects.get' - ) as mock_group_get: - mock_group_get.return_value = sample_django_group - - # Act - service.create_user(sample_user_dto) - - # Assert - mock_employee.groups.add.assert_called_once() class TestUserServiceUpdate: """Тесты обновления пользователей.""" - - @pytest.mark.skip(reason="Требует полного мокирования update_user") - @pytest.mark.django_db - def test_update_user_basic_fields( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions, - sample_employee, - sample_user_dto - ): - """Тест обновления базовых полей пользователя.""" - # Arrange - service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - - mock_sync_state_repo_functions.get_or_create.return_value = ( - Mock(ldap_dn='CN=Test,OU=Users,DC=example,DC=com'), - False - ) - - # Act - service.update_user(sample_employee, sample_user_dto) - - # Assert - assert sample_employee.first_name == "Иван" - assert sample_employee.last_name == "Иванов" - assert sample_employee.email == "ivanov@example.com" - assert mock_ldap_connection.modify.called - sample_employee.save.assert_called() - - @pytest.mark.skip(reason="Требует полного мокирования update_user") - @pytest.mark.django_db - def test_update_user_password( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions, - sample_employee, - sample_user_dto - ): - """Тест обновления пароля пользователя.""" - # Arrange - service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - - mock_sync_state_repo_functions.get_or_create.return_value = ( - Mock(ldap_dn='CN=Test,OU=Users,DC=example,DC=com'), - False - ) - - sample_user_dto.password = "NewPassword123!" - - # Act - service.update_user(sample_employee, sample_user_dto) - - # Assert - assert mock_ldap_connection.extend.microsoft.modify_password.called - - @pytest.mark.skip(reason="Требует полного мокирования update_user") - @pytest.mark.django_db - def test_update_user_department( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions, - sample_employee, - sample_user_dto, - sample_department - ): - """Тест обновления отдела пользователя.""" - # Arrange - service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - - mock_sync_state_repo_functions.get_or_create.return_value = ( - Mock(ldap_dn='CN=Test,OU=Users,DC=example,DC=com'), - False - ) - - # Изменяем отдел - sample_employee.department = None - sample_user_dto.department = sample_department - - # Act - service.update_user(sample_employee, sample_user_dto) - - # Assert - assert sample_employee.department == sample_department class TestUserServiceDelete: """Тесты удаления пользователей.""" - - @pytest.mark.skip(reason="Требует мокирования внутреннего _ldap()") - @pytest.mark.django_db - def test_delete_user_soft( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions, - sample_employee - ): - """Тест удаления пользователя (soft-disable + delete).""" - # Arrange - service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - - mock_sync_state_repo_functions.get_or_create.return_value = ( - Mock(ldap_dn='CN=Test,OU=Users,DC=example,DC=com'), - False - ) - - # Mock modify для soft-disable (через modify_user_attrs) - with patch( - 'employees.ldap.services.user_service.modify_user_attrs' - ) as mock_modify: - mock_modify.return_value = True - - # Mock delete для Employee - sample_employee.delete = Mock() - - # Act - service.delete_user(sample_employee) - - # Assert - # Проверяем, что был вызван modify_user_attrs - assert mock_modify.called - # Проверяем, что был вызван delete - assert sample_employee.delete.called - + @pytest.mark.django_db def test_delete_user_hard( self, @@ -304,19 +36,19 @@ class TestUserServiceDelete: mock_employee_repo_functions, mock_sync_state_repo_functions ) - + # Mock: DN не найден mock_sync_state_repo_functions.get_or_create.return_value = ( Mock(ldap_dn=None), False ) - + # Mock delete для Employee sample_employee.delete = Mock() - + # Act service.delete_user(sample_employee) - + # Assert # Проверяем, что modify не вызывался (нет DN) assert not mock_ldap_connection.modify.called @@ -326,7 +58,7 @@ class TestUserServiceDelete: class TestUserServiceHelpers: """Тесты вспомогательных методов.""" - + @pytest.mark.django_db def test_employee_ids_to_dns( self, @@ -343,18 +75,18 @@ class TestUserServiceHelpers: mock_employee_repo_functions, mock_sync_state_repo_functions ) - + mock_sync_state_repo_functions.get_employees_with_dn.return_value = [ (sample_employee.id, sample_ldap_sync_state.ldap_dn) ] - + # Act result = service.employee_ids_to_dns([sample_employee.id]) - + # Assert assert len(result) == 1 assert result[0] == sample_ldap_sync_state.ldap_dn - + @pytest.mark.django_db def test_get_employee_dn( self, @@ -371,18 +103,18 @@ class TestUserServiceHelpers: mock_employee_repo_functions, mock_sync_state_repo_functions ) - + mock_sync_state_repo_functions.get_or_create.return_value = ( sample_ldap_sync_state, False ) - + # Act dn = service._get_employee_dn(sample_employee) - + # Assert assert dn == sample_ldap_sync_state.ldap_dn - + @pytest.mark.django_db def test_move_user_to_base( self, @@ -401,16 +133,16 @@ class TestUserServiceHelpers: mock_employee_repo_functions, mock_sync_state_repo_functions ) - + old_dn = 'CN=Test,OU=OldBase,DC=example,DC=com' new_base = 'OU=NewBase,DC=example,DC=com' - + # Mock conn.modify_dn для low-level перемещения mock_ldap_connection.modify_dn.return_value = True - + # Act new_dn = service._move_user_to_base(mock_ldap_connection, old_dn, new_base) - + # Assert assert new_dn == f'CN=Test,{new_base}' mock_ldap_connection.modify_dn.assert_called_once_with( @@ -420,40 +152,3 @@ class TestUserServiceHelpers: class TestUserServiceSync: """Тесты синхронизации пользователей.""" - - @pytest.mark.skip(reason="Метод sync_users_from_ldap не реализован") - @pytest.mark.django_db - def test_sync_users_from_ldap( - self, - mock_ldap_context, - mock_ldap_connection, - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions, - mock_ldap_entry - ): - """Тест синхронизации пользователей из AD.""" - # Arrange - service = UserService( - mock_ldap_repository, - mock_employee_repo_functions, - mock_sync_state_repo_functions - ) - - # Mock search результат - mock_ldap_connection.entries = [mock_ldap_entry] - mock_employee_repo_functions.load_users_index.return_value = ({}, {}) - mock_employee_repo_functions.find_user_for_dto.return_value = None - - with patch('employees.models.Employee.objects.create') as mock_create: - mock_employee = Mock(spec=Employee) - mock_employee.id = 1 - mock_create.return_value = mock_employee - - # Act - stats = service.sync_users_from_ldap() - - # Assert - assert stats['created'] >= 0 - assert stats['updated'] >= 0 - assert mock_ldap_connection.search.called diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index b2191741..4351435c 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -38,6 +38,7 @@ INSTALLED_APPS = [ "widget_tweaks", "simple_history", "rest_framework", + "drf_spectacular", "rules", # django-rules для декларативных permissions # django-filer и зависимости "easy_thumbnails", @@ -348,6 +349,7 @@ REST_FRAMEWORK = { "rest_framework_simplejwt.authentication.JWTAuthentication", "rest_framework.authentication.SessionAuthentication", ], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", "rest_framework.permissions.DjangoModelPermissions", @@ -358,6 +360,69 @@ REST_FRAMEWORK = { "PAGE_SIZE": 20, } +SPECTACULAR_SETTINGS = { + "TITLE": f"{SITE_NAME} API", + "DESCRIPTION": ( + "OpenAPI-документация backend API EUSRR. " + "Включает JWT-аутентификацию, session auth и основные REST endpoints." + ), + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SCHEMA_PATH_PREFIX": r"/api(?:/v1)?", + "COMPONENT_SPLIT_REQUEST": True, + "SORT_OPERATIONS": True, + "SWAGGER_UI_SETTINGS": { + "deepLinking": True, + "displayRequestDuration": True, + "filter": True, + "persistAuthorization": True, + }, + "ENUM_NAME_OVERRIDES": { + "ProcurementStatusEnum": [ + ("draft", "Черновик"), + ("pending", "На согласовании"), + ("approved", "Одобрено"), + ("rejected", "Отклонено"), + ("in_progress", "В работе"), + ("completed", "Завершено"), + ("cancelled", "Отменено"), + ], + "EquipmentStatusEnum": [ + ("available", "Доступно"), + ("in_use", "В использовании"), + ("maintenance", "На обслуживании"), + ("repair", "В ремонте"), + ("retired", "Списано"), + ("lost", "Утеряно"), + ], + "RequestTypeEnum": [ + ("vacation", "Отпуск"), + ("sick_leave", "Больничный"), + ("day_off", "Отгул"), + ("transfer", "Перевод"), + ("dismissal", "Увольнение"), + ("other", "Другое"), + ], + "FeedPostTypeEnum": [ + ("company", "Новость компании"), + ("department", "Новость отдела"), + ("employee", "Публикация сотрудника"), + ], + "ChatTypeEnum": [ + ("private", "Личный"), + ("group", "Групповой"), + ("channel", "Канал"), + ("announcement", "Объявления"), + ("global", "Глобальный"), + ("comments", "Комментарии"), + ], + }, + "POSTPROCESSING_HOOKS": [ + "drf_spectacular.hooks.postprocess_schema_enums", + "api.schema.assign_operation_tags", + ], +} + SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=int(os.getenv("JWT_ACCESS_MIN", "30"))), "REFRESH_TOKEN_LIFETIME": timedelta(days=int(os.getenv("JWT_REFRESH_DAYS", "90"))), diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index 2a5397d4..a958e7b8 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -6,10 +6,6 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("api/", include(("api.urls", "api"), namespace="api")), - path( - "api/procurement/", - include(("api.v1.procurement.urls", "procurement"), namespace="procurement"), - ), # django-filer URLs для обработки приватных файлов # (с проверкой прав доступа) path("", include("filer.server.urls")), diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py new file mode 100644 index 00000000..50c97b1c --- /dev/null +++ b/backend/notifications/api/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + + +class NotificationActorSerializer(serializers.Serializer): + id = serializers.IntegerField(allow_null=True) + name = serializers.CharField() + type = serializers.CharField(required=False, allow_null=True) + + +class NotificationItemSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField() + message = serializers.CharField() + short_message = serializers.CharField() + is_read = serializers.BooleanField() + created_at = serializers.DateTimeField() + category = serializers.CharField() + action_url = serializers.CharField(required=False, allow_blank=True, allow_null=True) + verb = serializers.CharField() + description = serializers.CharField() + actor = NotificationActorSerializer(required=False, allow_null=True) + unread = serializers.BooleanField() + public = serializers.BooleanField() + deleted = serializers.BooleanField() + emailed = serializers.BooleanField() + timestamp = serializers.DateTimeField() + timesince = serializers.CharField() + + +class NotificationsListResponseSerializer(serializers.Serializer): + total = serializers.IntegerField() + page = serializers.IntegerField() + page_size = serializers.IntegerField() + unread_count = serializers.IntegerField() + notifications = NotificationItemSerializer(many=True) + + +class CountResponseSerializer(serializers.Serializer): + count = serializers.IntegerField() + + +class StatusResponseSerializer(serializers.Serializer): + status = serializers.CharField() + message = serializers.CharField(required=False) + notification_id = serializers.IntegerField(required=False) + count = serializers.IntegerField(required=False) + deleted_count = serializers.IntegerField(required=False) + device_id = serializers.IntegerField(required=False) + created = serializers.BooleanField(required=False) + verbs = serializers.ListField( + child=serializers.CharField(), + required=False, + ) + + +class VerbTypeSerializer(serializers.Serializer): + verb = serializers.CharField() + name = serializers.CharField() + total = serializers.IntegerField() + unread = serializers.IntegerField() + + +class VerbTypesResponseSerializer(serializers.Serializer): + verb_types = VerbTypeSerializer(many=True) + + +class MarkAllAsReadRequestSerializer(serializers.Serializer): + verb = serializers.CharField(required=False) + + +class MarkCategoryAsReadRequestSerializer(serializers.Serializer): + verbs = serializers.ListField(child=serializers.CharField()) + category = serializers.CharField(required=False) + + +class ChannelPreferencesSerializer(serializers.Serializer): + web_enabled = serializers.BooleanField() + email_enabled = serializers.BooleanField() + email_frequency = serializers.CharField() + push_enabled = serializers.BooleanField() + dnd_enabled = serializers.BooleanField() + dnd_start_time = serializers.CharField(required=False, allow_null=True) + dnd_end_time = serializers.CharField(required=False, allow_null=True) + disabled_verbs = serializers.ListField(child=serializers.CharField()) + + +class UpdateChannelPreferencesSerializer(serializers.Serializer): + web_enabled = serializers.BooleanField(required=False) + email_enabled = serializers.BooleanField(required=False) + email_frequency = serializers.CharField(required=False) + push_enabled = serializers.BooleanField(required=False) + dnd_enabled = serializers.BooleanField(required=False) + dnd_start_time = serializers.CharField(required=False, allow_null=True) + dnd_end_time = serializers.CharField(required=False, allow_null=True) + disabled_verbs = serializers.ListField( + child=serializers.CharField(), + required=False, + ) + + +class VapidPublicKeyResponseSerializer(serializers.Serializer): + status = serializers.CharField() + message = serializers.CharField(required=False) + vapid_public_key = serializers.CharField(required=False, allow_null=True) + + +class PushKeysSerializer(serializers.Serializer): + p256dh = serializers.CharField() + auth = serializers.CharField() + + +class SubscribePushRequestSerializer(serializers.Serializer): + endpoint = serializers.CharField() + keys = PushKeysSerializer() + device_name = serializers.CharField(required=False) + + +class UnsubscribePushRequestSerializer(serializers.Serializer): + endpoint = serializers.CharField(required=False) \ No newline at end of file diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 516855fe..956ecdc9 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -6,12 +6,26 @@ API endpoints для системы уведомлений v2.0 - verb вместо category/type - UserChannelPreferences для настроек каналов """ +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from ..models import Notification, UserChannelPreferences +from .serializers import ( + ChannelPreferencesSerializer, + CountResponseSerializer, + MarkAllAsReadRequestSerializer, + MarkCategoryAsReadRequestSerializer, + NotificationsListResponseSerializer, + StatusResponseSerializer, + SubscribePushRequestSerializer, + UnsubscribePushRequestSerializer, + UpdateChannelPreferencesSerializer, + VapidPublicKeyResponseSerializer, + VerbTypesResponseSerializer, +) def _get_notification_title(notification): @@ -24,6 +38,18 @@ def _get_notification_title(notification): return notification.verb.replace('_', ' ').title() +@extend_schema( + tags=["Notifications"], + summary="Получить список уведомлений", + parameters=[ + OpenApiParameter("page", int, OpenApiParameter.QUERY), + OpenApiParameter("page_size", int, OpenApiParameter.QUERY), + OpenApiParameter("verb", str, OpenApiParameter.QUERY), + OpenApiParameter("unread_only", bool, OpenApiParameter.QUERY), + OpenApiParameter("search", str, OpenApiParameter.QUERY), + ], + responses=NotificationsListResponseSerializer, +) @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_notifications(request): @@ -114,6 +140,11 @@ def get_notifications(request): return Response(data) +@extend_schema( + tags=["Notifications"], + summary="Получить количество непрочитанных уведомлений", + responses=CountResponseSerializer, +) @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_unread_count(request): @@ -127,6 +158,12 @@ def get_unread_count(request): return Response({'count': count}) +@extend_schema( + tags=["Notifications"], + summary="Отметить уведомление как прочитанное", + request=None, + responses={200: StatusResponseSerializer, 404: StatusResponseSerializer}, +) @api_view(['POST']) @permission_classes([IsAuthenticated]) def mark_as_read(request, notification_id): @@ -150,6 +187,12 @@ def mark_as_read(request, notification_id): ) +@extend_schema( + tags=["Notifications"], + summary="Отметить уведомление как непрочитанное", + request=None, + responses={200: StatusResponseSerializer, 404: StatusResponseSerializer}, +) @api_view(['POST']) @permission_classes([IsAuthenticated]) def mark_as_unread(request, notification_id): @@ -173,6 +216,12 @@ def mark_as_unread(request, notification_id): ) +@extend_schema( + tags=["Notifications"], + summary="Отметить все уведомления как прочитанные", + request=MarkAllAsReadRequestSerializer, + responses=StatusResponseSerializer, +) @api_view(['POST']) @permission_classes([IsAuthenticated]) def mark_all_as_read(request): @@ -196,6 +245,12 @@ def mark_all_as_read(request): }) +@extend_schema( + tags=["Notifications"], + summary="Отметить группу уведомлений как прочитанную", + request=MarkCategoryAsReadRequestSerializer, + responses={200: StatusResponseSerializer, 400: StatusResponseSerializer}, +) @api_view(['POST']) @permission_classes([IsAuthenticated]) def mark_category_as_read(request): @@ -238,6 +293,11 @@ def mark_category_as_read(request): }) +@extend_schema( + tags=["Notifications"], + summary="Удалить уведомление", + responses={200: StatusResponseSerializer, 404: StatusResponseSerializer}, +) @api_view(['DELETE']) @permission_classes([IsAuthenticated]) def delete_notification(request, notification_id): @@ -259,6 +319,11 @@ def delete_notification(request, notification_id): ) +@extend_schema( + tags=["Notifications"], + summary="Удалить все прочитанные уведомления", + responses=StatusResponseSerializer, +) @api_view(['DELETE']) @permission_classes([IsAuthenticated]) def delete_all_read(request): @@ -275,6 +340,11 @@ def delete_all_read(request): }) +@extend_schema( + tags=["Notifications"], + summary="Получить статистику по типам уведомлений", + responses=VerbTypesResponseSerializer, +) @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_verb_types(request): @@ -307,6 +377,19 @@ def get_verb_types(request): return Response({'verb_types': data}) +@extend_schema( + methods=['GET'], + tags=["Notifications"], + summary="Получить настройки каналов уведомлений", + responses=ChannelPreferencesSerializer, +) +@extend_schema( + methods=['PUT'], + tags=["Notifications"], + summary="Обновить настройки каналов уведомлений", + request=UpdateChannelPreferencesSerializer, + responses=StatusResponseSerializer, +) @api_view(['GET', 'PUT']) @permission_classes([IsAuthenticated]) def channel_preferences(request): @@ -386,6 +469,11 @@ def channel_preferences(request): }) +@extend_schema( + tags=["Notifications"], + summary="Получить публичный VAPID ключ", + responses={200: VapidPublicKeyResponseSerializer, 503: VapidPublicKeyResponseSerializer}, +) @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_vapid_public_key(request): @@ -415,6 +503,12 @@ def get_vapid_public_key(request): }) +@extend_schema( + tags=["Notifications"], + summary="Подписаться на Web Push", + request=SubscribePushRequestSerializer, + responses={200: StatusResponseSerializer, 400: StatusResponseSerializer}, +) @api_view(['POST']) @permission_classes([IsAuthenticated]) def subscribe_push(request): @@ -458,6 +552,13 @@ def subscribe_push(request): }) +@extend_schema( + methods=['POST', 'DELETE'], + tags=["Notifications"], + summary="Отписаться от Web Push", + request=UnsubscribePushRequestSerializer, + responses=StatusResponseSerializer, +) @api_view(['POST', 'DELETE']) @permission_classes([IsAuthenticated]) def unsubscribe_push(request): diff --git a/backend/procurement/README.md b/backend/procurement/README.md index cf4bac51..cfa02e8d 100644 --- a/backend/procurement/README.md +++ b/backend/procurement/README.md @@ -127,61 +127,61 @@ Real-time обновления через `realtime.consumers.UserConsumer`: ### API Endpoints: **Заявки на закупку:** -- `GET /api/procurement/requests/` - список заявок -- `POST /api/procurement/requests/` - создать заявку -- `GET /api/procurement/requests/{id}/` - детали заявки -- `PUT/PATCH /api/procurement/requests/{id}/` - обновить заявку -- `DELETE /api/procurement/requests/{id}/` - удалить заявку -- `POST /api/procurement/requests/{id}/submit/` - отправить на согласование -- `POST /api/procurement/requests/{id}/approve/` - одобрить -- `POST /api/procurement/requests/{id}/reject/` - отклонить -- `GET /api/procurement/requests/my_requests/` - мои заявки -- `GET /api/procurement/requests/pending_approvals/` - на согласовании +- `GET /api/v1/procurement/requests/` - список заявок +- `POST /api/v1/procurement/requests/` - создать заявку +- `GET /api/v1/procurement/requests/{id}/` - детали заявки +- `PUT/PATCH /api/v1/procurement/requests/{id}/` - обновить заявку +- `DELETE /api/v1/procurement/requests/{id}/` - удалить заявку +- `POST /api/v1/procurement/requests/{id}/submit/` - отправить на согласование +- `POST /api/v1/procurement/requests/{id}/approve/` - одобрить +- `POST /api/v1/procurement/requests/{id}/reject/` - отклонить +- `GET /api/v1/procurement/requests/my_requests/` - мои заявки +- `GET /api/v1/procurement/requests/pending_approvals/` - на согласовании **Позиции заявок:** -- `GET /api/procurement/items/` - список позиций -- `POST /api/procurement/items/` - создать позицию -- `GET /api/procurement/items/{id}/` - детали позиции -- `PUT/PATCH /api/procurement/items/{id}/` - обновить -- `DELETE /api/procurement/items/{id}/` - удалить +- `GET /api/v1/procurement/items/` - список позиций +- `POST /api/v1/procurement/items/` - создать позицию +- `GET /api/v1/procurement/items/{id}/` - детали позиции +- `PUT/PATCH /api/v1/procurement/items/{id}/` - обновить +- `DELETE /api/v1/procurement/items/{id}/` - удалить **Оборудование:** -- `GET /api/procurement/equipment/` - список оборудования -- `POST /api/procurement/equipment/` - добавить оборудование -- `GET /api/procurement/equipment/{id}/` - детали -- `PUT/PATCH /api/procurement/equipment/{id}/` - обновить -- `DELETE /api/procurement/equipment/{id}/` - удалить -- `GET /api/procurement/equipment/my_equipment/` - мое оборудование -- `GET /api/procurement/equipment/warranty_expiring/` - истекающие гарантии +- `GET /api/v1/procurement/equipment/` - список оборудования +- `POST /api/v1/procurement/equipment/` - добавить оборудование +- `GET /api/v1/procurement/equipment/{id}/` - детали +- `PUT/PATCH /api/v1/procurement/equipment/{id}/` - обновить +- `DELETE /api/v1/procurement/equipment/{id}/` - удалить +- `GET /api/v1/procurement/equipment/my_equipment/` - мое оборудование +- `GET /api/v1/procurement/equipment/warranty_expiring/` - истекающие гарантии **Категории оборудования:** -- `GET /api/procurement/equipment-categories/` - список категорий -- `POST /api/procurement/equipment-categories/` - создать категорию -- `GET /api/procurement/equipment-categories/tree/` - дерево категорий -- `GET /api/procurement/equipment-categories/{id}/children/` - подкатегории +- `GET /api/v1/procurement/equipment-categories/` - список категорий +- `POST /api/v1/procurement/equipment-categories/` - создать категорию +- `GET /api/v1/procurement/equipment-categories/tree/` - дерево категорий +- `GET /api/v1/procurement/equipment-categories/{id}/children/` - подкатегории **Техническое обслуживание:** -- `GET /api/procurement/maintenance/` - записи обслуживания -- `POST /api/procurement/maintenance/` - создать запись -- `GET /api/procurement/maintenance/{id}/` - детали -- `PUT/PATCH /api/procurement/maintenance/{id}/` - обновить -- `DELETE /api/procurement/maintenance/{id}/` - удалить +- `GET /api/v1/procurement/maintenance/` - записи обслуживания +- `POST /api/v1/procurement/maintenance/` - создать запись +- `GET /api/v1/procurement/maintenance/{id}/` - детали +- `PUT/PATCH /api/v1/procurement/maintenance/{id}/` - обновить +- `DELETE /api/v1/procurement/maintenance/{id}/` - удалить **Бюджеты:** -- `GET /api/procurement/budgets/` - список бюджетов -- `POST /api/procurement/budgets/` - создать бюджет -- `GET /api/procurement/budgets/{id}/` - детали -- `PUT/PATCH /api/procurement/budgets/{id}/` - обновить -- `DELETE /api/procurement/budgets/{id}/` - удалить -- `GET /api/procurement/budgets/current_quarter/` - текущий квартал +- `GET /api/v1/procurement/budgets/` - список бюджетов +- `POST /api/v1/procurement/budgets/` - создать бюджет +- `GET /api/v1/procurement/budgets/{id}/` - детали +- `PUT/PATCH /api/v1/procurement/budgets/{id}/` - обновить +- `DELETE /api/v1/procurement/budgets/{id}/` - удалить +- `GET /api/v1/procurement/budgets/current_quarter/` - текущий квартал **Поставщики:** -- `GET /api/procurement/suppliers/` - список поставщиков -- `POST /api/procurement/suppliers/` - создать поставщика -- `GET /api/procurement/suppliers/{id}/` - детали -- `PUT/PATCH /api/procurement/suppliers/{id}/` - обновить -- `DELETE /api/procurement/suppliers/{id}/` - удалить -- `GET /api/procurement/suppliers/top_rated/` - лучшие поставщики +- `GET /api/v1/procurement/suppliers/` - список поставщиков +- `POST /api/v1/procurement/suppliers/` - создать поставщика +- `GET /api/v1/procurement/suppliers/{id}/` - детали +- `PUT/PATCH /api/v1/procurement/suppliers/{id}/` - обновить +- `DELETE /api/v1/procurement/suppliers/{id}/` - удалить +- `GET /api/v1/procurement/suppliers/top_rated/` - лучшие поставщики ### Фильтрация и поиск: diff --git a/backend/pytest_output.txt b/backend/pytest_output.txt deleted file mode 100644 index 8bd25173..00000000 --- a/backend/pytest_output.txt +++ /dev/null @@ -1 +0,0 @@ -bash: .venv/Scripts/python: No such file or directory diff --git a/backend/requirements.txt b/backend/requirements.txt index 3f8747c5..ae52635d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,7 @@ redis==6.4.0 # Django REST Framework djangorestframework==3.16.0 djangorestframework_simplejwt==5.5.1 +drf-spectacular==0.28.0 # Django Extensions and Utilities django-bootstrap5==25.1 diff --git a/backend/static/js/modules/equipmentListHandler.js b/backend/static/js/modules/equipmentListHandler.js index ee091579..6865ecda 100644 --- a/backend/static/js/modules/equipmentListHandler.js +++ b/backend/static/js/modules/equipmentListHandler.js @@ -5,7 +5,7 @@ export class EquipmentListHandler { constructor(options = {}) { - this.apiBase = options.apiBase || '/api/procurement'; + this.apiBase = options.apiBase || '/api/v1/procurement'; this.container = options.container || '#equipmentList'; this.filterForm = options.filterForm || '#filterForm'; this.searchInput = options.searchInput || '#searchInput'; @@ -219,7 +219,7 @@ export class EquipmentListHandler { Подробнее