Add backend OpenAPI docs and clean test suite
This commit is contained in:
parent
7a4ed36b6e
commit
3f59f8ebb9
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
95
backend/api/schema.py
Normal file
95
backend/api/schema.py
Normal file
@ -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
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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 []
|
||||
|
||||
@ -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):
|
||||
# Логируем входящие данные для диагностики
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
"""Статистика по отделам."""
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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<comment_id>[^/.]+)")
|
||||
def delete_comment(self, request: DRFRequest, pk: int | str | None = None, comment_id: int | str | None = None) -> Response:
|
||||
"""Удаление комментария.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))),
|
||||
|
||||
@ -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")),
|
||||
|
||||
119
backend/notifications/api/serializers.py
Normal file
119
backend/notifications/api/serializers.py
Normal file
@ -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)
|
||||
@ -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):
|
||||
|
||||
@ -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/` - лучшие поставщики
|
||||
|
||||
### Фильтрация и поиск:
|
||||
|
||||
|
||||
@ -1 +0,0 @@
|
||||
bash: .venv/Scripts/python: No such file or directory
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
<i class="bi-eye me-1"></i>Подробнее
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-qr-url="/api/procurement/equipment/${eq.id}/qr_code/"
|
||||
data-qr-url="/api/v1/procurement/equipment/${eq.id}/qr_code/"
|
||||
data-qr-name="${this.escapeHtml(eq.name)}"
|
||||
title="QR-код">
|
||||
<i class="bi-qr-code"></i>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
export class ProcurementDashboardHandler {
|
||||
constructor(options = {}) {
|
||||
this.apiBase = options.apiBase || '/api/procurement';
|
||||
this.apiBase = options.apiBase || '/api/v1/procurement';
|
||||
this.statsContainer = options.statsContainer || '#procDashboardStats';
|
||||
this.recentRequestsContainer = options.recentRequestsContainer || '#recentRequests';
|
||||
this.recentEquipmentContainer = options.recentEquipmentContainer || '#recentEquipment';
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
export class ProcurementRequestListHandler {
|
||||
constructor(options = {}) {
|
||||
this.apiBase = options.apiBase || '/api/procurement';
|
||||
this.apiBase = options.apiBase || '/api/v1/procurement';
|
||||
this.container = options.container || '#requestsList';
|
||||
this.filterForm = options.filterForm || '#filterForm';
|
||||
this.searchInput = options.searchInput || '#searchInput';
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Тест Фазы 3: API и WebSocket consumers
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
|
||||
django.setup()
|
||||
|
||||
from communications.models import Chat
|
||||
from employees.models import Department
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
print("=" * 80)
|
||||
print("ФАЗА 3: ТЕСТИРОВАНИЕ API И CONSUMERS")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. Тест API serializers
|
||||
print("\n1. Тест API serializers:")
|
||||
from communications.api.serializers import ChatListSerializer, ChatDetailSerializer
|
||||
|
||||
chat = Chat.objects.first()
|
||||
if chat:
|
||||
# ChatListSerializer
|
||||
list_data = ChatListSerializer(chat).data
|
||||
print(f" ✓ ChatListSerializer работает")
|
||||
print(f" - Новые поля в ответе:")
|
||||
print(f" context_object_id: {list_data.get('context_object_id')}")
|
||||
print(f" context_type: {list_data.get('context_type')}")
|
||||
print(f" flags: {list_data.get('flags')}")
|
||||
print(f" extra_data: {list_data.get('extra_data')}")
|
||||
print(f" include_all_users: {list_data.get('include_all_users')}")
|
||||
print(f" - Старые поля (DEPRECATED) тоже есть:")
|
||||
print(f" is_main: {list_data.get('is_main')}")
|
||||
print(f" department: {list_data.get('department')}")
|
||||
|
||||
# ChatDetailSerializer
|
||||
detail_data = ChatDetailSerializer(chat).data
|
||||
print(f"\n ✓ ChatDetailSerializer работает")
|
||||
print(f" - context_app: {detail_data.get('context_app')}")
|
||||
|
||||
# 2. Тест WebSocket consumers (has_user_access логика)
|
||||
print("\n2. Тест WebSocket consumers:")
|
||||
|
||||
user = Employee.objects.filter(is_active=True).first()
|
||||
dept_chat = Chat.objects.filter(type='department').first()
|
||||
|
||||
if user and dept_chat:
|
||||
# Тестируем логику из has_user_access (без асинхронности)
|
||||
from communications.models import ChatMembership
|
||||
chat = dept_chat
|
||||
|
||||
# Логика из consumers.has_user_access
|
||||
has_access = False
|
||||
if chat.type == "department":
|
||||
if chat.include_all_users:
|
||||
has_access = True
|
||||
else:
|
||||
has_access = chat.get_participants().filter(pk=user.pk).exists()
|
||||
elif chat.type == "direct":
|
||||
has_access = chat.get_participants().filter(pk=user.pk).exists()
|
||||
|
||||
print(f" ✓ has_user_access() логика для department chat: {has_access}")
|
||||
print(f" User: {user}")
|
||||
print(f" Chat: {dept_chat}")
|
||||
print(f" include_all_users: {chat.include_all_users}")
|
||||
|
||||
# 3. Проверка фильтров в API views
|
||||
print("\n3. Тест фильтров API views:")
|
||||
from django.db.models import Q
|
||||
|
||||
user2 = Employee.objects.filter(is_active=True).first()
|
||||
if user2:
|
||||
# Проверяем фильтр как в ChatViewSet
|
||||
dept_ids = list(user2.departments_links.filter(
|
||||
is_active=True
|
||||
).values_list('department_id', flat=True))
|
||||
|
||||
print(f" User departments: {len(dept_ids)}")
|
||||
|
||||
dept_ct = None
|
||||
if dept_ids:
|
||||
dept_ct = ContentType.objects.get_for_model(Department)
|
||||
|
||||
chats = Chat.objects.filter(
|
||||
Q(participants=user2)
|
||||
| Q(department__in=dept_ids) # OLD
|
||||
| (Q(context_content_type=dept_ct, context_object_id__in=dept_ids) if dept_ct else Q(pk__in=[])) # NEW
|
||||
| Q(include_all_users=True) # NEW
|
||||
).distinct()
|
||||
|
||||
print(f" ✓ Найдено доступных чатов: {chats.count()}")
|
||||
|
||||
# 4. Проверка что старые поля еще работают
|
||||
print("\n4. Тест обратной совместимости:")
|
||||
old_dept_chats = Chat.objects.filter(department__isnull=False).count()
|
||||
new_context_chats = Chat.objects.filter(context_object_id__isnull=False).count()
|
||||
print(f" Чатов со старым полем department: {old_dept_chats}")
|
||||
print(f" Чатов с новым полем context_object: {new_context_chats}")
|
||||
|
||||
old_main_chats = Chat.objects.filter(is_main=True).count()
|
||||
new_flags_chats = Chat.objects.filter(flags__is_primary=True).count()
|
||||
print(f" Чатов со старым полем is_main: {old_main_chats}")
|
||||
print(f" Чатов с новым полем flags['is_primary']: {new_flags_chats}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ ВСЕ ТЕСТЫ ФАЗЫ 3 ЗАВЕРШЕНЫ")
|
||||
print("=" * 80)
|
||||
53
backend/tests/api/test_documentation.py
Normal file
53
backend/tests/api/test_documentation.py
Normal file
@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client() -> APIClient:
|
||||
return APIClient()
|
||||
|
||||
|
||||
def test_openapi_schema_is_available(api_client: APIClient):
|
||||
response = api_client.get(
|
||||
reverse("api:schema"),
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = response.json()
|
||||
assert payload["openapi"].startswith("3.")
|
||||
assert "/api/auth/token/" in payload["paths"]
|
||||
assert "/api/v1/auth/register/" in payload["paths"]
|
||||
assert "/api/v1/documents/" in payload["paths"]
|
||||
assert "/api/v1/notifications/" in payload["paths"]
|
||||
assert "/api/v1/procurement/stats/overview/" in payload["paths"]
|
||||
assert "/api/v1/requests/" in payload["paths"]
|
||||
assert "/api/v1/search/" in payload["paths"]
|
||||
assert "/api/procurement/requests/" not in payload["paths"]
|
||||
|
||||
tag_names = {tag["name"] for tag in payload.get("tags", [])}
|
||||
assert {
|
||||
"Auth",
|
||||
"Documents",
|
||||
"Notifications",
|
||||
"Procurement",
|
||||
"Requests",
|
||||
"Search",
|
||||
}.issubset(tag_names)
|
||||
|
||||
|
||||
def test_swagger_and_redoc_pages_are_available(api_client: APIClient):
|
||||
swagger_response = api_client.get(reverse("api:swagger-ui"))
|
||||
redoc_response = api_client.get(reverse("api:redoc"))
|
||||
|
||||
assert swagger_response.status_code == 200
|
||||
assert redoc_response.status_code == 200
|
||||
|
||||
assert "swagger-ui" in swagger_response.content.decode().lower()
|
||||
assert "redoc" in redoc_response.content.decode().lower()
|
||||
@ -158,45 +158,6 @@ class TestEmailChangeResetsVerification:
|
||||
class TestUnverifiedUserRestrictions:
|
||||
"""Тесты ограничений для неверифицированных пользователей."""
|
||||
|
||||
@pytest.mark.skip(reason="Permission classes not yet implemented")
|
||||
def test_unverified_user_cannot_create_department(self):
|
||||
"""Неверифицированный пользователь не может создавать отделы."""
|
||||
client = APIClient()
|
||||
user = make_user("unverified@test.com", verified=False)
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/departments/",
|
||||
{"name": "Test Department"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert "email" in response.data.get("detail", "").lower() or \
|
||||
"верифи" in response.data.get("detail", "").lower()
|
||||
|
||||
@pytest.mark.skip(reason="Permission classes not yet implemented")
|
||||
def test_unverified_user_cannot_update_other_employee(self):
|
||||
"""Неверифицированный staff не может редактировать других."""
|
||||
client = APIClient()
|
||||
unverified_staff = make_user("unverified@test.com", verified=False)
|
||||
unverified_staff.is_staff = True
|
||||
unverified_staff.save()
|
||||
|
||||
other_user = make_user("other@test.com", verified=True)
|
||||
|
||||
client.force_authenticate(user=unverified_staff)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/employees/{other_user.id}/",
|
||||
{"first_name": "Hacked"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_unverified_user_can_update_self_non_critical_fields(self):
|
||||
"""
|
||||
Неверифицированный пользователь может менять
|
||||
@ -350,32 +311,6 @@ class TestResendEmailLogic:
|
||||
class TestStaffCanBypassRestrictions:
|
||||
"""Тесты что staff может работать через админку даже без верификации."""
|
||||
|
||||
@pytest.mark.skip(reason="Form validation requires messenger fields in Meta.fields")
|
||||
def test_admin_created_user_is_verified_by_default(self):
|
||||
"""Пользователь созданный через админку верифицирован."""
|
||||
# Эмулируем создание через админку
|
||||
from employees.admin import EmployeeCreationForm
|
||||
|
||||
form_data = {
|
||||
"email": "admin_user@test.com",
|
||||
"password1": "TestPass123!",
|
||||
"password2": "TestPass123!",
|
||||
"first_name": "Admin",
|
||||
"last_name": "User",
|
||||
"phone_number": "+79001234567",
|
||||
"telegram": "@admin_user",
|
||||
"whatsapp": "+79001234567", # Добавляем обязательное поле
|
||||
}
|
||||
|
||||
form = EmployeeCreationForm(data=form_data)
|
||||
assert form.is_valid(), f"Form errors: {form.errors}"
|
||||
|
||||
user = form.save()
|
||||
|
||||
# Проверяем что верифицирован
|
||||
assert user.email_verified is True
|
||||
assert user.is_active is True
|
||||
|
||||
def test_superuser_is_verified_by_default(self):
|
||||
"""Суперпользователь создаётся верифицированным."""
|
||||
superuser = Employee.objects.create_superuser(
|
||||
|
||||
@ -6,7 +6,7 @@ from rest_framework import status
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
from employees.models import Employee, Department, EmployeeDepartment, EmployeeAction
|
||||
from employees.constants import ACTION_DISMISSED, ACTION_CHOICES
|
||||
from employees.constants import ACTION_DISMISSED, ACTION_CHOICES, ACTION_HIRED
|
||||
|
||||
# helpers
|
||||
_seq = 1
|
||||
@ -70,10 +70,10 @@ def _grant(user: Employee, code: str):
|
||||
|
||||
|
||||
def _any_non_dismissed():
|
||||
for k, _ in ACTION_CHOICES:
|
||||
for k, _ in list(ACTION_CHOICES):
|
||||
if k != ACTION_DISMISSED:
|
||||
return k
|
||||
pytest.skip("No non-dismissed action in ACTION_CHOICES")
|
||||
return ACTION_HIRED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@ -4,10 +4,8 @@
|
||||
Покрывает тест-кейсы R1-R9 из плана тестирования.
|
||||
"""
|
||||
import itertools
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
@ -15,15 +13,16 @@ from rest_framework.test import APIClient
|
||||
from employees.models import Employee
|
||||
from tests.conftest import _unique_phone
|
||||
|
||||
User = get_user_model()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
_phone_seq = itertools.count(4000)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data():
|
||||
"""Базовые данные для регистрации"""
|
||||
@ -43,72 +42,9 @@ def test_user_data():
|
||||
"birth_date": "1990-01-01",
|
||||
}
|
||||
|
||||
# ---------- Тесты С LDAP (LDAP_ENABLED=True) ----------
|
||||
|
||||
@pytest.mark.skip(reason="Requires real LDAP connection - skipped for safety")
|
||||
@patch("api.v1.employees.views.DirectoryService")
|
||||
def test_register_with_ldap_creates_user_in_ldap_and_db(
|
||||
mock_ds_class, api_client, test_user_data, settings
|
||||
):
|
||||
"""
|
||||
R1: Успешная регистрация с LDAP.
|
||||
- Пользователь создан в LDAP (disabled)
|
||||
- Пароль установлен в LDAP
|
||||
- Создана запись в БД (unusable_password)
|
||||
- is_active=False
|
||||
"""
|
||||
settings.LDAP_ENABLED = True
|
||||
|
||||
# Mock DirectoryService
|
||||
mock_service = Mock()
|
||||
mock_service.create_user.return_value = Mock(
|
||||
id=1,
|
||||
email=test_user_data["email"],
|
||||
ldap_dn=f"CN={test_user_data['first_name']} {test_user_data['last_name']},OU=Users,DC=example,DC=com"
|
||||
)
|
||||
mock_ds_class.return_value = mock_service
|
||||
|
||||
url = reverse("api:v1:register")
|
||||
response = api_client.post(url, test_user_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Проверяем создание в БД
|
||||
user = Employee.objects.get(email=test_user_data["email"])
|
||||
assert user.is_active is False
|
||||
assert user.email_verified is False
|
||||
assert not user.has_usable_password() # Пароль в LDAP, не в БД
|
||||
|
||||
# Проверяем вызов DirectoryService.create_user
|
||||
mock_service.create_user.assert_called_once()
|
||||
call_args = mock_service.create_user.call_args[0][0] # первый позиционный аргумент (dto)
|
||||
assert call_args.email == test_user_data["email"]
|
||||
assert call_args.password == test_user_data["password"]
|
||||
|
||||
@pytest.mark.skip(reason="Requires real LDAP connection - skipped for safety")
|
||||
@patch("api.v1.employees.views.DirectoryService")
|
||||
def test_register_with_ldap_duplicate_email_returns_400(
|
||||
mock_ds_class, api_client, test_user_data, settings
|
||||
):
|
||||
"""R3: Дублирование email возвращает 400"""
|
||||
settings.LDAP_ENABLED = True
|
||||
|
||||
# Создаём существующего пользователя
|
||||
Employee.objects.create(
|
||||
email=test_user_data["email"],
|
||||
phone_number=_unique_phone(),
|
||||
first_name="Existing",
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
url = reverse("api:v1:register")
|
||||
response = api_client.post(url, test_user_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "email" in str(response.data).lower() or "exists" in str(response.data).lower()
|
||||
|
||||
# ---------- Тесты БЕЗ LDAP (LDAP_ENABLED=False) ----------
|
||||
|
||||
|
||||
def test_register_without_ldap_creates_user_only_in_db(
|
||||
api_client, test_user_data, settings
|
||||
):
|
||||
@ -119,12 +55,12 @@ def test_register_without_ldap_creates_user_only_in_db(
|
||||
- is_active=False
|
||||
"""
|
||||
settings.LDAP_ENABLED = False
|
||||
|
||||
|
||||
url = reverse("api:v1:register")
|
||||
response = api_client.post(url, test_user_data, format="json")
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
|
||||
# Проверяем создание в БД
|
||||
user = Employee.objects.get(email=test_user_data["email"])
|
||||
assert user.is_active is False
|
||||
@ -132,12 +68,13 @@ def test_register_without_ldap_creates_user_only_in_db(
|
||||
assert user.has_usable_password() # Пароль в БД
|
||||
assert user.check_password(test_user_data["password"])
|
||||
|
||||
|
||||
def test_register_without_ldap_duplicate_email_returns_400(
|
||||
api_client, test_user_data, settings
|
||||
):
|
||||
"""R9: Дублирование email без LDAP возвращает 400"""
|
||||
settings.LDAP_ENABLED = False
|
||||
|
||||
|
||||
# Создаём существующего пользователя с верифицированным email
|
||||
Employee.objects.create(
|
||||
email=test_user_data["email"],
|
||||
@ -146,43 +83,9 @@ def test_register_without_ldap_duplicate_email_returns_400(
|
||||
last_name="User",
|
||||
email_verified=True, # Верифицирован - нельзя регистрировать снова
|
||||
)
|
||||
|
||||
|
||||
url = reverse("api:v1:register")
|
||||
response = api_client.post(url, test_user_data, format="json")
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# ---------- Параметризованные тесты (оба режима) ----------
|
||||
|
||||
@pytest.mark.skip(reason="Requires LDAP mocking improvements")
|
||||
@pytest.mark.parametrize(
|
||||
"ldap_enabled", [True, False], ids=["with_ldap", "without_ldap"]
|
||||
)
|
||||
@patch("api.v1.employees.views.DirectoryService")
|
||||
def test_register_validates_required_fields(
|
||||
mock_ds_class, api_client, ldap_enabled, settings
|
||||
):
|
||||
"""Проверка валидации обязательных полей в обоих режимах"""
|
||||
settings.LDAP_ENABLED = ldap_enabled
|
||||
|
||||
if ldap_enabled:
|
||||
mock_service = Mock()
|
||||
mock_ds_class.return_value = mock_service
|
||||
|
||||
url = reverse("api:v1:register")
|
||||
|
||||
# Отсутствует email
|
||||
response = api_client.post(url, {
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"password": "Pass123!",
|
||||
}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# Отсутствует password
|
||||
response = api_client.post(url, {
|
||||
"email": "test@example.com",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
}, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@ -13,7 +13,6 @@ import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
@ -52,41 +51,6 @@ def verified_user(db):
|
||||
return user
|
||||
|
||||
|
||||
# ---------- Тесты с LDAP ----------
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires real LDAP connection - skipped for safety")
|
||||
@patch("api.v1.employees.views.DirectoryService")
|
||||
def test_verify_email_with_ldap_activates_user_in_ldap_and_db(
|
||||
mock_ds, api_client, unverified_user, settings
|
||||
):
|
||||
"""V1: Верификация email с LDAP активирует пользователя в LDAP и БД"""
|
||||
settings.LDAP_ENABLED = True
|
||||
|
||||
mock_svc = MagicMock()
|
||||
mock_ds.return_value = mock_svc
|
||||
|
||||
url = reverse("api:v1:verify-email")
|
||||
data = {
|
||||
"email": unverified_user.email,
|
||||
"code": unverified_user.email_activation_code,
|
||||
}
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["ok"] is True
|
||||
|
||||
# Проверяем что пользователь активирован в БД
|
||||
unverified_user.refresh_from_db()
|
||||
assert unverified_user.is_active is True
|
||||
assert unverified_user.email_verified is True
|
||||
assert unverified_user.email_activation_code == ""
|
||||
|
||||
# Проверяем что вызван метод активации в LDAP
|
||||
mock_svc.enable_user.assert_called_once_with(unverified_user)
|
||||
|
||||
|
||||
# ---------- Тесты без LDAP ----------
|
||||
|
||||
|
||||
@ -161,6 +125,8 @@ def test_verify_email_already_verified_returns_400_without_ldap(
|
||||
# т.к. верифицированный пользователь не имеет валидного кода
|
||||
assert "ok" in response.data
|
||||
assert response.data["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_verify_email_nonexistent_user_returns_404_without_ldap(
|
||||
api_client, settings
|
||||
@ -197,30 +163,3 @@ def test_verify_email_empty_code_returns_400_without_ldap(
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
# ---------- Тесты LDAP ошибок ----------
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires real LDAP connection - skipped for safety")
|
||||
@patch("api.v1.employees.views.DirectoryService")
|
||||
def test_verify_email_ldap_error_returns_502(
|
||||
mock_ds, api_client, unverified_user, settings
|
||||
):
|
||||
"""Ошибка LDAP при активации возвращает 502"""
|
||||
settings.LDAP_ENABLED = True
|
||||
|
||||
from bots.services.directory import DirectoryLdapError
|
||||
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.enable_user.side_effect = DirectoryLdapError("LDAP connection failed")
|
||||
mock_ds.return_value = mock_svc
|
||||
|
||||
url = reverse("api:v1:verify-email")
|
||||
data = {
|
||||
"email": unverified_user.email,
|
||||
"code": unverified_user.email_activation_code,
|
||||
}
|
||||
|
||||
response = api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_502_BAD_GATEWAY
|
||||
assert "LDAP" in str(response.data)
|
||||
|
||||
@ -81,7 +81,7 @@ class TestProcurementRequestAPI:
|
||||
|
||||
def test_list_requests_unauthorized(self, api_client):
|
||||
"""Тест: неавторизованный доступ запрещен."""
|
||||
url = reverse('procurement:procurementrequest-list')
|
||||
url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@ -90,9 +90,9 @@ class TestProcurementRequestAPI:
|
||||
):
|
||||
"""Тест: авторизованный пользователь видит свои заявки."""
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse('procurement:procurementrequest-list')
|
||||
url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data['results']) == 1
|
||||
assert response.data['results'][0]['title'] == "Тестовая заявка"
|
||||
@ -103,10 +103,10 @@ class TestProcurementRequestAPI:
|
||||
"""Тест: создание заявки."""
|
||||
# Связываем пользователя с отделом (необходимо для создания заявки)
|
||||
link_factory(user, department, is_active=True)
|
||||
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse('procurement:procurementrequest-list')
|
||||
|
||||
url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
|
||||
data = {
|
||||
'title': 'Новая заявка',
|
||||
'description': 'Нужны компьютеры',
|
||||
@ -121,7 +121,7 @@ class TestProcurementRequestAPI:
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
response = api_client.post(url, data, format='json')
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data['title'] == 'Новая заявка'
|
||||
@ -133,11 +133,11 @@ class TestProcurementRequestAPI:
|
||||
"""Тест: получение детальной информации о заявке."""
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'procurement:procurementrequest-detail',
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
response = api_client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data['id'] == procurement_request.id
|
||||
assert response.data['title'] == "Тестовая заявка"
|
||||
@ -151,13 +151,13 @@ class TestProcurementRequestAPI:
|
||||
"""Тест: обновление своей заявки в статусе DRAFT."""
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse(
|
||||
'procurement:procurementrequest-detail',
|
||||
'api:v1:procurement:procurementrequest-detail',
|
||||
kwargs={'pk': procurement_request.id}
|
||||
)
|
||||
|
||||
|
||||
data = {'title': 'Обновленная заявка'}
|
||||
response = api_client.patch(url, data, format='json')
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data['title'] == 'Обновленная заявка'
|
||||
|
||||
@ -171,7 +171,7 @@ class TestEquipmentAPI:
|
||||
):
|
||||
"""Тест: список оборудования."""
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
|
||||
# Создаем категорию и оборудование
|
||||
category = EquipmentCategory.objects.create(name="Компьютеры")
|
||||
Equipment.objects.create(
|
||||
@ -182,10 +182,10 @@ class TestEquipmentAPI:
|
||||
purchase_date="2025-01-01",
|
||||
purchase_cost=Decimal("80000.00"),
|
||||
)
|
||||
|
||||
url = reverse('procurement:equipment-list')
|
||||
|
||||
url = reverse('api:v1:procurement:equipment-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data['results']) == 1
|
||||
|
||||
@ -194,11 +194,11 @@ class TestEquipmentAPI:
|
||||
):
|
||||
"""Тест: создание оборудования только для staff."""
|
||||
category = EquipmentCategory.objects.create(name="Техника")
|
||||
|
||||
|
||||
# Обычный пользователь не может создать
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse('procurement:equipment-list')
|
||||
|
||||
url = reverse('api:v1:procurement:equipment-list')
|
||||
|
||||
data = {
|
||||
'name': 'Принтер HP',
|
||||
'inventory_number': 'INV-2025-002',
|
||||
@ -207,10 +207,10 @@ class TestEquipmentAPI:
|
||||
'purchase_date': '2025-01-01',
|
||||
'purchase_cost': '20000.00',
|
||||
}
|
||||
|
||||
|
||||
response = api_client.post(url, data, format='json')
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# Staff может создать
|
||||
api_client.force_authenticate(user=staff_user)
|
||||
response = api_client.post(url, data, format='json')
|
||||
@ -226,17 +226,17 @@ class TestBudgetAPI:
|
||||
):
|
||||
"""Тест: список бюджетов для staff."""
|
||||
api_client.force_authenticate(user=staff_user)
|
||||
|
||||
|
||||
Budget.objects.create(
|
||||
department=department,
|
||||
year=2025,
|
||||
quarter=4,
|
||||
allocated_amount=Decimal("500000.00"),
|
||||
)
|
||||
|
||||
url = reverse('procurement:budget-list')
|
||||
|
||||
url = reverse('api:v1:procurement:budget-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data['results']) == 1
|
||||
|
||||
@ -245,10 +245,10 @@ class TestBudgetAPI:
|
||||
):
|
||||
"""Тест: обычный пользователь не видит бюджеты (нет прав)."""
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
url = reverse('procurement:budget-list')
|
||||
|
||||
url = reverse('api:v1:procurement:budget-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
|
||||
# Обычный пользователь не имеет доступа к бюджетам
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@ -260,22 +260,22 @@ class TestSupplierAPI:
|
||||
def test_list_suppliers(self, api_client, user):
|
||||
"""Тест: список поставщиков."""
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
|
||||
Supplier.objects.create(
|
||||
name="ООО Тестовый поставщик",
|
||||
rating=Decimal("4.50"),
|
||||
)
|
||||
|
||||
url = reverse('procurement:supplier-list')
|
||||
|
||||
url = reverse('api:v1:procurement:supplier-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data['results']) == 1
|
||||
|
||||
def test_top_rated_suppliers(self, api_client, user):
|
||||
"""Тест: лучшие поставщики."""
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
|
||||
Supplier.objects.create(
|
||||
name="Отличный поставщик",
|
||||
rating=Decimal("4.80"),
|
||||
@ -286,10 +286,10 @@ class TestSupplierAPI:
|
||||
rating=Decimal("2.50"),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
url = reverse('procurement:supplier-top-rated')
|
||||
|
||||
url = reverse('api:v1:procurement:supplier-top-rated')
|
||||
response = api_client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Должен вернуть только с рейтингом >= 4.0
|
||||
assert len(response.data) == 1
|
||||
@ -303,16 +303,16 @@ class TestEquipmentCategoryAPI:
|
||||
def test_category_tree(self, api_client, user):
|
||||
"""Тест: дерево категорий."""
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
|
||||
parent = EquipmentCategory.objects.create(name="Компьютеры")
|
||||
EquipmentCategory.objects.create(
|
||||
name="Ноутбуки",
|
||||
parent=parent
|
||||
)
|
||||
|
||||
url = reverse('procurement:equipmentcategory-tree')
|
||||
|
||||
url = reverse('api:v1:procurement:equipmentcategory-tree')
|
||||
response = api_client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Должен вернуть только корневые категории
|
||||
assert len(response.data) == 1
|
||||
|
||||
@ -146,7 +146,7 @@ class TestBudgetMyDepartmentEndpoint:
|
||||
"""Тест: получение бюджета своего отдела."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/budgets/my-department/')
|
||||
response = self.client.get('/api/v1/procurement/budgets/my-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data['department'] == self.department.id
|
||||
@ -168,7 +168,7 @@ class TestBudgetMyDepartmentEndpoint:
|
||||
)
|
||||
self.client.force_authenticate(user=user_no_dept)
|
||||
|
||||
response = self.client.get('/api/procurement/budgets/my-department/')
|
||||
response = self.client.get('/api/v1/procurement/budgets/my-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@ -179,7 +179,7 @@ class TestBudgetMyDepartmentEndpoint:
|
||||
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/budgets/my-department/')
|
||||
response = self.client.get('/api/v1/procurement/budgets/my-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@ -227,7 +227,7 @@ class TestProcurementStatsEndpoints:
|
||||
"""Тест: получение общей статистики."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/stats/overview/')
|
||||
response = self.client.get('/api/v1/procurement/stats/overview/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert 'total_requests' in response.data
|
||||
@ -239,7 +239,7 @@ class TestProcurementStatsEndpoints:
|
||||
"""Тест: статистика по отделам для staff."""
|
||||
self.client.force_authenticate(user=self.staff_user)
|
||||
|
||||
response = self.client.get('/api/procurement/stats/by-department/')
|
||||
response = self.client.get('/api/v1/procurement/stats/by-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert isinstance(response.data, list)
|
||||
@ -248,7 +248,7 @@ class TestProcurementStatsEndpoints:
|
||||
"""Тест: 403 для обычного пользователя."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/stats/by-department/')
|
||||
response = self.client.get('/api/v1/procurement/stats/by-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@ -260,7 +260,7 @@ class TestProcurementStatsEndpoints:
|
||||
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/procurement/stats/by-department/')
|
||||
response = self.client.get('/api/v1/procurement/stats/by-department/')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 1
|
||||
|
||||
@ -171,12 +171,12 @@ def make_equipment(
|
||||
|
||||
def equipment_create_url() -> str:
|
||||
"""URL для создания оборудования."""
|
||||
return reverse("procurement:equipment-list")
|
||||
return reverse("api:v1:procurement:equipment-list")
|
||||
|
||||
|
||||
def equipment_detail_url(pk: int) -> str:
|
||||
"""URL для конкретного оборудования."""
|
||||
return reverse("procurement:equipment-detail", args=[pk])
|
||||
return reverse("api:v1:procurement:equipment-detail", args=[pk])
|
||||
|
||||
|
||||
def valid_equipment_data(
|
||||
@ -808,7 +808,7 @@ class TestEquipmentCreateOptions:
|
||||
"""Админ получает все отделы для выбора."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
url = reverse("procurement:equipment-create-options")
|
||||
url = reverse("api:v1:procurement:equipment-create-options")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@ -824,7 +824,7 @@ class TestEquipmentCreateOptions:
|
||||
"""Начальник получает только свой отдел."""
|
||||
api_client.force_authenticate(user=dept_head)
|
||||
|
||||
url = reverse("procurement:equipment-create-options")
|
||||
url = reverse("api:v1:procurement:equipment-create-options")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@ -839,7 +839,7 @@ class TestEquipmentCreateOptions:
|
||||
"""Уполномоченный получает свой отдел и НЕ может выбирать ответственного."""
|
||||
api_client.force_authenticate(user=authorized_member)
|
||||
|
||||
url = reverse("procurement:equipment-create-options")
|
||||
url = reverse("api:v1:procurement:equipment-create-options")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@ -851,7 +851,7 @@ class TestEquipmentCreateOptions:
|
||||
"""Обычный сотрудник без прав не получает опции (или получает пустой ответ)."""
|
||||
api_client.force_authenticate(user=dept_member)
|
||||
|
||||
url = reverse("procurement:equipment-create-options")
|
||||
url = reverse("api:v1:procurement:equipment-create-options")
|
||||
response = api_client.get(url)
|
||||
|
||||
# Либо 403, либо пустой список отделов
|
||||
|
||||
@ -169,7 +169,7 @@ class TestEquipmentTransferEndpoint:
|
||||
self.client.force_authenticate(user=self.user1)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/equipment/{self.equipment.id}/transfer/',
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/transfer/',
|
||||
{
|
||||
'to_department': self.dept2.id,
|
||||
'reason': 'Переезд сотрудника',
|
||||
@ -197,7 +197,7 @@ class TestEquipmentTransferEndpoint:
|
||||
self.client.force_authenticate(user=self.user1)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/equipment/{self.equipment.id}/transfer/',
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/transfer/',
|
||||
{
|
||||
'to_person': self.user2.id,
|
||||
'reason': 'Смена ответственного',
|
||||
@ -214,7 +214,7 @@ class TestEquipmentTransferEndpoint:
|
||||
self.client.force_authenticate(user=self.user1)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/equipment/{self.equipment.id}/transfer/',
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/transfer/',
|
||||
{'reason': 'Без цели'},
|
||||
)
|
||||
|
||||
@ -259,7 +259,7 @@ class TestEquipmentWriteOffEndpoint:
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/equipment/{self.equipment.id}/write_off/',
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/write_off/',
|
||||
{'reason': 'Износ'}
|
||||
)
|
||||
|
||||
@ -278,7 +278,7 @@ class TestEquipmentWriteOffEndpoint:
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/equipment/{self.equipment.id}/write_off/',
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/write_off/',
|
||||
{'reason': 'Повторно'}
|
||||
)
|
||||
|
||||
@ -323,7 +323,7 @@ class TestEquipmentMaintenanceEndpoint:
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.post(
|
||||
f'/api/procurement/equipment/{self.equipment.id}/add_maintenance/',
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/add_maintenance/',
|
||||
{
|
||||
'type': 'repair',
|
||||
'description': 'Замена блока питания',
|
||||
@ -379,7 +379,7 @@ class TestEquipmentQRCodeEndpoint:
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get(
|
||||
f'/api/procurement/equipment/{self.equipment.id}/qr_code/'
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/qr_code/'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@ -444,7 +444,7 @@ class TestEquipmentTransferHistoryEndpoint:
|
||||
self.client.force_authenticate(user=self.user1)
|
||||
|
||||
response = self.client.get(
|
||||
f'/api/procurement/equipment/{self.equipment.id}/transfer_history/'
|
||||
f'/api/v1/procurement/equipment/{self.equipment.id}/transfer_history/'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@ -130,12 +130,12 @@ def make_procurement_request(
|
||||
|
||||
def request_list_url() -> str:
|
||||
"""URL для списка/создания заявок."""
|
||||
return reverse("procurement:procurementrequest-list")
|
||||
return reverse("api:v1:procurement:procurementrequest-list")
|
||||
|
||||
|
||||
def request_detail_url(pk: int) -> str:
|
||||
"""URL для конкретной заявки."""
|
||||
return reverse("procurement:procurementrequest-detail", args=[pk])
|
||||
return reverse("api:v1:procurement:procurementrequest-detail", args=[pk])
|
||||
|
||||
|
||||
def valid_request_data(dept: Department, title: str = "Тестовая заявка") -> dict:
|
||||
|
||||
@ -75,29 +75,6 @@ class TestUserConsumerConnection:
|
||||
|
||||
# После отключения не должно быть ошибок
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserConsumerPing:
|
||||
"""Тесты ping/pong механизма для keepalive."""
|
||||
|
||||
async def test_receives_ping_messages(self, ws_communicator, user):
|
||||
"""Клиент получает ping сообщения для keepalive."""
|
||||
communicator = await ws_communicator(user=user)
|
||||
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected
|
||||
|
||||
# Ждем ping сообщение (с таймаутом)
|
||||
try:
|
||||
response = await communicator.receive_json_from(timeout=25)
|
||||
assert response["type"] == "ping"
|
||||
assert "timestamp" in response
|
||||
except TimeoutError:
|
||||
pytest.skip("Ping не получен в течение таймаута")
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserConsumerChatManagement:
|
||||
"""Тесты управления активным чатом."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user