Add backend OpenAPI docs and clean test suite

This commit is contained in:
lizerk 2026-03-31 17:05:31 +03:00
parent 7a4ed36b6e
commit 3f59f8ebb9
45 changed files with 996 additions and 1625 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 []

View File

@ -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):
# Логируем входящие данные для диагностики

View File

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

View File

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

View File

@ -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):
"""Статистика по отделам."""

View File

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

View File

@ -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:
"""Удаление комментария.

View File

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

View File

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

View File

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

View File

@ -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 = {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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/` - лучшие поставщики
### Фильтрация и поиск:

View File

@ -1 +0,0 @@
bash: .venv/Scripts/python: No such file or directory

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, либо пустой список отделов

View File

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

View File

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

View File

@ -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:
"""Тесты управления активным чатом."""