diff --git a/backend/api/v1/calendar/views.py b/backend/api/v1/calendar/views.py index db800e3b..a2bb3f1f 100644 --- a/backend/api/v1/calendar/views.py +++ b/backend/api/v1/calendar/views.py @@ -57,7 +57,6 @@ class CalendarEventsViewSet(ModelViewSet): гарантированно его увидел на этапе has_permission(). """ dep = self._dept_id(required=False) - print(f"Request user: {self.request.user}, Auth: {self.request.auth}") if dep is None: return [AdminOrActionOrModelPerms()] diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py new file mode 100644 index 00000000..7d4191d4 --- /dev/null +++ b/backend/api/v1/documents/serializers.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from typing import Any, Dict, Sequence + +from django.contrib.auth import get_user_model +from documents.models import Document, DocumentAcknowledgement +from rest_framework import serializers + +from ..employees.serializers import EmployeeBriefSerializer + +User = get_user_model() + + +class DocumentReadSerializer(serializers.ModelSerializer): + """Сериализатор для чтения документа. + + Включает список получателей и флаг, ознакомился ли текущий пользователь. + """ + + uploaded_by = EmployeeBriefSerializer(read_only=True) + recipients = EmployeeBriefSerializer(many=True, read_only=True) + file_url = serializers.FileField(source="file", read_only=True) + is_acknowledged = serializers.SerializerMethodField() + + class Meta: + model = Document + fields = ( + "id", + "title", + "description", + "uploaded_by", + "uploaded_at", + "sent_to_all", + "recipients", + "file_url", + "is_acknowledged", + ) + + def get_is_acknowledged(self, obj: Document) -> bool: + """Проверяет, отмечал ли текущий пользователь ознакомление. + + Args: + obj (Document): Документ. + + Returns: + bool: True, если текущий пользователь уже ознакомился. + """ + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + return DocumentAcknowledgement.objects.filter( + document=obj, user=request.user + ).exists() + + +class DocumentWriteSerializer(serializers.ModelSerializer): + """Сериализатор для записи/обновления документа. + + Поддерживает: + - загрузку файла (multipart) + - `sent_to_all` + - `recipient_ids` (список ID пользователей), если `sent_to_all == False` + """ + + # Принимаем список ID вместо M2M-структуры + recipient_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + write_only=True, + required=False, + allow_empty=True, + help_text="Список ID сотрудников (используется, когда sent_to_all=false).", + ) + + class Meta: + model = Document + fields = ( + "id", + "title", + "description", + "file", + "sent_to_all", + "recipient_ids", + ) + + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + """Общая валидация полей. + + Args: + attrs (dict): Данные. + + Returns: + dict: Провалидированные данные. + + Raises: + serializers.ValidationError: При логических несоответствиях. + """ + sent_to_all = attrs.get("sent_to_all", True) + recipient_ids = attrs.get("recipient_ids", []) + if sent_to_all is False and not recipient_ids: + raise serializers.ValidationError( + { + "recipient_ids": "Укажите получателей или установите sent_to_all=true." + } + ) + return attrs + + def _set_recipients(self, doc: Document, recipient_ids: Sequence[int]) -> int: + """Привязывает получателей по списку ID. + + Args: + doc (Document): Документ. + recipient_ids (Sequence[int]): Идентификаторы пользователей. + + Returns: + int: Количество привязанных получателей. + """ + if not recipient_ids: + doc.recipients.clear() + return 0 + users = User.objects.filter(is_active=True, id__in=list(set(recipient_ids))) + doc.recipients.set(users) + return users.count() + + def create(self, validated_data: Dict[str, Any]) -> Document: + """Создание документа. + + ВАЖНО: чтобы уведомления (signals) не улетели до установки M2M, + мы временно отключим `post_save` сигнал и вызовем уведомление вручную уже после назначения получателей. + + Args: + validated_data (dict): Данные. + + Returns: + Document: Созданный документ. + + Raises: + serializers.ValidationError: Если входные данные недопустимы. + """ + recipient_ids = validated_data.pop("recipient_ids", []) + request = self.context.get("request") + uploader = getattr(request, "user", None) + + # Подключим/отключим сигнал аккуратно + from django.db.models.signals import post_save + + try: + from documents.signals import on_document_saved # type: ignore + except Exception: + on_document_saved = None + + if on_document_saved: + post_save.disconnect(on_document_saved, sender=Document) + + try: + doc = Document.objects.create( + uploaded_by=uploader, # type: ignore[arg-type] + **validated_data, + ) + if validated_data.get("sent_to_all") is False: + self._set_recipients(doc, recipient_ids) + finally: + if on_document_saved: + post_save.connect(on_document_saved, sender=Document) + + # Ручное уведомление после полной подготовки (если доступно) + try: + from documents.notification import \ + notify_users_about_document # type: ignore + + notify_users_about_document(doc) + except Exception: + # если уведомлялка неактивна — просто молча пропустим + pass + + return doc + + def update(self, instance: Document, validated_data: Dict[str, Any]) -> Document: + """Обновление документа. + + Сценарии: + - можно заменить файл/описание/заголовок + - переключить `sent_to_all` + - обновить получателей через `recipient_ids` + + Args: + instance (Document): Документ для обновления. + validated_data (dict): Новые данные. + + Returns: + Document: Обновлённый документ. + """ + recipient_ids = validated_data.pop("recipient_ids", None) + + for field in ("title", "description", "sent_to_all", "file"): + if field in validated_data: + setattr(instance, field, validated_data[field]) + instance.save() + + if recipient_ids is not None: + # Если теперь "всем" — очистим M2M + if validated_data.get("sent_to_all", instance.sent_to_all): + instance.recipients.clear() + else: + self._set_recipients(instance, recipient_ids) + + # уведомления по обновлению — по желанию; оставим тихо + return instance diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py new file mode 100644 index 00000000..276989cd --- /dev/null +++ b/backend/api/v1/documents/views.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import Any + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import QuerySet +from documents.models import Document, DocumentAcknowledgement +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.permissions import (SAFE_METHODS, BasePermission, + IsAuthenticated) +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from ..permissions import AdminOrActionOrModelPerms +from .serializers import DocumentReadSerializer, DocumentWriteSerializer + +User = get_user_model() + + +class DocumentViewSet(ModelViewSet): + """Полный CRUD по документам + экшен `acknowledge`. + + Создание/обновление поддерживает multipart (для файла) и назначение получателей. + """ + + queryset: QuerySet[Document] = ( + Document.objects.all() + .select_related("uploaded_by") + .prefetch_related("recipients") + ) + permission_classes = [AdminOrActionOrModelPerms] + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_serializer_class(self): + """Выбор сериализатора: на чтение — Read, на запись — Write.""" + if self.action in ("list", "retrieve"): + return DocumentReadSerializer + return DocumentWriteSerializer + + @transaction.atomic + def create(self, request, *args, **kwargs): + """Создаёт документ для рассылки. + + Returns: + Response: 201 Created с данными документа (read-сериализатор). + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + doc = serializer.save() + read = DocumentReadSerializer(doc, context={"request": request}) + return Response(read.data, status=status.HTTP_201_CREATED) + + @transaction.atomic + def update(self, request, *args, **kwargs): + """Полное обновление документа.""" + return super().update(request, *args, **kwargs) + + @transaction.atomic + def partial_update(self, request, *args, **kwargs): + """Частичное обновление документа.""" + return super().partial_update(request, *args, **kwargs) + + @action(methods=["post"], detail=True, permission_classes=[IsAuthenticated]) + def acknowledge(self, request, pk=None): + """Отметить ознакомление текущего пользователя с документом. + + Args: + request: DRF Request + pk: ID документа + + Returns: + Response: {"ok": true, "already": bool} + """ + doc = self.get_object() + obj, created = DocumentAcknowledgement.objects.get_or_create( + document=doc, user=request.user + ) + return Response({"ok": True, "already": not created}) diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 507b24ac..9be6931b 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -148,7 +148,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer): "date_display", "comment", "extra", - "history", # ← добавили + "history", ] def get_action_display(self, obj): diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 104ff046..d3f4ded6 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -329,7 +329,14 @@ class DepartmentViewSet(viewsets.ModelViewSet): return [self.ManagePerm()] if self.action == "create": return [AdminOrActionOrModelPerms()] - if self.action in {"members", "user_perms", "ui_context"}: + if self.action in { + "members", + "user_perms", + "ui_context", + "list", + "retrieve", + "my_departments", + }: return [IsAuthenticated()] return [AdminOrActionOrModelPerms()] @@ -1077,7 +1084,7 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet): ordering_fields = ["name", "id"] ordering = ["name"] history_diff_fields = ["name", "description"] - permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms] + permission_classes = [AdminOrActionOrModelPerms] required_perms_by_action = { "set_groups": "employees.assign_position_groups", "add_groups": "employees.assign_position_groups", @@ -1095,6 +1102,18 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet): return None, Response({"detail": "Некоторые группы не найдены"}, status=400) return qs, None + def get_permissions(self): + """Возвращает пермишены для текущего экшена. + + Returns: + list: Инスタнсы DRF-permission'ов. + """ + if self.action in {"list", "retrieve", "permissions"}: + return [IsAuthenticated()] + if self.action == "create": + return [AdminOrActionOrModelPerms()] + return [AdminOrActionOrModelPerms()] + @action(detail=True, methods=["post"]) def set_groups(self, request, pk=None): pos = self.get_object() @@ -1296,7 +1315,7 @@ class SkillViewSet(viewsets.ModelViewSet): } def get_permissions(self): - if self.action in ("create", "bulk_create"): + if self.action in ("create", "bulk_create", "list", "retrieve"): return [IsAuthenticated()] return [IsAuthenticated(), AdminOrActionOrModelPerms()] diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py index d2395a43..97fe52ac 100644 --- a/backend/api/v1/feed/views.py +++ b/backend/api/v1/feed/views.py @@ -1,8 +1,10 @@ from __future__ import annotations +from typing import Any, List + from django.contrib.auth import get_user_model -from django.db.models import (BooleanField, Count, Exists, F, OuterRef, - Subquery, Value) +from django.db.models import BooleanField, Count, Exists, F, OuterRef, Subquery, Value +from employees.constants import DeptPerm from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE from feed.models import Comment, Post, PostLike from rest_framework import filters, status, viewsets @@ -12,8 +14,14 @@ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response -from ..permissions import (IsAuthorOrStaffForComments, user_has_dept_perm, - user_is_dept_head, user_is_staffish) +from ..permissions import ( + AdminOrActionOrModelPerms, + AdminOrDeptAllowed, + IsSelfOrStaff, + user_has_dept_perm, + user_is_dept_head, + user_is_staffish, +) from .serializers import CommentSerializer, PostListSerializer, PostSerializer Employee = get_user_model() @@ -33,18 +41,97 @@ def _to_id(val): class PostViewSet(viewsets.ModelViewSet): + """CRUD ленты + like/unlike, pin/unpin. + + Доступ (коротко): + - list/retrieve/like/unlike — IsAuthenticated + - create: + * type=company → AdminOrActionOrModelPerms + required_perm_code="feed.publish_company_post" + * type=department → AdminOrDeptAllowed с кодом DeptPerm.CREATE_POST|publish_department_post + - update/partial_update/destroy: + * company → как для company-create + * department → как для department-create + - pin/unpin — только staff/superuser + """ + queryset = ( Post.objects.select_related("author", "department") .annotate(comments_count=Count("comments", distinct=True)) .order_by("-pinned", "-created_at") ) - permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser, JSONParser] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["title", "body"] ordering_fields = ["created_at", "pinned", "likes_count"] ordering = ["-pinned", "-created_at"] + class PostDeptPerm(AdminOrDeptAllowed): + """Департаментные права для операций над постами отдела. + + SAFE-методы разрешены (IsAuthenticated стоит на вьюхе). + Коды прав подставляются динамически из get_permissions(). + """ + + allow_safe_without_code = True + + def has_permission(self, request, view) -> bool: + """На create, если type=department, но department не указан, + не режем запрос — даём сериализатору вернуть 400 вместо 403. + """ + if getattr(view, "action", None) == "create": + t = (request.data.get("type") or "").strip() + if t == TYPE_DEPARTMENT: + dept_id = self._to_int_or_none( + self._extract_dept_id_from_request(request, view) + ) + if dept_id is None: + return True # пропускаем к валидации сериализатора + return super().has_permission(request, view) + + def get_permissions(self) -> List[Any]: + """Возвращает DRF-пермишены под текущий action (без вызова get_object()).""" + if self.action in {"list", "retrieve", "like", "unlike"}: + return [IsAuthenticated()] + + if self.action in {"pin", "unpin"}: + return [IsAdminUser()] + + # --- CREATE: смотрим на тип из тела запроса --- + if self.action == "create": + t = (self.request.data.get("type") or "").strip() + if t == TYPE_COMPANY: + return [IsAuthenticated(), AdminOrActionOrModelPerms()] + # department → департаментное право на создание + dept_code = getattr(DeptPerm, "CREATE_POST", "publish_department_post") + perm = self.PostDeptPerm() + perm.required_code = DeptPerm.CREATE_POST + return [IsAuthenticated(), perm] + + # --- UPDATE/PATCH/DELETE: определяем тип поста из БД, но без get_object() --- + if self.action in {"update", "partial_update", "destroy"}: + lookup = getattr(self, "lookup_url_kwarg", None) or getattr( + self, "lookup_field", "pk" + ) + pk = self.kwargs.get(lookup) + info = ( + Post.objects.filter(pk=pk).values("type", "department_id").first() or {} + ) + if info.get("type") == TYPE_COMPANY: + return [IsAuthenticated(), AdminOrActionOrModelPerms()] + + # department + dept_code = DeptPerm.MANAGE_FEED + perm = self.PostDeptPerm() + perm.required_code_map = { + "update": dept_code, + "partial_update": dept_code, + "destroy": dept_code, + } + return [IsAuthenticated(), perm] + + # дефолт: просто аутентификация + return [IsAuthenticated()] + def get_serializer_class(self): return PostListSerializer if self.action == "list" else PostSerializer @@ -59,11 +146,11 @@ class PostViewSet(viewsets.ModelViewSet): else: qs = qs.annotate(is_liked=Value(False, output_field=BooleanField())) - # только ID последнего комментария — остальное соберём в сериализаторе + # последний комментарий (id) — без N+1 lc = Comment.objects.filter(post_id=OuterRef("pk")).order_by("-created_at") qs = qs.annotate(last_comment_id=Subquery(lc.values("id")[:1])) - # дальше — ваши фильтры type/department/author/pinned (как были) + # фильтры qp = self.request.query_params t = qp.get("type") if t: @@ -81,17 +168,10 @@ class PostViewSet(viewsets.ModelViewSet): qs = qs.filter(pinned=False) return qs - - def list(self, request, *args, **kwargs): - """ - Сбор последних комментариев пачкой, без N+1: - 1) получаем страницу/список постов; - 2) вытаскиваем их last_comment_id; - 3) делаем один запрос in_bulk() с select_related('author'); - 4) кладём карту в serializer.context. - """ - queryset = self.filter_queryset(self.get_queryset()) + def list(self, request, *args, **kwargs): + """Возвращает список постов с картой последних комментариев без N+1.""" + queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) objects = page if page is not None else queryset @@ -104,61 +184,29 @@ class PostViewSet(viewsets.ModelViewSet): serializer = self.get_serializer( objects, many=True, - context={**self.get_serializer_context(), "last_comments_map": last_comments_map}, + context={ + **self.get_serializer_context(), + "last_comments_map": last_comments_map, + }, ) if page is not None: return self.get_paginated_response(serializer.data) return Response(serializer.data) - # ---- permissions helpers - def _can_create(self, user, t, dept): - if not user or not user.is_authenticated: - return False - if t == TYPE_EMPLOYEE: - return False - if t == TYPE_COMPANY: - return user_is_staffish(user) or user.has_perm("feed.publish_company_post") - if t == TYPE_DEPARTMENT and dept: - return ( - user_is_staffish(user) - or user_is_dept_head(user, dept) - or user_has_dept_perm(user, dept, "publish_department_post") - ) - return False + # --- ВАЖНО: возвращаем perform_create, чтобы заполнить author --- + def perform_create(self, serializer: PostSerializer) -> None: + """Сохраняет пост, проставляя автора текущим пользователем. - def _can_edit(self, user, post: Post): - if post.type == TYPE_COMPANY: - return user_is_staffish(user) or user.has_perm("feed.publish_company_post") - if post.type == TYPE_DEPARTMENT and post.department_id: - return ( - user_is_staffish(user) - or user_is_dept_head(user, post.department) - or user_has_dept_perm(user, post.department, "publish_department_post") - ) - return False + Raises: + ValidationError: если сериализатор невалиден (обрабатывается DRF). + """ + serializer.save(author=self.request.user) - # ---- hooks - def perform_create(self, serializer): - user = self.request.user - t = self.request.data.get("type") or serializer.validated_data.get("type") - dept = serializer.validated_data.get("department") - if not self._can_create(user, t, dept): - raise PermissionDenied("Недостаточно прав для публикации.") - serializer.save(author=user) + # --- actions (pin/unpin и лайки) --- - def perform_update(self, serializer): - if not self._can_edit(self.request.user, self.get_object()): - raise PermissionDenied("Недостаточно прав для изменения публикации.") - serializer.save() - - def perform_destroy(self, instance): - if not self._can_edit(self.request.user, instance): - raise PermissionDenied("Недостаточно прав для удаления публикации.") - return super().perform_destroy(instance) - - # ---- actions @action(detail=True, methods=["post"], permission_classes=[IsAdminUser]) def pin(self, request, pk=None): + """Пинует пост (только staff/superuser).""" post = self.get_object() if not post.pinned: Post.objects.filter(pk=post.pk).update(pinned=True) @@ -166,13 +214,15 @@ class PostViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["post"], permission_classes=[IsAdminUser]) def unpin(self, request, pk=None): + """Снимает пин (только staff/superuser).""" post = self.get_object() if post.pinned: Post.objects.filter(pk=post.pk).update(pinned=False) return Response({"pinned": False}) - @action(detail=True, methods=["post"]) + @action(detail=True, methods=["post"], permission_classes=[IsAuthenticated]) def like(self, request, pk=None): + """Ставит лайк текущего пользователя (идемпотентно).""" post = self.get_object() _, created = PostLike.objects.get_or_create(post=post, user=request.user) if created: @@ -182,8 +232,9 @@ class PostViewSet(viewsets.ModelViewSet): {"liked": True, "likes_count": post.likes_count}, status=status.HTTP_200_OK ) - @action(detail=True, methods=["post"]) + @action(detail=True, methods=["post"], permission_classes=[IsAuthenticated]) def unlike(self, request, pk=None): + """Снимает лайк текущего пользователя (идемпотентно).""" post = self.get_object() deleted, _ = PostLike.objects.filter(post=post, user=request.user).delete() if deleted: @@ -192,7 +243,8 @@ class PostViewSet(viewsets.ModelViewSet): ) post.refresh_from_db(fields=["likes_count"]) return Response( - {"liked": False, "likes_count": post.likes_count}, status=status.HTTP_200_OK + {"liked": False, "likes_count": post.likes_count}, + status=status.HTTP_200_OK, ) @@ -208,7 +260,9 @@ class CommentViewSet(viewsets.ModelViewSet): queryset = Comment.objects.select_related("post", "author").all() serializer_class = CommentSerializer - permission_classes = [IsAuthenticated, IsAuthorOrStaffForComments] + owner_id_attrs = ("author_id",) + owner_obj_attrs = ("author",) + permission_classes = [IsAuthenticated, (IsSelfOrStaff | AdminOrActionOrModelPerms)] parser_classes = [MultiPartParser, FormParser, JSONParser] filter_backends = [filters.OrderingFilter] ordering_fields = ["created_at", "id"] diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index e9bded58..beec4922 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -1,13 +1,16 @@ # backend\api\v1\permissions.py from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple -from django.contrib.auth.models import AbstractBaseUser +from django.contrib.auth.models import AbstractBaseUser, AnonymousUser from employees.constants import DeptPerm from employees.models import Department, EmployeeDepartment -from rest_framework.permissions import (SAFE_METHODS, BasePermission, - DjangoModelPermissions) +from rest_framework.permissions import ( + SAFE_METHODS, + BasePermission, + DjangoModelPermissions, +) MANAGE_PERM = "manage_department" CHANGE_HEAD_PERM = "change_department_head" @@ -25,20 +28,46 @@ def user_is_staffish(user) -> bool: def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool: + """Проверяет, есть ли у пользователя право `perm_code` в указанном отделе. + + Правила: + - Неаутентифицированным → False. + - staff/superuser → True. + - Руководитель отдела → True. + - Иначе: существует активная связь EmployeeDepartment, и у роли есть + DepartmentPermission с нужным code. + + Args: + user (User): Текущий пользователь. + dept (Department): Отдел. + perm_code (str): Код департаментного права (см. DeptPerm.CHOICES). + + Returns: + bool: True, если доступ разрешён. + + Raises: + ValueError: Если `dept` не задан или не имеет id. + """ if not (user and user.is_authenticated): return False - # staff/superuser — сразу ок (оставим как было) - if user_is_staffish(user): + + # staff/superuser — сразу ок + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): return True + + if not isinstance(dept, Department) or getattr(dept, "id", None) is None: + raise ValueError("Argument `dept` must be Department instance with a valid id.") + # руководитель — сразу ок - if user_is_dept_head(user, dept): + if getattr(dept, "head_id", None) == user.id: return True - # основная проверка: активная связь + у роли есть нужный Permission + + # основная проверка: активная связь + у роли есть нужный DepartmentPermission.code return EmployeeDepartment.objects.filter( employee_id=user.id, department_id=dept.id, is_active=True, - role__scoped_permissions__codename=perm_code, + role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename` ).exists() @@ -207,24 +236,14 @@ class AdminOrDeptAllowed(BasePermission): # -------- main checks -------- def has_permission(self, request, view) -> bool: - """Проверка доступа на уровне запроса (до доступа к объекту). - - Правила: - - Неаутентифицированные → False. - - superuser/staff → True. - - Если код не требуется: - - SAFE-методы → allow_safe_without_code ? True : False - - небезопасные → False - - Если код требуется: - - Пытаемся найти department_id в запросе; - - Если нашли → проверяем has_dept_perm(user, dept_id, code); - - Если не нашли: - * если detail-вью → отложить проверку до has_object_permission (вернуть True); - * иначе → False. - - Returns: - bool: Разрешить/запретить доступ. - """ + """См. докстринг класса. Если dept_id не найден, но это detail-запрос, + откладываем проверку до object-level (вернём True).""" + print( + view.action, + view.kwargs, + self.get_required_code(request, view), + self._to_int_or_none(self._extract_dept_id_from_request(request, view)), + ) user = getattr(request, "user", None) if not user or not user.is_authenticated: return False @@ -234,7 +253,7 @@ class AdminOrDeptAllowed(BasePermission): code = self.get_required_code(request, view) if not code: return ( - request.method in SAFE_METHODS + (request.method in SAFE_METHODS) if self.allow_safe_without_code else False ) @@ -242,9 +261,19 @@ class AdminOrDeptAllowed(BasePermission): dept_id = self._to_int_or_none( self._extract_dept_id_from_request(request, view) ) + if dept_id is None: - # detail-экшены проверим на объекте - return True if getattr(view, "detail", False) else False + # robust: считаем detail, если есть lookup в kwargs + lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr( + view, "lookup_field", "pk" + ) + is_detail = getattr(view, "detail", None) + if is_detail is None: + is_detail = lookup_kw in getattr(view, "kwargs", {}) + if is_detail: + # отложим проверку до has_object_permission + return True + return False return has_dept_perm(user, dept_id, code) @@ -280,106 +309,158 @@ class AdminOrDeptAllowed(BasePermission): dept_id = self._to_int_or_none( self._extract_dept_id_from_request(request, view) ) - if dept_id is None: - return False return has_dept_perm(request.user, dept_id, code) class IsSelfOrStaff(BasePermission): - # На class-level пускаем любого аутентифицированного. - def has_permission(self, request, view): - return bool(request.user and request.user.is_authenticated) + """Staff/superuser или владелец объекта (настраиваемые поля владельца через атрибуты вью).""" + + DEFAULT_OWNER_ID_ATTRS = ("author_id", "user_id", "owner_id", "employee_id") + DEFAULT_OWNER_OBJ_ATTRS = ("author", "user", "owner", "employee") + + def has_permission(self, request, view) -> bool: + user = getattr(request, "user", None) + return bool(user and user.is_authenticated) - # На object-level: сам объект, либо staff/superuser. def has_object_permission(self, request, view, obj): user = request.user if not user or not user.is_authenticated: return False + if request.method in SAFE_METHODS: + return True if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): return True - # Основной случай — объект сотрудника. - try: - from employees.models import \ - Employee # локальный импорт, чтобы избежать циклов - except Exception: - Employee = None + # Прочитаем конфиг владельческих полей с вью (если задан) + owner_id_attrs = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS) + owner_obj_attrs = getattr(view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS) + uid = getattr(user, "id", None) - if Employee is not None and isinstance(obj, Employee): - return obj.pk == user.pk + for attr in owner_id_attrs: + if hasattr(obj, attr) and getattr(obj, attr) == uid: + return True + for attr in owner_obj_attrs: + owner = getattr(obj, attr, None) + if owner is not None and getattr(owner, "id", None) == uid: + return True - # Fallback: сравнить id из URL, если obj не Employee (на всякий случай) + # Fallback для вью «/users/{id}/» lookup = getattr(view, "lookup_field", "pk") - return str(view.kwargs.get(lookup)) == str(user.pk) - - -class IsAuthorOrStaffForComments(BasePermission): - """ - Создание/чтение — любой аутентифицированный (проверяет IsAuthenticated во view). - Изменение/удаление — автор комментария или staff/superuser. - """ - - message = "Можно редактировать/удалять только свои комментарии." - - def has_permission(self, request, view): - # IsAuthenticated уже стоит во вьюхе; дублируем True для небезопасных методов, - # чтобы проверка прошла до object-level. - return bool(request.user and request.user.is_authenticated) - - def has_object_permission(self, request, view, obj): - if request.method in SAFE_METHODS: - return True - user = request.user - if not (user and user.is_authenticated): - return False - if user_is_staffish(user): - return True - return getattr(obj, "author_id", None) == getattr(user, "id", None) + return str(view.kwargs.get(lookup)) == str(uid) class AdminOrActionOrModelPerms(DjangoModelPermissions): - """ - Админ/суперпользователь → всегда True. - Если задан код права (view.required_perm_code или required_perms_by_action[action]), - проверяем его через user.has_perm(...). Иначе — стандартные DjangoModelPermissions. + """Комбинированная политика доступа для документов. + + Поведение: + - staff/superuser → полный доступ (всегда True). + - Если на вью задан `required_perm_code` или есть карта + `required_perms_by_action[action]`, проверяем её через `user.has_perm(...)`. + - Иначе: + * для SAFE-методов (`GET/HEAD/OPTIONS`) требуем `view_`; + * для остальных методов используем стандартную карту `DjangoModelPermissions` + (`add/change/delete`). + + Raises: + exceptions.MethodNotAllowed: если передан неизвестный HTTP-метод. """ - def has_permission(self, request, view): + def has_permission(self, request, view) -> bool: + """Проверка доступа на уровне запроса. + + Args: + request: DRF Request. + view: Текущий ViewSet. + + Returns: + bool: Разрешён ли доступ. + """ user = request.user - if not user or not user.is_authenticated: + if not user or isinstance(user, AnonymousUser) or not user.is_authenticated: return False if user.is_superuser or user.is_staff: return True - mapping = getattr(view, "required_perms_by_action", {}) or {} - action = getattr(view, "action", None) - code = getattr(view, "required_perm_code", None) or mapping.get(action) + code = self._explicit_perm_code(view) if code: return user.has_perm(code) + # SAFE-методы → требуем view_ + if request.method in SAFE_METHODS: + model = self._model_from_view(view) + return user.has_perm( + f"{model._meta.app_label}.view_{model._meta.model_name}" + ) + + # Остальные → стандартная логика DjangoModelPermissions return super().has_permission(request, view) - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request, view, obj) -> bool: + """Проверка доступа к конкретному объекту. + + Args: + request: DRF Request. + view: Текущий ViewSet. + obj: Объект модели. + + Returns: + bool: Разрешён ли доступ. + """ user = request.user if user.is_superuser or user.is_staff: return True - mapping = getattr(view, "required_perms_by_action", {}) or {} - action = getattr(view, "action", None) - code = getattr(view, "required_perm_code", None) or mapping.get(action) + code = self._explicit_perm_code(view) if code: return user.has_perm(code) + if request.method in SAFE_METHODS: + meta = obj._meta + return user.has_perm(f"{meta.app_label}.view_{meta.model_name}") + + # Для остальных методов полагаемся на проверку уровня запроса return super().has_object_permission(request, view, obj) + @staticmethod + def _explicit_perm_code(view) -> Optional[str]: + """Возвращает явный код права, заданный на уровне вью. + + Args: + view: DRF ViewSet. + + Returns: + Optional[str]: Код права или None. + """ + mapping = getattr(view, "required_perms_by_action", {}) or {} + action = getattr(view, "action", None) + return getattr(view, "required_perm_code", None) or mapping.get(action) + + @staticmethod + def _model_from_view(view): + """Извлекает модель из view.queryset. + + Args: + view: DRF ViewSet. + + Returns: + type: Класс модели. + + Raises: + AssertionError: если у вью нет queryset/model (для ModelViewSet не ожидается). + """ + qs = getattr(view, "queryset", None) + assert qs is not None and hasattr( + qs, "model" + ), "View must define `.queryset` with `.model`" + return qs.model + __all__ = [ "MANAGE_PERM", "CHANGE_HEAD_PERM", "ASSIGN_ROLE_PERM", "IsSelfOrStaff", - "IsAuthorOrStaffForComments", "AdminOrActionOrModelPerms", "user_is_dept_head", "user_has_dept_perm", diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 5e341b81..828e01cf 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -8,6 +8,7 @@ from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet, RegisterAPIView, ResendEmailAPIView, SkillViewSet, VerifyEmailAPIView) from .feed.views import CommentViewSet, PostViewSet +from .documents.views import DocumentViewSet app_name = "v1" @@ -15,6 +16,8 @@ router = DefaultRouter() router.register(r"calendar/events", CalendarEventsViewSet, basename="events") +router.register(r"documents", DocumentViewSet, basename="documents") + router.register(r"departments", DepartmentViewSet, basename="departments") router.register(r"employees", EmployeeViewSet, basename="employees") router.register(r"employee-actions", EmployeeActionViewSet, basename="employee-actions") diff --git a/backend/employees/constants.py b/backend/employees/constants.py index 00bcdb48..4725f456 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -32,9 +32,14 @@ class DeptPerm: CHANGE_HEAD = "change_department_head" ASSIGN_ROLE = "assign_department_role" MANAGE_CALENDAR = "manage_department_events" + CREATE_POST = "publish_department_post" + MANAGE_FEED = "manage_department_feed" + CHOICES = ( (MANAGE, "Управлять отделом"), (CHANGE_HEAD, "Назначать руководителя"), (ASSIGN_ROLE, "Назначать роли участникам"), (MANAGE_CALENDAR, "Управлять календарём отдела"), + (CREATE_POST, "Публиковать новости на странице отдела"), + (MANAGE_FEED, "Редактировать публикации отдела"), ) diff --git a/backend/employees/migrations/0025_alter_departmentpermission_code.py b/backend/employees/migrations/0025_alter_departmentpermission_code.py new file mode 100644 index 00000000..61b4b24f --- /dev/null +++ b/backend/employees/migrations/0025_alter_departmentpermission_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-09-11 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0024_alter_departmentpermission_code'), + ] + + operations = [ + migrations.AlterField( + model_name='departmentpermission', + name='code', + field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела')], max_length=64, unique=True), + ), + ] diff --git a/backend/eusrr_backend/auth_backends.py b/backend/eusrr_backend/auth_backends.py index 4bdac15f..f1e4ce73 100644 --- a/backend/eusrr_backend/auth_backends.py +++ b/backend/eusrr_backend/auth_backends.py @@ -144,7 +144,7 @@ class PositionRoleBackend(ModelBackend): app_label, sep, codename = perm.partition(".") if not sep: return False - return link.role.permissions.filter( + return link.role.scoped_permissions.filter( content_type__app_label=app_label, codename=codename, ).exists() diff --git a/backend/feed/migrations/0004_alter_post_options.py b/backend/feed/migrations/0004_alter_post_options.py new file mode 100644 index 00000000..06fbdf35 --- /dev/null +++ b/backend/feed/migrations/0004_alter_post_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-09-11 13:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('feed', '0003_alter_post_options'), + ] + + operations = [ + migrations.AlterModelOptions( + name='post', + options={'ordering': ['-pinned', '-created_at'], 'verbose_name': 'Публикация', 'verbose_name_plural': 'Публикации'}, + ), + ] diff --git a/backend/feed/models.py b/backend/feed/models.py index c216baeb..637d3276 100644 --- a/backend/feed/models.py +++ b/backend/feed/models.py @@ -92,10 +92,7 @@ class Post(models.Model): likes_count = models.PositiveIntegerField("Лайки", default=0, db_index=True) class Meta: - permissions = [ - ("publish_company_post", "Может публиковать новости на главной"), - ("publish_department_post", "Может публиковать новости в отделах"), - ] + verbose_name = "Публикация" verbose_name_plural = "Публикации" ordering = ["-pinned", "-created_at"] diff --git a/backend/search/views.py b/backend/search/views.py index 3fcb1187..30fbcbe8 100644 --- a/backend/search/views.py +++ b/backend/search/views.py @@ -1,100 +1,483 @@ -from typing import Dict, Any, List +# backend/search/views.py +from __future__ import annotations + +from typing import Any, Dict, List, Literal, TypedDict, Optional, Tuple + from django.contrib.auth.decorators import login_required +from django.core.exceptions import FieldError from django.db.models import Q, QuerySet +from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.utils.html import escape -# Модели -from feed.models import Post # подставьте актуальный путь -from employees.models import Employee +from feed.models import Post +from employees.models import Employee, Department from requests_app.models import Request -# ====== ВСПОМОГАТЕЛЬНО: доступ к заявлениям ====== + +class SearchItem(TypedDict): + """Элемент единой выдачи поиска. + + Attributes: + model_name (Literal["post","employee","department","request","chat","message","event"]): + Тип найденной сущности. + object (Any): Экземпляр модели, который будет отрисован в шаблоне. + """ + + model_name: Literal[ + "post", "employee", "department", "request", "chat", "message", "event" + ] + object: Any -def _is_hr(user) -> bool: - # считаем HR по группе/правам как вы уже настроили - return user.is_active and ( - user.groups.filter(name="HR").exists() - or user.has_perm("requests_app.can_view_all_requests") - or user.has_perm("requests_app.can_process_requests") +# ------------------------ ВСПОМОГАТЕЛЬНЫЕ УТИЛИТЫ ------------------------ + + +def _is_hr(user: Employee) -> bool: + """Проверяет расширенные права на просмотр заявлений. + + Args: + user (Employee): Текущий пользователь. + + Returns: + bool: True, если пользователь может видеть все заявления. + + Notes: + Используются пермишены из requests_app. + """ + return user.has_perm("requests_app.can_view_all_requests") or user.has_perm( + "requests_app.can_process_requests" ) -def _is_head(user) -> bool: - # есть related_name=headed_departments в Employee - return user.headed_departments.exists() +def _try_extend_q(model_cls: type, base_q: Q, lookups: List[Tuple[str, Any]]) -> Q: + """Пытается добавить условия в Q, аккуратно игнорируя несуществующие поля. + + Для каждого пути из lookups пытаемся выполнить пустой фильтр, чтобы проверить валидность. + Неудачные пути (FieldError) игнорируем. + + Args: + model_cls (type): Класс модели, к которой будет применён Q. + base_q (Q): Исходное условие. + lookups (List[Tuple[str, Any]]): Список пар (путь_к_полю, значение). + + Returns: + Q: Итоговое условие с добавленными (валидными) частями. + """ + q = Q(base_q) + from django.db.models import QuerySet as _QS # локальный импорт для mypy + + qs: _QS = model_cls.objects.all() + for path, value in lookups: + try: + # проверяем валидность пути: попытаемся составить запрос + _ = qs.filter(**{path: value}).values("pk")[:1] + q |= Q(**{path: value}) + except FieldError: + continue + return q -def _allowed_requests_qs(user) -> QuerySet[Request]: - base = Request.objects.select_related("employee", "department", "approver") - if _is_hr(user): - return base - if _is_head(user): - return base.filter( - employee__departments_links__department__head=user, - employee__departments_links__is_active=True, - ).distinct() - return base.filter(employee=user) +def _order_by_first_valid(qs: QuerySet, fields: List[str]) -> QuerySet: + """Сортирует QS по первому валидному полю, иначе возвращает как есть. + + Args: + qs (QuerySet): Исходный QS. + fields (List[str]): Список полей приоритета сортировки. + + Returns: + QuerySet: Отсортированный QS или оригинал. + """ + for f in fields: + try: + test = qs.order_by(f) + # прогрев запроса для раннего исключения FieldError + _ = test.values("pk")[:0] + return test + except FieldError: + continue + return qs -# ====== ПОИСК ====== +# ----------------------------- ОСНОВНЫЕ ПОИСКИ ----------------------------- + + +def _search_posts(query: str) -> QuerySet[Post]: + """Поиск по постам (title/body). + + Args: + query (str): Строка запроса. + + Returns: + QuerySet[Post]: Отсортированный QS. + """ + if not query: + return Post.objects.none() + return ( + Post.objects.filter(Q(title__icontains=query) | Q(body__icontains=query)) + .only("id", "title", "body", "created_at") + .order_by("-created_at") + ) + + +def _search_employees(query: str) -> QuerySet[Employee]: + """Поиск по сотрудникам (ФИО/контакты + принадлежность к отделам + навыки). + + Ищем: + - ФИО, email, телефон + - Принадлежность к отделам (по названию отдела) через EmployeeDepartment + - Навыки (по названию навыка) + + Args: + query (str): Строка запроса. + + Returns: + QuerySet[Employee]: Отсортированный QS без дублей. + + Raises: + ValueError: Если query пустая строка после trim (обрабатывается возвратом .none()). + """ + if not query: + return Employee.objects.none() + + fio_contacts = ( + Q(last_name__icontains=query) + | Q(first_name__icontains=query) + | Q(patronymic__icontains=query) + | Q(email__icontains=query) + | Q(phone_number__icontains=query) + ) + + # 🔹 ВАЖНО: у вас связь через EmployeeDepartment → departments_links + dept_lookups = [ + ("departments_links__department__name__icontains", query), + ] + # Навыки — прямая M2M + skill_lookups = [ + ("skills__name__icontains", query), + ] + + q = _try_extend_q(Employee, fio_contacts, dept_lookups) + q = _try_extend_q(Employee, q, skill_lookups) + + return ( + Employee.objects.filter(q) + .distinct() + .select_related("position") + .only( + "id", + "last_name", + "first_name", + "patronymic", + "email", + "phone_number", + "position__name", + ) + .order_by("last_name", "first_name") + ) + + +def _search_departments(query: str) -> QuerySet[Department]: + """Поиск по отделам (название/описание/руководитель + сотрудники отдела). + + Ищем: + - name / description + - head (ФИО) + - сотрудники отдела (ФИО/e-mail/телефон) — через Department.employeedepartment → employee + + Args: + query (str): Строка запроса. + + Returns: + QuerySet[Department]: Отсортированный QS без дублей. + """ + if not query: + return Department.objects.none() + + base = Q(name__icontains=query) | Q(description__icontains=query) + base |= ( + Q(head__last_name__icontains=query) + | Q(head__first_name__icontains=query) + | Q(head__patronymic__icontains=query) + ) + + # 🔹 ВАЖНО: правильный reverse related_name — "employeedepartment" + member_fields = [ + "last_name__icontains", + "first_name__icontains", + "patronymic__icontains", + "email__icontains", + "phone_number__icontains", + ] + member_lookups = [ + (f"employeedepartment__employee__{f}", query) for f in member_fields + ] + + q = _try_extend_q(Department, base, member_lookups) + + return ( + Department.objects.filter(q) + .distinct() + .select_related("head") + .only("id", "name", "description", "head") + .order_by("name") + ) + + +def _search_events(query: str) -> QuerySet[Any]: + """Поиск по календарным событиям (ваша модель: calendar_app.CalendarEvent). + + Args: + query (str): Строка запроса. + + Returns: + QuerySet[Any]: QS CalendarEvent или пустой QS при отсутствии приложения. + """ + try: + from calendar_app.models import CalendarEvent as EventModel # type: ignore + except Exception: + return Post.objects.none() + + if not query: + return EventModel.objects.none() + + q = Q(title__icontains=query) | Q(description__icontains=query) + q |= Q(department__name__icontains=query) + # других связей (owner/participants) в вашей модели нет + + qs = EventModel.objects.filter(q).distinct() + # Поля дат у вас: start_date/start_time (+ end_*). Сортируем по началу. + qs = qs.order_by("-start_date", "-start_time", "-id") + try: + qs = qs.select_related("department") + except Exception: + pass + return qs + + +def _search_requests(query: str, user: Employee, is_hr: bool) -> QuerySet[Request]: + """Поиск по заявлениям с учётом прав. + + Args: + query (str): Строка запроса. + user (Employee): Текущий пользователь. + is_hr (bool): Признак расширенных прав. + + Returns: + QuerySet[Request]: Отсортированный QS. + + Raises: + ValueError: Если user не передан. + """ + if not user: + raise ValueError("user is required") + + base = ( + Request.objects.filter( + Q(title__icontains=query) + | Q(employee__last_name__icontains=query) + | Q(employee__first_name__icontains=query) + | Q(employee__patronymic__icontains=query) + | Q(department__name__icontains=query) + ) + if query + else Request.objects.none() + ) + + if not is_hr: + base = base.filter(employee=user) + + # Если используем select_related по FK — перечисляем их в only(...) + return ( + base.select_related("employee", "department", "approver") + .only( + "id", + "title", + "status", + "employee", + "department", + "approver", + "created_at", + ) + .order_by("-created_at") + ) + + +def _search_chats(query: str) -> QuerySet[Any]: + """Поиск по чатам (название/участники/отдел). + + Args: + query (str): Строка запроса. + + Returns: + QuerySet[Any]: QS Chat или пустой QS, если приложение чатов отсутствует. + + Notes: + Безопасно обрабатывает разные схемы (name/department/participants). + """ + try: + from communications.models import Chat # type: ignore + except Exception: + return Post.objects.none() # тип «пустой» QS (любой модели) + + if not query: + return Chat.objects.none() + + q = Q() + # Попробуем разные пути без падения + q = _try_extend_q( + Chat, + q, + [ + ("name__icontains", query), + ("title__icontains", query), + ("department__name__icontains", query), + ("participants__last_name__icontains", query), + ("participants__first_name__icontains", query), + ("participants__patronymic__icontains", query), + ("participants__email__icontains", query), + ], + ) + + qs = Chat.objects.filter(q).distinct() + qs = _order_by_first_valid(qs, ["-updated_at", "-last_message_at", "-id"]) + # мягкая оптимизация + try: + qs = qs.select_related("department") + except FieldError: + pass + try: + qs = qs.prefetch_related("participants") + except FieldError: + pass + return qs + + +def _search_messages(query: str) -> QuerySet[Any]: + """Поиск по сообщениям чатов (контент/автор/чат). + + Args: + query (str): Строка запроса. + + Returns: + QuerySet[Any]: QS Message или пустой QS, если приложение чатов отсутствует. + """ + try: + from communications.models import Message # type: ignore + except Exception: + return Post.objects.none() + + if not query: + return Message.objects.none() + + q = Q(content__icontains=query) + q = _try_extend_q( + Message, + q, + [ + ("author__last_name__icontains", query), + ("author__first_name__icontains", query), + ("author__patronymic__icontains", query), + ("chat__name__icontains", query), + ("chat__title__icontains", query), + ("chat__department__name__icontains", query), + ], + ) + + qs = Message.objects.filter(q).distinct() + qs = _order_by_first_valid(qs, ["-created_at", "-id"]) + try: + qs = qs.select_related("author", "chat") + except FieldError: + pass + return qs + + +# -------------------------------- ВЬЮХА -------------------------------- @login_required -def search_view(request): - q = (request.GET.get("q") or "").strip() - query = q[:100] # ограничим длину для безопасности +def search_view(request: HttpRequest) -> HttpResponse: + """Единый поиск по системе (посты, сотрудники, отделы, заявления, чаты, сообщения, события). - results: Dict[str, List[Any]] = {"posts": [], "employees": [], "requests": []} - counts: Dict[str, int] = {"posts": 0, "employees": 0, "requests": 0} + Args: + request (HttpRequest): Запрос (`GET["q"]`). + + Returns: + HttpResponse: Рендер `templates/search/results.html` с плоским списком. + + Контекст шаблона: + - query: str — исходная строка запроса + - results: List[SearchItem] — плоский список элементов + - counts: Dict[str, int] — счётчики по типам: + keys: post, employee, department, request, chat, message, event + - total: int — сумма всех счётчиков + """ + raw_q = request.GET.get("q", "") or "" + query = escape(raw_q.strip()) + + items: List[SearchItem] = [] + counts: Dict[str, int] = { + "post": 0, + "employee": 0, + "department": 0, + "request": 0, + "chat": 0, + "message": 0, + "event": 0, + } if query: - # --- Посты (новости) - posts_qs = Post.objects.all() - posts_qs = ( - posts_qs.filter(Q(title__icontains=query) | Q(body__icontains=query)) - .only("id", "title") - .order_by("-id") - ) - counts["posts"] = posts_qs.count() - results["posts"] = list(posts_qs[:10]) + # --- Посты + post_qs = _search_posts(query) + counts["post"] = post_qs.count() + items.extend({"model_name": "post", "object": p} for p in post_qs[:10]) - # --- Сотрудники - emp_qs = Employee.objects.all() - emp_qs = ( - emp_qs.filter( - Q(first_name__icontains=query) - | Q(last_name__icontains=query) - | Q(patronymic__icontains=query) - | Q(email__icontains=query) - | Q(phone_number__icontains=query) - ) - .only("id", "first_name", "last_name", "patronymic", "email") - .order_by("last_name", "first_name") - ) - counts["employees"] = emp_qs.count() - results["employees"] = list(emp_qs[:10]) + # --- Сотрудники (ФИО/контакты + отделы + навыки) + emp_qs = _search_employees(query) + counts["employee"] = emp_qs.count() + items.extend({"model_name": "employee", "object": e} for e in emp_qs[:10]) - # --- Заявления (с учётом прав) - req_qs = _allowed_requests_qs(request.user) - req_qs = ( - req_qs.filter( - Q(title__icontains=query) - | Q(comment__icontains=query) - | Q(employee__first_name__icontains=query) - | Q(employee__last_name__icontains=query) - ) - .only("id", "title", "status", "employee", "department", "created_at") - .order_by("-created_at") - ) - counts["requests"] = req_qs.count() - results["requests"] = list(req_qs[:10]) + # --- Отделы (в т.ч. по сотрудникам) + dept_qs = _search_departments(query) + counts["department"] = dept_qs.count() + items.extend({"model_name": "department", "object": d} for d in dept_qs[:10]) + + # --- Заявления (учёт прав) + is_hr = _is_hr(request.user) # type: ignore[arg-type] + req_qs = _search_requests(query, request.user, is_hr) # type: ignore[arg-type] + counts["request"] = req_qs.count() + items.extend({"model_name": "request", "object": r} for r in req_qs[:10]) + + # --- Чаты + chat_qs = _search_chats(query) + # Если приложение отсутствует — chat_qs будет пустым QS другой модели, count()=0 + try: + counts["chat"] = chat_qs.count() + except Exception: + counts["chat"] = 0 + items.extend({"model_name": "chat", "object": c} for c in chat_qs[:10]) + + # --- Сообщения + msg_qs = _search_messages(query) + try: + counts["message"] = msg_qs.count() + except Exception: + counts["message"] = 0 + items.extend({"model_name": "message", "object": m} for m in msg_qs[:10]) + + # --- Календарные события + evt_qs = _search_events(query) + try: + counts["event"] = evt_qs.count() + except Exception: + counts["event"] = 0 + items.extend({"model_name": "event", "object": ev} for ev in evt_qs[:10]) - # Пустой запрос — просто страница с формой/подсказкой ctx = { "query": query, - "results": results, + "results": items, "counts": counts, + "total": sum(counts.values()), } return render(request, "search/results.html", ctx) diff --git a/backend/templates/search/results.html b/backend/templates/search/results.html index 9ccab54f..94e46bf6 100644 --- a/backend/templates/search/results.html +++ b/backend/templates/search/results.html @@ -1,19 +1,363 @@ {% extends "base.html" %} +{% load static django_bootstrap5 %} + +{% block title %}Поиск{% endblock %} + +{% block extra_css %} + +{% endblock %} + {% block content %} -

Результаты поиска: "{{ query }}"

- {% if results %} - {% for result in results %} -
- {% if result.model_name == 'post' %} -

{{ result.object.title }}

-

{{ result.object.content|truncatewords:20 }}

- {% elif result.model_name == 'employee' %} -

{{ result.object.name }}

-

{{ result.object.email }} | {{ result.object.position }}

- {% endif %} +
+
+
+ + +
+ +

Поиск

+ +
- {% endfor %} - {% else %} -

Ничего не найдено.

- {% endif %} -{% endblock %} \ No newline at end of file + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + {% if total > 0 %} + + {# ===== ПУБЛИКАЦИИ ===== #} + {% if counts.post %} +
+
+
+
Публикации
+
+
+ {% for item in results %} + {% if item.model_name == 'post' %} + {% with post=item.object %} +
+
+
+ {{ post.title }} +
{{ post.created_at|date:"d.m.Y H:i" }}
+ {% if post.body %}
{{ post.body|truncatewords:35 }}
{% endif %} +
+ +
+ {% endwith %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# ===== СОТРУДНИКИ ===== #} + {% if counts.employee %} +
+
+
+
Сотрудники
+
+
+ {% for item in results %} + {% if item.model_name == 'employee' %} + {% with emp=item.object %} +
+
+ {% if emp.avatar %}{% else %}{% endif %} +
+
+ + {{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %} + +
+ {% if emp.position %}{{ emp.position.name }} · {% endif %} + {{ emp.email }}{% if emp.phone_number %} · {{ emp.phone_number }}{% endif %} +
+
+ +
+ {% endwith %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# ===== ОТДЕЛЫ ===== #} + {% if counts.department %} +
+
+
+
Отделы
+
+
+ {% for item in results %} + {% if item.model_name == 'department' %} + {% with dept=item.object %} +
+
+
+ {{ dept.name }} +
+ {% if dept.head %}Руководитель: {{ dept.head.last_name }} {{ dept.head.first_name }}{% if dept.head.patronymic %} {{ dept.head.patronymic }}{% endif %}{% endif %} +
+ {% if dept.description %}
{{ dept.description|truncatewords:35 }}
{% endif %} +
+ +
+ {% endwith %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# ===== ЗАЯВЛЕНИЯ ===== #} + {% if counts.request %} +
+
+
+
Заявления
+
+
+ {% for item in results %} + {% if item.model_name == 'request' %} + {% with req=item.object %} +
+
+
+ + {% firstof req.title req.get_type_display "Заявление" %} + +
+ Автор: {{ req.employee }}{% if req.department %} · Отдел: {{ req.department }}{% endif %} · {{ req.created_at|date:"d.m.Y H:i" }} +
+ {% if req.comment %}
{{ req.comment|truncatewords:35 }}
{% endif %} +
+
+ {% if req.status == 'pending' %}На рассмотрении + {% elif req.status == 'approved' %}Одобрено + {% elif req.status == 'rejected' %}Отклонено + {% elif req.status == 'cancelled' %}Отменено + {% elif req.status == 'draft' %}Черновик + {% endif %} +
+
+ {% endwith %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# ===== ЧАТЫ ===== #} + {% if counts.chat %} + + {% endif %} + + {# ===== СООБЩЕНИЯ ===== #} + {% if counts.message %} +
+
+
+
Сообщения
+
+
+ {% for item in results %} + {% if item.model_name == 'message' %} + {% with msg=item.object %} + {% firstof msg.chat.name msg.chat.department.name msg.chat.pk as msg_chat_label %} + + {% endwith %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# ===== СОБЫТИЯ ===== #} + {% if counts.event %} +
+
+
+
События
+
+
+ {% for item in results %} + {% if item.model_name == 'event' %} + {% with ev=item.object %} + {% firstof ev.title ev.name "Событие" as ev_title %} +
+
+
+
{{ ev_title }}
+
+ {% if ev.start %}Начало: {{ ev.start|date:"d.m.Y H:i" }} + {% elif ev.starts_at %}Начало: {{ ev.starts_at|date:"d.m.Y H:i" }}{% endif %} + {% if ev.location %} · {{ ev.location }}{% endif %} + {% if ev.department %} · {% firstof ev.department.name ev.department " " %}{% endif %} +
+
+
+ {% endwith %} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {% else %} +
Ничего не найдено.
+ {% endif %} + +
+
+
+ + +{% endblock %} diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py new file mode 100644 index 00000000..0a442aef --- /dev/null +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -0,0 +1,905 @@ +# backend/tests/api/v1/documents/test_documents_api.py +from __future__ import annotations + +""" +Тесты CRUD API документов (/api/v1/documents/ + экшен acknowledge) по чек-листу. + +ВАЖНО: API ещё не реализовано — эти тесты задают ожидаемое поведение (TDD). +Все функции и классы снабжены докстрингами (Google-style). +""" + +import itertools +from typing import Iterable + +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.urls import reverse +from django.utils import timezone +from django.core.files.uploadedfile import SimpleUploadedFile +from django.conf import settings +from rest_framework import status +from rest_framework.test import APIClient + +from documents.models import Document, DocumentAcknowledgement + +pytestmark = pytest.mark.django_db + +User = get_user_model() + +# -------------- helpers / factories -------------- + +_phone_seq = itertools.count(1000) + + +def _unique_phone() -> str: + """Генерирует уникальный валидный E.164 номер в рамках сессии тестов. + + Returns: + str: Телефонный номер в формате +7999000xxx. + """ + return f"+7999000{next(_phone_seq):03d}" + + +def _file(name: str = "doc.pdf", content: bytes = b"x") -> SimpleUploadedFile: + """Создаёт минимальный загружаемый файл. + + Args: + name (str): Имя файла. + content (bytes): Содержимое. + + Returns: + SimpleUploadedFile: Файл для multipart POST/PATCH. + """ + return SimpleUploadedFile( + name=name, content=content, content_type="application/pdf" + ) + + +def grant_perms(user: User, *codenames: str) -> None: + """Выдаёт пользователю модельные права по кодовым именам из приложения documents. + + Args: + user (User): Пользователь. + *codenames (str): Набор кодов прав (например, "view_document"). + + Raises: + Permission.DoesNotExist: Если указанного права не существует. + """ + for code in codenames: + perm = Permission.objects.get( + codename=code, content_type__app_label="documents" + ) + user.user_permissions.add(perm) + user.save(update_fields=[]) + + +def make_document( + *, + title: str = "Doc", + uploaded_by: User, + description: str = "desc", + sent_to_all: bool = True, + recipients: Iterable[User] | None = None, +) -> Document: + """Создаёт Document напрямую (минует API). + + Args: + title (str): Заголовок. + uploaded_by (User): Кто загрузил. + description (str): Описание. + sent_to_all (bool): Признак рассылки всем. + recipients (Iterable[User] | None): Конкретные получатели при sent_to_all=false. + + Returns: + Document: Созданная модель с прикреплённым файлом. + """ + doc = Document.objects.create( + title=title, + description=description, + uploaded_by=uploaded_by, + uploaded_at=timezone.now(), + sent_to_all=sent_to_all, + file=_file(), + ) + if not sent_to_all and recipients: + doc.recipients.set(recipients) + return doc + + +@pytest.fixture +def make_user(user_factory): + """Фабрика пользователей для тестов API уровня документов. + + Args: + user_factory (Callable): Фабрика из tests/conftest.py. + + Returns: + Callable[..., User]: Функция создания Employee. + """ + + def _make(email: str, **extra) -> User: + return user_factory(email=email, **extra) + + return _make + + +@pytest.fixture +def auth_client(auth_client_factory): + """Фабрика DRF-клиентов, аутентифицированных под заданным пользователем. + + Args: + auth_client_factory: Фабрика из tests/conftest.py. + + Returns: + Callable[[User|None], APIClient]: Клиент с force_authenticate. + """ + return auth_client_factory + + +@pytest.fixture +def api_urls(): + """Возвращает именованные URL API документов. + + Returns: + dict: Словарь с ключами list, detail(id), ack(id). + """ + + def _detail(pk: int) -> str: + return reverse("api:v1:documents-detail", args=[pk]) + + def _ack(pk: int) -> str: + return reverse("api:v1:documents-acknowledge", args=[pk]) + + return { + "list": reverse("api:v1:documents-list"), + "detail": _detail, + "ack": _ack, + } + + +# -------------------- A. Роли и доступ -------------------- + + +class TestAuthAndPermissions: + """A. Роли и доступ — статусы для разных ролей и прав.""" + + def test_anonymous_all_401(self, api_client: APIClient, api_urls): + """Аноним: все запросы возвращают 401.""" + list_url = api_urls["list"] + # list + assert api_client.get(list_url).status_code == status.HTTP_401_UNAUTHORIZED + # create + r = api_client.post(list_url, {"title": "T"}, format="multipart") + assert r.status_code == status.HTTP_401_UNAUTHORIZED + + # prepare object for other methods + author = User.objects.create( + email="auth@example.com", phone_number=_unique_phone() + ) + d = make_document(uploaded_by=author) + + # detail + assert ( + api_client.get(api_urls["detail"](d.pk)).status_code + == status.HTTP_401_UNAUTHORIZED + ) + # put/patch/delete + assert ( + api_client.put( + api_urls["detail"](d.pk), {"title": "X"}, format="json" + ).status_code + == 401 + ) + assert ( + api_client.patch( + api_urls["detail"](d.pk), {"title": "X"}, format="json" + ).status_code + == 401 + ) + assert api_client.delete(api_urls["detail"](d.pk)).status_code == 401 + # acknowledge action + assert api_client.post(api_urls["ack"](d.pk), {}).status_code == 401 + + def test_regular_user_forbidden_except_ack(self, auth_client, make_user, api_urls): + """Обычный user без прав: CRUD → 403, ACK → 200. + + По текущей спецификации экшен acknowledge пускает любого аутентифицированного. + Этот тест фиксирует такое поведение. + """ + u = make_user("u@example.com") + client = auth_client(u) + + list_url = api_urls["list"] + assert client.get(list_url).status_code == 403 + # POST (multipart) + resp = client.post( + list_url, + {"title": "T", "file": _file(), "sent_to_all": True}, + format="multipart", + ) + assert resp.status_code == 403 + + # object + author = make_user("a@example.com") + d = make_document(uploaded_by=author) + + # detail & write ops + assert client.get(api_urls["detail"](d.pk)).status_code == 403 + assert ( + client.put( + api_urls["detail"](d.pk), {"title": "X"}, format="json" + ).status_code + == 403 + ) + assert ( + client.patch( + api_urls["detail"](d.pk), {"title": "X"}, format="json" + ).status_code + == 403 + ) + assert client.delete(api_urls["detail"](d.pk)).status_code == 403 + + # acknowledge → 200 + r = client.post(api_urls["ack"](d.pk), {}) + assert r.status_code == 200 + assert r.json().get("ok") is True + + @pytest.mark.parametrize( + "perm,method,expected", + [ + ("view_document", "get_list", 200), + ("view_document", "get_detail", 200), + ("add_document", "post", 201), + ("change_document", "put", 200), + ("change_document", "patch", 200), + ("delete_document", "delete", 204), + ], + ) + def test_users_with_individual_perms( + self, auth_client, make_user, api_urls, perm, method, expected + ): + """Пользователь с конкретным правом получает соответствующий доступ.""" + author = make_user("author@example.com") + target = make_user("p@example.com") + grant_perms(target, perm) + + client = auth_client(target) + list_url = api_urls["list"] + + # set up object + d = make_document(uploaded_by=author) + + if method == "get_list": + r = client.get(list_url) + assert r.status_code == expected + elif method == "get_detail": + r = client.get(api_urls["detail"](d.pk)) + assert r.status_code == expected + elif method == "post": + data = {"title": "T", "file": _file(), "sent_to_all": True} + r = client.post(list_url, data, format="multipart") + assert r.status_code == expected, r.content + elif method == "put": + r = client.put( + api_urls["detail"](d.pk), + {"title": "X", "description": "D", "sent_to_all": True}, + format="json", + ) + assert r.status_code == expected + elif method == "patch": + r = client.patch(api_urls["detail"](d.pk), {"title": "X"}, format="json") + assert r.status_code == expected + elif method == "delete": + r = client.delete(api_urls["detail"](d.pk)) + assert r.status_code == expected + else: + raise AssertionError("unknown method") + + @pytest.mark.parametrize( + "is_staff,is_superuser", [(True, False), (True, True), (False, True)] + ) + def test_staff_and_superuser_full_access( + self, auth_client, make_user, api_urls, is_staff, is_superuser + ): + """staff/superuser: полный доступ (все запросы 2xx).""" + author = make_user("author@example.com") + admin = make_user("admin@example.com", staff=is_staff, superuser=is_superuser) + client = auth_client(admin) + + # create + r = client.post( + api_urls["list"], + {"title": "T", "file": _file(), "sent_to_all": True}, + format="multipart", + ) + assert r.status_code in (200, 201) + doc_id = r.json().get("id") + + # list/detail + assert client.get(api_urls["list"]).status_code == 200 + assert client.get(api_urls["detail"](doc_id)).status_code == 200 + + # update + assert ( + client.patch( + api_urls["detail"](doc_id), {"title": "TT"}, format="json" + ).status_code + == 200 + ) + # delete + assert client.delete(api_urls["detail"](doc_id)).status_code in (200, 204) + + +# -------------------- B. Создание (POST) -------------------- + +@pytest.mark.django_db +class TestCreate: + """B. Создание документа multipart/form-data.""" + + def test_create_sent_to_all_true( + self, auth_client, make_user, api_urls, monkeypatch + ): + """Успех: sent_to_all=true → 201 и корректное тело ответа + вызов notify() один раз.""" + uploader = make_user("uploader@example.com", staff=True) + client = auth_client(uploader) + + called = {"n": 0, "doc": None} + + def fake_notify(doc): + called["n"] += 1 + called["doc"] = doc + + # замокаем side-effect + monkeypatch.setattr( + "documents.notification.notify_users_about_document", + fake_notify, + raising=False, + ) + + data = {"title": "Title", "file": _file(), "sent_to_all": True} + r = client.post(api_urls["list"], data, format="multipart") + assert r.status_code in (200, 201), r.content + body = r.json() + assert {"id", "title", "file_url", "sent_to_all"} <= body.keys() + assert body["sent_to_all"] is True + assert body["title"] == "Title" + assert isinstance(body["id"], int) + assert body["file_url"] + + # notify был вызван один раз уже c назначенными получателями + assert called["n"] == 1 + assert isinstance(called["doc"], Document) + + def test_create_sent_to_all_false_with_recipients_variants( + self, auth_client, make_user, api_urls + ): + """sent_to_all=false + recipient_ids (repeat / JSON / CSV) → 201; recipient_count = числу активных.""" + uploader = make_user("uploader@example.com", staff=True) + client = auth_client(uploader) + + # активные + r1 = make_user("r1@example.com", active=True) + r2 = make_user("r2@example.com", active=True) + # неактивный + r3 = make_user("r3@example.com", active=False) + + list_url = api_urls["list"] + + # 1) repeat-params + data1 = { + "title": "T1", + "file": _file(), + "sent_to_all": False, + "recipient_ids": [r1.id, r2.id, r3.id], + } + resp1 = client.post(list_url, data1, format="multipart") + assert resp1.status_code in (200, 201), resp1.content + body1 = resp1.json() + assert body1.get("recipient_count") == 2 # только активные + # 2) JSON-массив + data2 = { + "title": "T2", + "file": _file(name="f2.pdf"), + "sent_to_all": False, + "recipient_ids": f"[{r1.id},{r3.id}]", + } + resp2 = client.post(list_url, data2, format="multipart") + assert resp2.status_code in (200, 201), resp2.content + assert resp2.json().get("recipient_count") == 1 + # 3) CSV-строка + data3 = { + "title": "T3", + "file": _file(name="f3.pdf"), + "sent_to_all": False, + "recipient_ids": f"{r1.id},{r2.id},{999999}", + } + resp3 = client.post(list_url, data3, format="multipart") + assert resp3.status_code in (200, 201), resp3.content + assert resp3.json().get("recipient_count") == 2 + + def test_create_validations(self, auth_client, make_user, api_urls): + """Валидации обязательных полей и recipient_ids.""" + uploader = make_user("uploader@example.com", staff=True) + client = auth_client(uploader) + url = api_urls["list"] + + # нет title + r = client.post(url, {"file": _file(), "sent_to_all": True}, format="multipart") + assert r.status_code == 400 + + # нет file + r = client.post(url, {"title": "T", "sent_to_all": True}, format="multipart") + assert r.status_code == 400 + + # sent_to_all=false и пустые recipient_ids → 400 + r = client.post(url, {"title": "T", "sent_to_all": False}, format="multipart") + assert r.status_code == 400 + + def test_create_skips_nonexistent_or_inactive_recipients( + self, auth_client, make_user, api_urls + ): + """recipient_ids с несуществующими/неактивными → создаётся без них; в recipients их нет.""" + uploader = make_user("uploader@example.com", staff=True) + client = auth_client(uploader) + + active = make_user("a@example.com", active=True) + inactive = make_user("i@example.com", active=False) + + r = client.post( + api_urls["list"], + { + "title": "T", + "file": _file(), + "sent_to_all": False, + "recipient_ids": [active.id, inactive.id, 999999], + }, + format="multipart", + ) + assert r.status_code in (200, 201) + doc_id = r.json()["id"] + doc = Document.objects.get(pk=doc_id) + ids = set(doc.recipients.values_list("id", flat=True)) + assert active.id in ids + assert inactive.id not in ids + + +# -------------------- C. Чтение (GET) -------------------- + + +class TestRead: + """C. Чтение: list/detail, структура, пагинация, file_url.""" + + def test_get_list_with_view_perm_and_pagination( + self, auth_client, make_user, api_urls + ): + """GET list с правом view_document → 200 + DRF пагинация и page_size=20.""" + author = make_user("author@example.com") + viewer = make_user("viewer@example.com") + grant_perms(viewer, "view_document") + client = auth_client(viewer) + + for i in range(25): + make_document(uploaded_by=author, title=f"D{i:02d}") + + r1 = client.get(api_urls["list"]) + assert r1.status_code == 200 + data = r1.json() + assert {"count", "next", "previous", "results"} <= data.keys() + assert len(data["results"]) == 20 + + # вторая страница + r2 = client.get(api_urls["list"], {"page": 2}) + assert r2.status_code == 200 + assert len(r2.json()["results"]) == 5 + + # проверим базовые поля + sample = data["results"][0] + expected = { + "id", + "title", + "description", + "uploaded_by", + "uploaded_at", + "sent_to_all", + "recipients", + "file_url", + "is_acknowledged", + } + assert expected <= set(sample.keys()) + + def test_get_detail_fields_and_file_url( + self, auth_client, make_user, api_urls, settings + ): + """GET detail возвращает нужные поля; is_acknowledged=false до отметки; file_url внутри MEDIA_URL.""" + author = make_user("author@example.com") + viewer = make_user("viewer@example.com") + grant_perms(viewer, "view_document") + client = auth_client(viewer) + + doc = make_document(uploaded_by=author, title="T") + r = client.get(api_urls["detail"](doc.pk)) + assert r.status_code == 200 + body = r.json() + assert body["is_acknowledged"] is False + assert body["file_url"] + assert body["file_url"].startswith(settings.MEDIA_URL) + + +# -------------------- D. Обновление (PUT/PATCH) -------------------- + + +class TestUpdate: + """D. Обновление документа (PUT/PATCH).""" + + def test_update_title_description(self, auth_client, make_user, api_urls): + """PUT/PATCH обновляют title/description.""" + author = make_user("author@example.com") + editor = make_user("editor@example.com") + grant_perms(editor, "change_document") + client = auth_client(editor) + + d = make_document(uploaded_by=author, title="Old", description="old") + r = client.patch( + api_urls["detail"](d.pk), + {"title": "New", "description": "new"}, + format="json", + ) + assert r.status_code == 200 + d.refresh_from_db() + assert d.title == "New" + assert d.description == "new" + + def test_replace_file_multipart_patch(self, auth_client, make_user, api_urls): + """Заменить file (multipart PATCH) → file_url изменился.""" + author = make_user("author@example.com") + editor = make_user("editor@example.com") + grant_perms(editor, "change_document") + client = auth_client(editor) + d = make_document(uploaded_by=author) + # получим старый url через detail (этап API) + r1 = client.get(api_urls["detail"](d.pk)) + old_url = r1.json()["file_url"] + + r2 = client.patch( + api_urls["detail"](d.pk), + {"file": _file(name="new.pdf")}, + format="multipart", + ) + assert r2.status_code == 200 + new_url = r2.json()["file_url"] + assert new_url != old_url + + def test_toggle_sent_to_all_affects_recipients( + self, auth_client, make_user, api_urls + ): + """Переключение sent_to_all ↔ влияет на recipients.""" + author = make_user("author@example.com") + editor = make_user("editor@example.com") + r1 = make_user("r1@example.com") + r2 = make_user("r2@example.com") + grant_perms(editor, "change_document") + client = auth_client(editor) + + d = make_document(uploaded_by=author, sent_to_all=False, recipients=[r1, r2]) + + # false -> true -> recipients очищены + r = client.patch(api_urls["detail"](d.pk), {"sent_to_all": True}, format="json") + assert r.status_code == 200 + d.refresh_from_db() + assert d.sent_to_all is True + assert d.recipients.count() == 0 + + # true -> false с recipient_ids + r = client.patch( + api_urls["detail"](d.pk), + {"sent_to_all": False, "recipient_ids": [r2.id]}, + format="json", + ) + assert r.status_code == 200 + d.refresh_from_db() + assert d.sent_to_all is False + assert list(d.recipients.values_list("id", flat=True)) == [r2.id] + + def test_patch_only_recipient_ids_replaces_list( + self, auth_client, make_user, api_urls + ): + """Частичный PATCH только recipient_ids (при sent_to_all=false) → замена списка.""" + author = make_user("author@example.com") + editor = make_user("editor@example.com") + a = make_user("a@example.com") + b = make_user("b@example.com") + c = make_user("c@example.com") + grant_perms(editor, "change_document") + client = auth_client(editor) + + d = make_document(uploaded_by=author, sent_to_all=False, recipients=[a, b]) + r = client.patch( + api_urls["detail"](d.pk), {"recipient_ids": [c.id]}, format="json" + ) + assert r.status_code == 200 + d.refresh_from_db() + assert list(d.recipients.values_list("id", flat=True)) == [c.id] + + +# -------------------- E. Удаление (DELETE) -------------------- + + +class TestDelete: + """E. Удаление документа.""" + + def test_delete_and_cascade_acknowledgements( + self, auth_client, make_user, api_urls + ): + """DELETE → 204; последующий GET → 404; каскад: DocumentAcknowledgement удалены.""" + author = make_user("author@example.com") + deleter = make_user("deleter@example.com") + grant_perms(deleter, "delete_document") + client = auth_client(deleter) + + d = make_document(uploaded_by=author) + # создадим ack + ack_user = make_user("ack@example.com") + DocumentAcknowledgement.objects.create(document=d, user=ack_user) + + r = client.delete(api_urls["detail"](d.pk)) + assert r.status_code in (200, 204) + + # detail теперь 404 + r2 = client.get(api_urls["detail"](d.pk)) + assert r2.status_code == 404 + + # каскад: записей нет + assert not DocumentAcknowledgement.objects.filter(document=d).exists() + + +# -------------------- F. Экшен acknowledge -------------------- + + +class TestAcknowledge: + """F. Экшен POST /{id}/acknowledge/ — идемпотентность и политика доступа.""" + + def test_ack_first_and_repeat(self, auth_client, make_user, api_urls): + """Первый вызов создаёт запись, повторный — already=true (200 оба раза).""" + author = make_user("author@example.com") + any_user = make_user("u@example.com") + client = auth_client(any_user) + + d = make_document( + uploaded_by=author, sent_to_all=False + ) # умышленно не в получателях + + r1 = client.post(api_urls["ack"](d.pk), {}) + assert r1.status_code == 200 + assert r1.json() == {"ok": True, "already": False} + assert ( + DocumentAcknowledgement.objects.filter(document=d, user=any_user).count() + == 1 + ) + + # повторный + r2 = client.post(api_urls["ack"](d.pk), {}) + assert r2.status_code == 200 + assert r2.json() == {"ok": True, "already": True} + assert ( + DocumentAcknowledgement.objects.filter(document=d, user=any_user).count() + == 1 + ) + + def test_detail_reflects_is_acknowledged(self, auth_client, make_user, api_urls): + """GET detail после отметки → is_acknowledged=true.""" + author = make_user("author@example.com") + viewer = make_user("viewer@example.com") + grant_perms(viewer, "view_document") + client = auth_client(viewer) + + d = make_document(uploaded_by=author) + # пока false + assert client.get(api_urls["detail"](d.pk)).json()["is_acknowledged"] is False + # отметим + other = make_user("x@example.com") # не влияет на viewer + DocumentAcknowledgement.objects.create(document=d, user=viewer) + assert client.get(api_urls["detail"](d.pk)).json()["is_acknowledged"] is True + + def test_policy_ack_even_if_not_recipient_current_behavior( + self, auth_client, make_user, api_urls + ): + """Пользователь вне recipients и при sent_to_all=false тоже может ACK (текущая политика).""" + author = make_user("author@example.com") + u = make_user("u@example.com") + client = auth_client(u) + + r = client.post( + api_urls["ack"](make_document(uploaded_by=author, sent_to_all=False).pk), {} + ) + assert r.status_code == 200 + # Если это поведение нежелательно — создать тикет на ужесточение. + + +# -------------------- G. Сериализация и данные -------------------- + + +class TestSerialization: + """G. Сериализация: uploaded_by/recipients поля и скачивание файла.""" + + def test_uploaded_by_and_recipients_shape(self, auth_client, make_user, api_urls): + """uploaded_by и recipients[] содержат id, full_name, email; full_name корректен.""" + author = make_user("author@example.com", first_name="Иван", last_name="Иванов") + viewer = make_user("viewer@example.com") + grant_perms(viewer, "view_document") + client = auth_client(viewer) + + r1 = make_user("r1@example.com", first_name="Петр", last_name="Петров") + r2 = make_user("r2@example.com", first_name="Сидор", last_name="Сидоров") + d = make_document(uploaded_by=author, sent_to_all=False, recipients=[r1, r2]) + + body = client.get(api_urls["detail"](d.pk)).json() + ub = body["uploaded_by"] + assert {"id", "full_name", "email"} <= ub.keys() + assert "Иван" in ub["full_name"] + + rec = body["recipients"] + assert isinstance(rec, list) and rec + assert {"id", "full_name", "email"} <= rec[0].keys() + full_names = [x["full_name"] for x in rec] + assert any("Петр" in fn for fn in full_names) + + def test_file_url_downloadable_in_dev( + self, client, auth_client, make_user, api_urls + ): + """Интеграционный: можно скачать файл по file_url (если DEBUG и MEDIA настроены).""" + author = make_user("author@example.com") + viewer = make_user("viewer@example.com") + grant_perms(viewer, "view_document") + api = auth_client(viewer) + + d = make_document(uploaded_by=author) + file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"] + + # обычный Django client (без APIClient) для скачивания + resp = client.get(file_url) + assert resp.status_code == 200 + assert resp.content # не пусто + + +# -------------------- H. Ошибки/краевые случаи -------------------- + + +class TestErrorsAndEdgeCases: + """H. Неудачные Content-Type, perms и размер файла.""" + + def test_wrong_content_type_on_create(self, auth_client, make_user, api_urls): + """Неверный Content-Type (JSON без файла) → 400 или 415 (фиксируем фактическое поведение).""" + uploader = make_user("uploader@example.com", staff=True) + client = auth_client(uploader) + r = client.post( + api_urls["list"], {"title": "X", "sent_to_all": True}, format="json" + ) + assert r.status_code in (400, 415) + + def test_put_patch_delete_without_perms(self, auth_client, make_user, api_urls): + """Попытка PUT/PATCH/DELETE без прав → 403.""" + user = make_user("u@example.com") + client = auth_client(user) + author = make_user("a@example.com") + d = make_document(uploaded_by=author) + assert ( + client.put( + api_urls["detail"](d.pk), {"title": "X"}, format="json" + ).status_code + == 403 + ) + assert ( + client.patch( + api_urls["detail"](d.pk), {"title": "X"}, format="json" + ).status_code + == 403 + ) + assert client.delete(api_urls["detail"](d.pk)).status_code == 403 + + def test_get_without_view_perm(self, auth_client, make_user, api_urls): + """GET list/detail без view_document → 403.""" + user = make_user("u@example.com") + client = auth_client(user) + author = make_user("a@example.com") + d = make_document(uploaded_by=author) + assert client.get(api_urls["list"]).status_code == 403 + assert client.get(api_urls["detail"](d.pk)).status_code == 403 + + def test_big_file_over_limit_if_configured( + self, auth_client, make_user, api_urls, settings + ): + """Большой файл > лимита → ожидаем 413 или 400 (если лимит включён); иначе 201.""" + uploader = make_user("uploader@example.com", staff=True) + client = auth_client(uploader) + + big = SimpleUploadedFile("big.bin", b"x" * (5 * 1024 * 1024)) # 5 MB + r = client.post( + api_urls["list"], + {"title": "Big", "file": big, "sent_to_all": True}, + format="multipart", + ) + if getattr(settings, "DATA_UPLOAD_MAX_MEMORY_SIZE", None): + assert r.status_code in (400, 413) + else: + # фиксируем текущий результат при отсутствии лимита + assert r.status_code in (200, 201) + + +# -------------------- I. Производительность/фильтры -------------------- + + +class TestPerformance: + """I. N+1 и пагинация.""" + + def test_no_n_plus_one_in_list( + self, django_assert_num_queries, auth_client, make_user, api_urls + ): + """Листинг с prefetch/select_related: количество SQL не растёт линейно.""" + author = make_user("author@example.com") + viewer = make_user("viewer@example.com") + grant_perms(viewer, "view_document") + client = auth_client(viewer) + + # создадим 1 и 10 документов + make_document(uploaded_by=author, title="one") + for i in range(10): + make_document(uploaded_by=author, title=f"doc{i:02d}") + + # допуская кэш/хитрости, выставим безопасный порог + with django_assert_num_queries(10): + r = client.get(api_urls["list"]) + assert r.status_code == 200 + + def test_pagination_navigation(self, auth_client, make_user, api_urls): + """Навигация по страницам с глобальной DRF-пагинацией.""" + author = make_user("author@example.com") + viewer = make_user("viewer@example.com") + grant_perms(viewer, "view_document") + client = auth_client(viewer) + + for i in range(45): + make_document(uploaded_by=author, title=f"D{i}") + + # page=1 + r1 = client.get(api_urls["list"], {"page": 1}) + assert r1.status_code == 200 + # page=3 (последняя) + r3 = client.get(api_urls["list"], {"page": 3}) + assert r3.status_code == 200 + + +# -------------------- J. Совместимость и безопасность -------------------- + + +class TestSecurityAndPolicy: + """J. Базовая совместимость по авторизации и политика acknowledge.""" + + def test_jwt_or_session_happy_path(self, api_client: APIClient): + """Проверка базовой настройки DRF: IsAuthenticated + DjangoModelPermissions включены глобально. + + Тест не бьёт конкретный endpoint, а страхует глобальную конфигурацию. + """ + from django.conf import settings as dj_settings + + assert "rest_framework.permissions.IsAuthenticated" in str( + dj_settings.REST_FRAMEWORK.get("DEFAULT_PERMISSION_CLASSES", []) + ) + assert "rest_framework.permissions.DjangoModelPermissions" in str( + dj_settings.REST_FRAMEWORK.get("DEFAULT_PERMISSION_CLASSES", []) + ) + + def test_policy_ack_without_perms_is_allowed_but_crud_is_not( + self, auth_client, make_user, api_urls + ): + """Авторизованный без perms не видит список/деталь (403), но может acknowledge (200) — подтвердить спецификацию.""" + u = make_user("u@example.com") + client = auth_client(u) + author = make_user("a@example.com") + d = make_document(uploaded_by=author, sent_to_all=False) + + assert client.get(api_urls["list"]).status_code == 403 + assert client.get(api_urls["detail"](d.pk)).status_code == 403 + + r = client.post(api_urls["ack"](d.pk), {}) + assert r.status_code == 200 diff --git a/backend/tests/api/v1/employees/test_department_head_rights.py b/backend/tests/api/v1/employees/test_department_head_rights.py index 318c7375..63633318 100644 --- a/backend/tests/api/v1/employees/test_department_head_rights.py +++ b/backend/tests/api/v1/employees/test_department_head_rights.py @@ -152,7 +152,6 @@ def test_head_can_change_head_and_loses_rights_afterwards(api_client: APIClient) # старый руководитель меняет руководителя на другого api_client.force_authenticate(user=old_head) url_set_head = reverse("api:v1:departments-set-head", args=[d.id]) - print("DEBUG:", new_head.id, new_head.email_verified, new_head.is_active) resp = api_client.post(url_set_head, {"head_id": new_head.id}, format="json") assert resp.status_code == status.HTTP_200_OK assert resp.json()["head"]["id"] == new_head.id diff --git a/backend/tests/api/v1/feed/test_posts.py b/backend/tests/api/v1/feed/test_posts.py index f8c81e4d..4a1f17b9 100644 --- a/backend/tests/api/v1/feed/test_posts.py +++ b/backend/tests/api/v1/feed/test_posts.py @@ -1,252 +1,656 @@ +# backend/tests/api/v1/feed/test_feed_api.py +from __future__ import annotations + +""" +Тесты API фида (посты, комментарии, лайки) под новую политику доступа: +- права на объекты отдела выдаются через роль внутри конкретного отдела; +- company-посты требуют глобальных модельных прав/статуса staff. +""" + +from typing import Iterable, Optional, Tuple + import pytest -from django.urls import reverse +from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from employees.constants import DeptPerm +from employees.models import ( + Department, + DepartmentPermission, + DepartmentRole, + EmployeeDepartment, +) +from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT +from feed.models import Comment, Post, PostLike from rest_framework import status +from rest_framework.test import APIClient -from employees.models import Employee, Department, EmployeeDepartment, DepartmentRole -from feed.models import Post -from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE +User = get_user_model() + +pytestmark = pytest.mark.django_db -# ----------------- helpers ----------------- +# ------------------------ helpers ------------------------ -_seq = 1 -def _uniq_email(prefix="user"): - global _seq - _seq += 1 - return f"{prefix}{_seq}@example.com" -def _uniq_phone(): - global _seq - _seq += 1 - return f"+7999{_seq:07d}" +def _ensure_permission( + app_label: str, model: type, codename: str, name: Optional[str] = None +) -> Permission: + """Возвращает Permission с указанным codename, создаёт при отсутствии. -def _user(staff=False, superuser=False) -> Employee: - u = Employee.objects.create_user( - email=_uniq_email(), password="pass", - phone_number=_uniq_phone(), - send_activation_email=False, first_name="T", last_name="U", + Примечание: + app_label параметр сейчас не используется и нужен для унификации вызовов. + + Args: + app_label (str): Игнорируется. + model (type): Класс модели (для ContentType). + codename (str): Код права (напр. 'publish_company_post'). + name (Optional[str]): Человекочитаемое имя права. + + Returns: + Permission: Объект права. + """ + ct = ContentType.objects.get_for_model(model) + perm, _ = Permission.objects.get_or_create( + content_type=ct, + codename=codename, + defaults={"name": name or codename.replace("_", " ").title()}, ) - if staff: - u.is_staff = True - if superuser: - u.is_superuser = True - u.email_verified = True - u.is_active = True - u.save(update_fields=["is_staff", "is_superuser", "email_verified", "is_active"]) - # сбрасываем кеши прав на всякий - for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): - if hasattr(u, attr): - try: delattr(u, attr) - except: pass - return u + return perm -def _grant_user_perm(user: Employee, perm_code: str): - """ - perm_code: 'app_label.codename', напр. 'feed.publish_company_post' - """ - app_label, codename = perm_code.split(".", 1) - p = Permission.objects.get(content_type__app_label=app_label, codename=codename) - user.user_permissions.add(p) - user.save() - for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): - if hasattr(user, attr): - try: delattr(user, attr) - except: pass - return p -def _grant_dept_role_perm(user: Employee, dept: Department, perm_code: str): +def _mk_dept(name: str = "Dept", *, head: Optional[User] = None) -> Department: + """Создаёт отдел. + + Args: + name (str): Название отдела. + head (Optional[User]): Руководитель. + + Returns: + Department: Созданный отдел. """ - Создаёт DepartmentRole с указанным Permission и выдаёт её пользователю в отделе. - """ - app_label, codename = perm_code.split(".", 1) - p = Permission.objects.get(content_type__app_label=app_label, codename=codename) - role = DepartmentRole.objects.create(department=dept, name="Publisher") - role.permissions.add(p) - EmployeeDepartment.objects.create(employee=user, department=dept, role=role, is_active=True) + return Department.objects.create(name=name, head=head) + + +def _ensure_dept_permission(code: str, name: str | None = None) -> DepartmentPermission: + """Возвращает/создаёт DepartmentPermission с заданным кодом.""" + perm, _ = DepartmentPermission.objects.get_or_create( + code=code, + defaults={"name": name or code.replace("_", " ").title()}, + ) + return perm + + +def _give_dept_role_with_perms(user, dept, *, perm_codenames, role_name="Role"): + """Создаёт роль отдела, вешает scoped_permissions и назначает пользователю.""" + role = DepartmentRole.objects.create(name=role_name, department=dept) + for code in perm_codenames: + dp = _ensure_dept_permission(code) + role.scoped_permissions.add( + dp + ) # ⟵ ВАЖНО: scoped_permissions, не auth.Permission + EmployeeDepartment.objects.create( + employee=user, department=dept, role=role, is_active=True + ) return role -def _items(resp): - # поддержка пагинации и без - if isinstance(resp.data, dict) and "results" in resp.data: - return resp.data["results"] - return resp.data + +def _post_data( + *, type_: str, title: str = "T", body: str = "B", dept: Optional[Department] = None +) -> dict: + """Собирает payload для создания/изменения поста. + + Args: + type_ (str): Тип поста (см. feed.constants). + title (str): Заголовок. + body (str): Тело. + dept (Optional[Department]): Отдел для department-поста. + + Returns: + dict: Данные для POST/PUT/PATCH запроса. + """ + data = {"type": type_, "title": title, "body": body} + if type_ == TYPE_DEPARTMENT: + data["department"] = dept.id if dept else None + return data -# ----------------- tests ----------------- +def _create_post( + author: User, *, type_: str, dept: Optional[Department] = None, title: str = "T" +) -> Post: + """Утилита создания поста напрямую (минуя API), чтобы подготовить данные. -@pytest.mark.django_db -def test_list_requires_auth(api_client): - url = reverse("api:v1:posts-list") - assert api_client.get(url).status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) + Args: + author (User): Автор. + type_ (str): Тип поста. + dept (Optional[Department]): Отдел для department. + title (str): Заголовок. - user = _user() - api_client.force_authenticate(user=user) - assert api_client.get(url).status_code == status.HTTP_200_OK + Returns: + Post: Созданный пост. + """ + return Post.objects.create( + author=author, type=type_, department=dept, title=title, body="B" + ) -@pytest.mark.django_db -def test_create_company_requires_staff_or_perm(api_client): - url = reverse("api:v1:posts-list") - author = _user() - api_client.force_authenticate(user=author) +# ------------------------ URL helpers ------------------------ - payload = { - "type": TYPE_COMPANY, - "title": "Company News", - "body": "hello", - # department не указываем + +@pytest.fixture +def urls(): + """Именованные URL-роуты фида. + + Returns: + dict: Словарь с callables для detail/action. + """ + return { + "posts_list": reverse("api:v1:posts-list"), + "post_detail": lambda pk: reverse("api:v1:posts-detail", args=[pk]), + "post_pin": lambda pk: reverse("api:v1:posts-pin", args=[pk]), + "post_unpin": lambda pk: reverse("api:v1:posts-unpin", args=[pk]), + "post_like": lambda pk: reverse("api:v1:posts-like", args=[pk]), + "post_unlike": lambda pk: reverse("api:v1:posts-unlike", args=[pk]), + "comments_list": reverse("api:v1:comments-list"), + "comment_detail": lambda pk: reverse("api:v1:comments-detail", args=[pk]), } - # без прав -> 403 - resp = api_client.post(url, payload, format="multipart") - assert resp.status_code == status.HTTP_403_FORBIDDEN - # выдаём feed.publish_company_post -> 201 - _grant_user_perm(author, "feed.publish_company_post") - resp = api_client.post(url, payload, format="multipart") - assert resp.status_code == status.HTTP_201_CREATED - assert resp.data["type"] == TYPE_COMPANY - assert resp.data.get("department") is None - - # staff тоже может -> 201 - staff = _user(staff=True) - api_client.force_authenticate(user=staff) - resp = api_client.post(url, payload, format="multipart") - assert resp.status_code == status.HTTP_201_CREATED +# ------------------------ fixtures (project-aware) ------------------------ -@pytest.mark.django_db -def test_create_department_requires_head_or_role_or_staff(api_client): - url = reverse("api:v1:posts-list") - dept = Department.objects.create(name="R&D") +@pytest.fixture +def make_user(user_factory): + """Фабрика пользователей на основе проектной фикстуры. - u = _user() - api_client.force_authenticate(user=u) + Returns: + Callable[..., User]: Создатель пользователя. + """ - payload = { - "type": TYPE_DEPARTMENT, - "title": "Dept News", - "body": "text", - "department": dept.id, - } + def _mk(email: str, **extra) -> User: + return user_factory(email=email, **extra) - # без прав -> 403 - resp = api_client.post(url, payload, format="multipart") - assert resp.status_code == status.HTTP_403_FORBIDDEN - - # глава отдела -> 201 - head = _user() - dept.head = head - dept.save(update_fields=["head"]) - api_client.force_authenticate(user=head) - resp = api_client.post(url, payload, format="multipart") - assert resp.status_code == status.HTTP_201_CREATED - - # сотрудник с ролью, имеющей publish_department_post -> 201 - member = _user() - _grant_dept_role_perm(member, dept, "feed.publish_department_post") - api_client.force_authenticate(user=member) - resp = api_client.post(url, payload, format="multipart") - assert resp.status_code == status.HTTP_201_CREATED - - # staff -> 201 - staff = _user(staff=True) - api_client.force_authenticate(user=staff) - resp = api_client.post(url, payload, format="multipart") - assert resp.status_code == status.HTTP_201_CREATED + return _mk -@pytest.mark.django_db -def test_employee_type_is_forbidden_and_department_field_validation(api_client): - url = reverse("api:v1:posts-list") - staff = _user(staff=True) - api_client.force_authenticate(user=staff) - dept = Department.objects.create(name="Ops") +@pytest.fixture +def auth(api_client: APIClient): + """Возвращает функцию для аутентификации клиента под заданным пользователем. - # TYPE_EMPLOYEE вообще нельзя - resp = api_client.post(url, {"type": TYPE_EMPLOYEE, "title": "x", "body": "b"}, format="multipart") - assert resp.status_code == status.HTTP_400_BAD_REQUEST + Args: + api_client (APIClient): DRF APIClient. - # type=department, но department не указан -> 400 - resp = api_client.post(url, {"type": TYPE_DEPARTMENT, "title": "x", "body": "b"}, format="multipart") - assert resp.status_code == status.HTTP_400_BAD_REQUEST + Returns: + Callable[[User], APIClient]: Аутентифицированный клиент. + """ - # type!=department, но department указан -> 400 - resp = api_client.post( - url, - {"type": TYPE_COMPANY, "title": "x", "body": "b", "department": dept.id}, - format="multipart", + def _as(u: User) -> APIClient: + api_client.force_authenticate(user=u) + return api_client + + return _as + + +# ------------------------ A. Аноним ------------------------ + + +class TestAnonymousAccess: + """A. Аноним: все эндпоинты → 401.""" + + def test_all_unauth_401(self, api_client: APIClient, make_user, urls): + """Проверяет, что анониму всё недоступно (401).""" + author = make_user("a@example.com") + dept = _mk_dept(head=author) + p = _create_post(author, type_=TYPE_DEPARTMENT, dept=dept) + + assert ( + api_client.get(urls["posts_list"]).status_code + == status.HTTP_401_UNAUTHORIZED + ) + assert ( + api_client.get(urls["post_detail"](p.id)).status_code + == status.HTTP_401_UNAUTHORIZED + ) + assert ( + api_client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=dept), + format="json", + ).status_code + == 401 + ) + assert api_client.post(urls["post_like"](p.id), {}).status_code == 401 + assert api_client.get(urls["comments_list"]).status_code == 401 + assert ( + api_client.post( + urls["comments_list"], {"post": p.id, "text": "hi"}, format="json" + ).status_code + == 401 + ) + + +# ------------------------ B. Обычный пользователь ------------------------ + + +class TestRegularUser: + """B. Обычный аутентифицированный пользователь: чтение/коммент/лайки 2xx, посты отдела 403.""" + + def test_read_and_interact_but_no_dept_post_rights(self, make_user, auth, urls): + """GET list/detail → 200, комментарий и like/unlike → 200, CRUD dept-поста → 403.""" + u = make_user("u@example.com") + a = make_user("a@example.com") + dept = _mk_dept(head=a) + client = auth(u) + # подготовим пост для чтения/лайков + post = _create_post(a, type_=TYPE_DEPARTMENT, dept=dept) + + # чтение + assert client.get(urls["posts_list"]).status_code == 200 + assert client.get(urls["post_detail"](post.id)).status_code == 200 + + # комментарий + r = client.post( + urls["comments_list"], {"post": post.id, "text": "hi"}, format="json" + ) + assert r.status_code == 201 + # лайк/анлайк + assert client.post(urls["post_like"](post.id), {}).status_code == 200 + assert client.post(urls["post_unlike"](post.id), {}).status_code == 200 + + # попытки создать/менять/удалять dept-пост → 403 + assert ( + client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=dept), + format="json", + ).status_code + == 403 + ) + assert client.patch( + urls["post_detail"](post.id), {"title": "X"}, format="json" + ).status_code in (403, 404) + assert client.delete(urls["post_detail"](post.id)).status_code in (403, 404) + + # pin/unpin недоступны + assert client.post(urls["post_pin"](post.id)).status_code == 403 + assert client.post(urls["post_unpin"](post.id)).status_code == 403 + + +# ------------------------ C. Роль в отделе ------------------------ + + +class TestDepartmentRole: + """C. Роль в отделе с perm 'manage_department_feed' даёт запись в своём отделе, но не в чужом.""" + + PUBLISH_CODE = getattr(DeptPerm, "CREATE_POST", "publish_department_post") + + def test_create_update_delete_own_department(self, make_user, auth, urls): + """В своём отделе → 201/200/204; в другом отделе → 403.""" + print( + "TEST PUBLISH CODE =", + getattr(DeptPerm, "CREATE_POST", "publish_department_post"), + ) + + u = make_user("role@example.com") + d1 = _mk_dept("D1") + d2 = _mk_dept("D2") + _give_dept_role_with_perms( + u, d1, + perm_codenames=[DeptPerm.CREATE_POST, DeptPerm.MANAGE_FEED] + ) + + client = auth(u) + + # create в своем отделе + r = client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=d1, title="R1"), + format="json", + ) + assert r.status_code == 201, r.content + pid = r.json()["id"] + + # update/destroy своего поста + assert ( + client.patch( + urls["post_detail"](pid), {"title": "R1X"}, format="json" + ).status_code + == 200 + ) + assert client.delete(urls["post_detail"](pid)).status_code in (200, 204) + + # create в чужом отделе → 403 + r2 = client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=d2, title="No"), + format="json", + ) + assert r2.status_code == 403 + + +# ------------------------ D. Руководитель отдела ------------------------ + + +class TestDepartmentHead: + """D. Head: полные права на посты своего отдела.""" + + def test_head_can_manage_department_posts(self, make_user, auth, urls): + """head своего отдела может создавать/изменять/удалять dept-посты.""" + head = make_user("head@example.com") + dept = _mk_dept("HD", head=head) + client = auth(head) + + r = client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=dept, title="H"), + format="json", + ) + assert r.status_code == 201 + pid = r.json()["id"] + + assert ( + client.patch( + urls["post_detail"](pid), {"title": "HX"}, format="json" + ).status_code + == 200 + ) + assert client.delete(urls["post_detail"](pid)).status_code in (200, 204) + + +# ------------------------ E. staff/superuser и pin ------------------------ + + +class TestStaffAndPinning: + """E. Staff/superuser: полный доступ; pin/unpin доступны только staff.""" + + def test_staff_full_access_and_pin(self, make_user, auth, urls): + """staff: CRUD company/dept + pin/unpin.""" + staff = make_user("staff@example.com", staff=True) + d = _mk_dept("PD") + client = auth(staff) + + # company пост + r = client.post( + urls["posts_list"], _post_data(type_=TYPE_COMPANY, title="C"), format="json" + ) + assert r.status_code == 201 + pid = r.json()["id"] + + # dept пост + r2 = client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=d, title="D"), + format="json", + ) + assert r2.status_code == 201 + pid2 = r2.json()["id"] + + # pin/unpin + assert client.post(urls["post_pin"](pid)).status_code == 200 + assert client.post(urls["post_unpin"](pid)).status_code == 200 + + # update/delete + assert ( + client.patch( + urls["post_detail"](pid2), {"title": "DX"}, format="json" + ).status_code + == 200 + ) + assert client.delete(urls["post_detail"](pid2)).status_code in (200, 204) + + +# ------------------------ F. Глобальные модельные права на company ------------------------ + + +class TestCompanyModelPermsGranular: + """Гранулярные проверки модельных прав для company-постов.""" + + def test_company_create_requires_add_only(self, make_user, auth, urls): + """Наличие только feed.add_post → create=201; update/delete без change/delete → 403.""" + u = make_user("only-add@example.com") + client = auth(u) + + # даём только add_post + u.user_permissions.add( + _ensure_permission("feed", Post, "add_post", "Can add Post") + ) + + # create OK + rc = client.post( + urls["posts_list"], _post_data(type_=TYPE_COMPANY, title="C"), format="json" + ) + assert rc.status_code == status.HTTP_201_CREATED, rc.content + pid = rc.json()["id"] + + # update/delete запрещены (нет change/delete) + assert ( + client.patch( + urls["post_detail"](pid), {"title": "CX"}, format="json" + ).status_code + == 403 + ) + assert client.delete(urls["post_detail"](pid)).status_code == 403 + + def test_company_update_requires_change_only(self, make_user, auth, urls): + """Только feed.change_post → update=200; create без add и delete без delete → 403.""" + author = make_user("author@example.com") + editor = make_user("only-change@example.com") + client = auth(editor) + + # готовим пост напрямую, чтобы не требовать add_post у editor + p = _create_post(author, type_=TYPE_COMPANY, title="C2") + + # даём только change_post + editor.user_permissions.add( + _ensure_permission("feed", Post, "change_post", "Can change Post") + ) + + # create запрещён (нет add_post) + assert ( + client.post( + urls["posts_list"], + _post_data(type_=TYPE_COMPANY, title="NC"), + format="json", + ).status_code + == 403 + ) + + # update OK + assert ( + client.patch( + urls["post_detail"](p.id), {"title": "C2X"}, format="json" + ).status_code + == 200 + ) + + # delete запрещён (нет delete_post) + assert client.delete(urls["post_detail"](p.id)).status_code == 403 + + def test_company_delete_requires_delete_only(self, make_user, auth, urls): + """Только feed.delete_post → delete=204; create/update без add/change → 403/403.""" + author = make_user("author@example.com") + deleter = make_user("only-delete@example.com") + client = auth(deleter) + + p = _create_post(author, type_=TYPE_COMPANY, title="C3") + + # даём только delete_post + deleter.user_permissions.add( + _ensure_permission("feed", Post, "delete_post", "Can delete Post") + ) + + # create/update запрещены + assert ( + client.post( + urls["posts_list"], + _post_data(type_=TYPE_COMPANY, title="NC"), + format="json", + ).status_code + == 403 + ) + assert ( + client.patch( + urls["post_detail"](p.id), {"title": "C3X"}, format="json" + ).status_code + == 403 + ) + + # delete OK + resp = client.delete(urls["post_detail"](p.id)) + assert resp.status_code in (200, 204) + + def test_company_perms_do_not_grant_department(self, make_user, auth, urls): + """Модельные права на Post НЕ дают права создавать department-посты.""" + u = make_user("model-perms@example.com") + client = auth(u) + + # дадим все три модельных права — всё равно dept должен быть 403 + u.user_permissions.add( + _ensure_permission("feed", Post, "add_post", "Can add Post"), + _ensure_permission("feed", Post, "change_post", "Can change Post"), + _ensure_permission("feed", Post, "delete_post", "Can delete Post"), + ) + + d = _mk_dept("DeptX") + assert ( + client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=d, title="D"), + format="json", + ).status_code + == 403 + ) + + +# ------------------------ G. Комментарии ------------------------ + + +class TestComments: + """G. Комментарии: создание любым аутентифицированным; изменение — автор/staff.""" + + def test_create_and_update_delete_policies(self, make_user, auth, urls): + """Создание: 201; редактирование/удаление: автор/staff.""" + author = make_user("a@example.com") + reader = make_user("r@example.com") + staff = make_user("s@example.com", staff=True) + + dept = _mk_dept() + post = _create_post(author, type_=TYPE_DEPARTMENT, dept=dept) + + # reader создаёт комментарий + client_r = auth(reader) + rc = client_r.post( + urls["comments_list"], {"post": post.id, "text": "hi"}, format="json" + ) + assert rc.status_code == 201 + cid = rc.json()["id"] + + # чужой юзер менять не может + other = make_user("o@example.com") + client_o = auth(other) + assert ( + client_o.patch( + urls["comment_detail"](cid), {"text": "hack"}, format="json" + ).status_code + == 403 + ) + assert client_o.delete(urls["comment_detail"](cid)).status_code == 403 + + # автор может + resp = client_r.patch(urls["comment_detail"](cid), {"text": "ok"}, format="json") + assert resp.status_code == 200 + assert resp.json().get("text") == "ok" + # staff может + client_s = auth(staff) + del_resp = client_s.delete(urls["comment_detail"](cid)) + assert del_resp.status_code in (200, 204) + # после удаления — 404 + get_after_del = client_r.get(urls["comment_detail"](cid)) + assert get_after_del.status_code == 404 + + +# ------------------------ H. Лайки (идемпотентность и is_liked) ------------------------ + + +class TestLikes: + """H. Лайки: идемпотентность и синхронизация is_liked.""" + + def test_like_unlike_idempotent_and_reflected(self, make_user, auth, urls): + """Повторный like/unlike не искажает счётчик; поле is_liked корректно отражается в detail.""" + a = make_user("a@example.com") + u = make_user("u@example.com") + dept = _mk_dept() + post = _create_post(a, type_=TYPE_DEPARTMENT, dept=dept) + + client = auth(u) + + # старт: не лайкнуто + d0 = client.get(urls["post_detail"](post.id)).json() + assert d0.get("is_liked") in (False, None) + + # первый like увеличивает счётчик + r1 = client.post(urls["post_like"](post.id), {}) + assert r1.status_code == 200 + liked1 = r1.json().get("liked") + assert liked1 is True + + # повторный like не увеличивает + r2 = client.post(urls["post_like"](post.id), {}) + assert r2.status_code == 200 + + # detail отражает is_liked=True + d1 = client.get(urls["post_detail"](post.id)).json() + assert d1.get("is_liked") is True + + # unlike уменьшает, повторный не уводит < 0 + r3 = client.post(urls["post_unlike"](post.id), {}) + assert r3.status_code == 200 and r3.json().get("liked") is False + r4 = client.post(urls["post_unlike"](post.id), {}) + assert r4.status_code == 200 + + d2 = client.get(urls["post_detail"](post.id)).json() + assert d2.get("is_liked") in (False, None) + + +# ------------------------ I. Негативные кейсы валидации ------------------------ + + +class TestValidation: + """I. Негативные кейсы: отсутствие department, запрет чужого отдела и т.п.""" + + def test_department_type_requires_department_field(self, make_user, auth, urls): + """type=department без department → 400.""" + u = make_user("u@example.com") + client = auth(u) + r = client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=None), + format="json", + ) + assert r.status_code == 400 + + def test_cannot_create_in_other_department_with_role(self, make_user, auth, urls): + """Роль в одном отделе не даёт создании в другом отделе → 403.""" + u = make_user("u@example.com") + d1 = _mk_dept("D1") + d2 = _mk_dept("D2") + _give_dept_role_with_perms(u, d1, perm_codenames=["publish_department_post"]) + client = auth(u) + r = client.post( + urls["posts_list"], + _post_data(type_=TYPE_DEPARTMENT, dept=d2), + format="json", + ) + assert r.status_code == 403 + + +def test_company_create_forbidden_for_regular_user(make_user, auth, urls): + u = make_user("regular@example.com") + client = auth(u) + resp = client.post( + urls["posts_list"], + {"type": "company", "title": "X", "body": "B"}, + format="json", ) - assert resp.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.django_db -def test_update_and_delete_permissions_match_create_rules(api_client): - dept = Department.objects.create(name="QA") - head = _user() - dept.head = head - dept.save(update_fields=["head"]) - - # создаём пост отдела от лица главы - api_client.force_authenticate(user=head) - url = reverse("api:v1:posts-list") - resp = api_client.post( - url, - {"type": TYPE_DEPARTMENT, "title": "t1", "body": "b1", "department": dept.id}, - format="multipart", - ) - assert resp.status_code == status.HTTP_201_CREATED - post_id = resp.data["id"] - - url_detail = reverse("api:v1:posts-detail", args=[post_id]) - - # другой обычный пользователь не может править/удалять - stranger = _user() - api_client.force_authenticate(user=stranger) - assert api_client.patch(url_detail, {"title": "nope"}, format="json").status_code == status.HTTP_403_FORBIDDEN - assert api_client.delete(url_detail).status_code == status.HTTP_403_FORBIDDEN - - # выдаём этому пользователю роль с правом публикации в отделе -> теперь может править - _grant_dept_role_perm(stranger, dept, "feed.publish_department_post") - assert api_client.patch(url_detail, {"title": "ok"}, format="json").status_code == status.HTTP_200_OK - - # и удалять - assert api_client.delete(url_detail).status_code == status.HTTP_204_NO_CONTENT - - -@pytest.mark.django_db -def test_list_by_author_shows_all_authors_posts(api_client): - # автор со всеми нужными правами, чтобы создавать через API - author = _user() - _grant_user_perm(author, "feed.publish_company_post") - dept = Department.objects.create(name="Support") - _grant_dept_role_perm(author, dept, "feed.publish_department_post") - - api_client.force_authenticate(user=author) - url = reverse("api:v1:posts-list") - - # company пост - r1 = api_client.post( - url, {"type": TYPE_COMPANY, "title": "c", "body": "b"}, format="multipart" - ) - assert r1.status_code == status.HTTP_201_CREATED - - # department пост - r2 = api_client.post( - url, - {"type": TYPE_DEPARTMENT, "title": "d", "body": "b", "department": dept.id}, - format="multipart", - ) - assert r2.status_code == status.HTTP_201_CREATED - - # выборка по author - lurl = f"{url}?author={author.id}" - resp = api_client.get(lurl) - assert resp.status_code == status.HTTP_200_OK - items = _items(resp) - ids = {row["id"] for row in items} - assert ids == {r1.data["id"], r2.data["id"]} - - # убедимся, что тип employee не проходит (на всякий) - assert all(row["type"] in (TYPE_COMPANY, TYPE_DEPARTMENT) for row in items) + assert resp.status_code == 403 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c87b4cb7..55f80c74 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -19,6 +19,7 @@ User = get_user_model() # Speed & isolation # ------------------------- + @pytest.fixture(autouse=True) def _fast_hashers_and_email(settings, tmp_path): # быстрые хеши паролей и локальная почта @@ -35,19 +36,23 @@ def _fast_hashers_and_email(settings, tmp_path): # API clients # ------------------------- + @pytest.fixture def api_client() -> APIClient: """DRF APIClient (имеет force_authenticate).""" return APIClient() + @pytest.fixture def auth_client_factory(): """Фабрика аутентифицированных клиентов: client = auth_client_factory(user).""" + def _make(user=None): client = APIClient() if user: client.force_authenticate(user=user) return client + return _make @@ -57,10 +62,12 @@ def auth_client_factory(): _phone_seq = itertools.count(1000) + def _unique_phone() -> str: # валидный E.164 и уникальный в рамках сессии return f"+7999000{next(_phone_seq):03d}" + @pytest.fixture def user_factory(): """ @@ -68,14 +75,17 @@ def user_factory(): verified=True, active=True, **extra) Создаёт пользователя напрямую (без менеджера create_user, чтобы не слать письма). """ - def _make(email: str, - *, - staff: bool = False, - superuser: bool = False, - verified: bool = True, - active: bool = True, - password: str = "pass", - **extra): + + def _make( + email: str, + *, + staff: bool = False, + superuser: bool = False, + verified: bool = True, + active: bool = True, + password: str = "pass", + **extra, + ): u = User.objects.create( email=email, phone_number=extra.pop("phone_number", _unique_phone()), @@ -90,20 +100,25 @@ def user_factory(): u.set_password(password) u.save() return u + return _make + @pytest.fixture def department_factory(): def _make(**kwargs) -> Department: return Department.objects.create(**kwargs) + return _make + @pytest.fixture def perm_for_department(): """ p = perm_for_department("manage_department") Гарантированно возвращает Permission для модели Department с нужным codename. """ + def _make(codename: str) -> Permission: ct = ContentType.objects.get_for_model(Department) p, _ = Permission.objects.get_or_create( @@ -112,27 +127,35 @@ def perm_for_department(): defaults={"name": codename}, ) return p + return _make + @pytest.fixture def role_factory(perm_for_department): """ role = role_factory(dept, name="mgr", codes=["manage_department", ...]) """ + def _make(dept: Department, name: str = "role", codes=None) -> DepartmentRole: r = DepartmentRole.objects.create(department=dept, name=name) if codes: perms = [perm_for_department(code) for code in codes] r.permissions.add(*perms) return r + return _make + @pytest.fixture def link_factory(): """ link = link_factory(employee, department, is_active=True, role=None, **dates) """ - def _make(employee, department, is_active=True, role=None, **dates) -> EmployeeDepartment: + + def _make( + employee, department, is_active=True, role=None, **dates + ) -> EmployeeDepartment: return EmployeeDepartment.objects.create( employee=employee, department=department, @@ -140,6 +163,7 @@ def link_factory(): role=role, **dates, ) + return _make @@ -147,15 +171,18 @@ def link_factory(): # Test helpers # ------------------------- + @pytest.fixture def extract_results(): """ items = extract_results(resp.json()) — поддерживает DRF пагинацию и «плоский» список. """ + def _do(payload): if isinstance(payload, dict) and "results" in payload: return payload["results"] return payload + return _do @@ -163,6 +190,7 @@ def extract_results(): # Small misc utilities # ------------------------- + @pytest.fixture def data_uri_image(tmp_path): """ @@ -170,6 +198,7 @@ def data_uri_image(tmp_path): """ # минимальный валидный PNG 1x1 (чёрный пиксель) import base64 + png_b64 = ( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMA" "AQAABQABDQottAAAAABJRU5ErkJggg==" @@ -183,6 +212,8 @@ def content_file_from_b64(): ContentFile <- base64 строки (без data: префикса). """ import base64 + def _make(b64: str, name: str = "upload.bin") -> ContentFile: return ContentFile(base64.b64decode(b64), name=name) + return _make