diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py index be623ae3..6973ff0c 100644 --- a/backend/api/auth/views.py +++ b/backend/api/auth/views.py @@ -6,3 +6,5 @@ from .serializers import PhoneOrEmailTokenObtainPairSerializer class PhoneOrEmailTokenObtainPairView(TokenObtainPairView): serializer_class = PhoneOrEmailTokenObtainPairSerializer + + diff --git a/backend/api/urls.py b/backend/api/urls.py index 66cb6bd0..938b0837 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -4,11 +4,10 @@ from django.urls import include, path from rest_framework_simplejwt.views import TokenRefreshView from .auth.views import PhoneOrEmailTokenObtainPairView - app_name = "api" urlpatterns = [ - path("v1/", include(("api.v1.urls", "api_v1"), namespace="api_v1")), + path("v1/", include(("api.v1.urls", "v1"), namespace="v1")), path( "auth/token/", PhoneOrEmailTokenObtainPairView.as_view(), diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 0d87ebfc..994898e4 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -1,12 +1,19 @@ import base64 import binascii +from collections import defaultdict from io import BytesIO from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, Permission from django.core.files.base import ContentFile -from employees.models import (Department, DepartmentRole, EmployeeAction, - EmployeeDepartment, Position, Skill) +from employees.models import ( + Department, + DepartmentRole, + EmployeeAction, + EmployeeDepartment, + Position, + Skill, +) from PIL import Image from rest_framework import serializers @@ -148,7 +155,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer): "date_display", "comment", "extra", - "history", # ← добавили + "history", # ← добавили ] def get_action_display(self, obj): @@ -162,8 +169,9 @@ class EmployeeActionSerializer(serializers.ModelSerializer): return [] items = list( - obj.history.select_related("history_user") - .order_by("-history_date", "-history_id") + obj.history.select_related("history_user").order_by( + "-history_date", "-history_id" + ) ) action_labels = dict(ACTION_CHOICES) @@ -236,6 +244,7 @@ class EmployeeSerializer(serializers.ModelSerializer): ) departments = serializers.SerializerMethodField() + auth = serializers.SerializerMethodField(read_only=True) class Meta: model = Employee @@ -264,6 +273,8 @@ class EmployeeSerializer(serializers.ModelSerializer): "updated_at", "last_login", "date_joined", + "auth", + ) read_only_fields = ( "is_active", @@ -272,6 +283,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "updated_at", "last_login", "date_joined", + "auth", ) extra_kwargs = { # чтобы точно не принимали/не отдавали пароль и коды @@ -310,21 +322,57 @@ class EmployeeSerializer(serializers.ModelSerializer): return out def get_fields(self): - """ - Показываем actions только в деталке сотрудника и в /employees/me, - либо если явно передан context['include_actions']=True. - Во всех остальных контекстах поле скрываем, чтобы не раздувать ответы. - """ fields = super().get_fields() - if self.context.get("include_actions") is True: - return fields + # actions — как и было + if self.context.get("include_actions") is not True: + view = self.context.get("view") + action = getattr(view, "action", None) if view else None + if action not in {"retrieve", "me"}: + fields.pop("actions", None) + + # ⬇️ Новое: auth показываем только в /me или при include_auth=True + include_auth = self.context.get("include_auth") is True view = self.context.get("view") action = getattr(view, "action", None) if view else None - if action not in {"retrieve", "me"}: - fields.pop("actions", None) + if not include_auth and action != "me": + fields.pop("auth", None) + return fields + def get_auth(self, obj): + """ + Возвращает только ГЛОБАЛЬНЫЕ права текущего (request.user), + уже с учётом должности (PositionRoleBackend). + """ + req = self.context.get("request") + if not req or not getattr(req.user, "is_authenticated", False): + return None + + # Не светим auth другого сотрудника (кроме явного include_auth=True) + include_auth = self.context.get("include_auth") is True + if not include_auth and req.user.pk != getattr(obj, "pk", None): + return None + + u = req.user + perms = sorted(u.get_all_permissions()) # уже включает Position-права + + by_app = defaultdict(list) + for p in perms: + app, code = p.split(".", 1) + by_app[app].append(code) + perms_by_app = {app: sorted(codes) for app, codes in by_app.items()} + + return { + "id": u.id, + "email": u.email or "", + "is_staff": u.is_staff, + "is_superuser": u.is_superuser, + "groups": list(u.groups.values_list("name", flat=True)), + "permissions": perms, + "permissions_by_app": perms_by_app, + } + class DepartmentSerializer(serializers.ModelSerializer): head = EmployeeSerializer(read_only=True) diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 224badd2..5196ba74 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -1,64 +1,38 @@ # backend/api/v1/employees/views.py from __future__ import annotations +from collections import defaultdict from datetime import timedelta -from django.db.models import Prefetch import phonenumbers from common.emails import send_templated_mail from django.conf import settings from django.contrib.auth.models import Group, Permission from django.db import IntegrityError, transaction -from django.db.models import ( - Case, - Count, - Exists, - F, - IntegerField, - OuterRef, - Q, - Subquery, - Value, - When, -) +from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef, + Prefetch, Q, Subquery, Value, When) from django.utils import timezone from django.utils.crypto import get_random_string from employees.constants import ACTION_DISMISSED -from employees.models import ( - Department, - DepartmentRole, - Employee, - EmployeeAction, - EmployeeDepartment, - Position, - Skill, -) +from employees.models import (Department, DepartmentRole, Employee, + EmployeeAction, EmployeeDepartment, Position, + Skill) from phonenumbers import PhoneNumberFormat from rest_framework import filters, status, viewsets from rest_framework.decorators import action -from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated +from rest_framework.permissions import (AllowAny, DjangoModelPermissions, + IsAdminUser, IsAuthenticated) from rest_framework.response import Response from rest_framework.views import APIView -from ..permissions import ( - ASSIGN_ROLE_PERM, - MANAGE_PERM, - IsDeptManagerForWrite, - IsSelfOrStaff, - IsStaffOrHasModelPerm, -) -from .serializers import ( - DepartmentRoleSerializer, - DepartmentSerializer, - EmailSerializer, - EmailVerifySerializer, - EmployeeActionSerializer, - EmployeeListSerializer, - EmployeeSerializer, - PositionSerializer, - RegisterSerializer, - SkillSerializer, -) +from ..permissions import (ASSIGN_ROLE_PERM, MANAGE_PERM, + AdminOrActionOrModelPerms, IsDeptManagerForWrite, + IsSelfOrStaff) +from .serializers import (DepartmentRoleSerializer, DepartmentSerializer, + EmailSerializer, EmailVerifySerializer, + EmployeeActionSerializer, EmployeeListSerializer, + EmployeeSerializer, PositionSerializer, + RegisterSerializer, SkillSerializer) def _to_bool(val: str | None) -> bool | None: @@ -328,7 +302,7 @@ class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet): """ /api/departments/ - GET (list/detail) — только аутентифицированные пользователи - - POST/DELETE — только staff/superuser + - POST/DELETE — только staff/superuser или пользователи с правами - PATCH/PUT — руководитель отдела ИЛИ роль с perm "manage_department" - Смена head — руководитель отдела ИЛИ роль с perm "change_department_head" - Управление ролями — руководитель отдела ИЛИ роль с perm "assign_department_role" @@ -373,7 +347,7 @@ class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet): def get_permissions(self): # Создание/удаление — только админы if self.action in ["create", "destroy"]: - return [IsAdminUser()] + return [AdminOrActionOrModelPerms()] # Общие правки отдела — право manage_department на объекте if self.action in ["update", "partial_update"]: self.required_perm_code = "manage_department" @@ -526,28 +500,61 @@ class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet): class EmployeeViewSet(viewsets.ModelViewSet): """ /api/v1/employees/ - - GET list/retrieve — только аутентифицированные - - POST (create) — только staff/superuser (через EmployeeManager.create_user) - - PATCH/PUT (update/partial) — staff/superuser ИЛИ владелец (self) - - DELETE — только staff/superuser + + Доступ: + - GET list/retrieve — только аутентифицированные пользователи. + - POST (create) — только staff/superuser (в методе create есть явная проверка). + - PATCH/PUT/DELETE {id} — staff/superuser ИЛИ пользователи с модельными правами + на Employee (через AdminOrActionOrModelPerms). + - GET/PATCH /employees/me/ — только аутентифицированные; PATCH правит профиль текущего пользователя. + - POST {id}/add_skill|remove_skill — требуется спец-пермишен + "employees.manage_employee_skills" ИЛИ staff/superuser + (через AdminOrActionOrModelPerms). Поиск: last_name, first_name, patronymic, email, phone_number + Фильтры: ?department= — участники отдела + head ?position= — по должности ?skill=&skill= — любые из навыков (OR) ?email_verified=true|false ?active=true|false - ?actually_active=true|false — email_verified=True и не DISMISSED (или нет действий, но is_active=True) - Сортировка: ?ordering=last_name|first_name|created_at|id (с '-' для DESC) + ?actually_active=true|false — реально активные: email_verified=True и не DISMISSED + (или нет действий, но is_active=True) + + Сортировка: + ?ordering=last_name|first_name|created_at|id (+ '-' для DESC) + + Примечания: + - В detail/retrieve и в me мы добавляем в контекст сериализатора флаги + include_actions и include_action_history — в ответе придут кадровые + события сотрудника и их история. + - Создание /employees/ доступно ТОЛЬКО staff/superuser, даже если у пользователя + есть модельный пермишен add_employee (это отмечено явно в create()). """ serializer_class = EmployeeSerializer - permission_classes = [IsAuthenticated] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["last_name", "first_name", "patronymic", "email", "phone_number"] ordering_fields = ["last_name", "first_name", "created_at", "id"] ordering = ["last_name", "first_name"] + required_perms_by_action = { + "add_skill": "employees.manage_employee_skills", + "remove_skill": "employees.manage_employee_skills", + } + + def get_permissions(self): + if self.action in {"add_skill", "remove_skill"}: + return [IsAuthenticated(), AdminOrActionOrModelPerms()] + elif self.action in {"create", "update", "partial_update", "destroy"}: + if self.action in {"update", "partial_update"}: + return [ + IsAuthenticated(), + (IsSelfOrStaff | AdminOrActionOrModelPerms)(), + ] + return [IsAuthenticated(), AdminOrActionOrModelPerms()] + else: + return [IsAuthenticated()] def get_queryset(self): last_action_code_sq = Subquery( @@ -557,7 +564,9 @@ class EmployeeViewSet(viewsets.ModelViewSet): ) dep_links_prefetch = Prefetch( "departments_links", - queryset=EmployeeDepartment.objects.filter(is_active=True).select_related("department", "role"), + queryset=EmployeeDepartment.objects.filter(is_active=True).select_related( + "department", "role" + ), to_attr="dept_links", ) @@ -630,18 +639,6 @@ class EmployeeViewSet(viewsets.ModelViewSet): return qs - # --- права --- - def get_permissions(self): - if self.action in {"create", "destroy"}: - return [IsAdminUser()] - if self.action in {"update", "partial_update"}: - # class-level: аутентификация - # object-level: IsSelfOrStaff - return [IsAuthenticated(), IsSelfOrStaff()] - if self.action in {"me"}: - return [IsAuthenticated()] - return super().get_permissions() - def get_serializer_class(self): # для списка отдаём облегчённый, но с нужными полями if self.action == "list": @@ -700,22 +697,23 @@ class EmployeeViewSet(viewsets.ModelViewSet): headers = self.get_success_headers(out.data) return Response(out.data, status=status.HTTP_201_CREATED, headers=headers) - # --- профиль текущего пользователя --- @action(detail=False, methods=["get", "patch"]) def me(self, request): """ - GET /api/v1/employees/me/ — ваш профиль + GET /api/v1/employees/me/ — ваш профиль (ТОТ ЖЕ формат, что retrieve) PATCH /api/v1/employees/me/ — частичное обновление своего профиля """ instance: Employee = request.user # type: ignore - if request.method.lower() == "get": + + if request.method == "GET": instance = ( Employee.objects.select_related("position") .prefetch_related( "skills", Prefetch( "departments_links", - queryset=EmployeeDepartment.objects.filter(is_active=True).select_related("department", "role"), + queryset=EmployeeDepartment.objects.filter(is_active=True) + .select_related("department", "role"), to_attr="dept_links", ), Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")), @@ -724,34 +722,77 @@ class EmployeeViewSet(viewsets.ModelViewSet): ) ctx = self.get_serializer_context() ctx["include_actions"] = True - ctx["include_action_history"] = True # <-- добавили - return Response(self.get_serializer(instance, context=ctx).data) - - + ctx["include_action_history"] = True + data = self.get_serializer(instance, context=ctx).data + return Response(data, status=status.HTTP_200_OK) ser = self.get_serializer(instance, data=request.data, partial=True) ser.is_valid(raise_exception=True) - # Можно повторить контакт-валидацию для PATCH: + vd = ser.validated_data if all(k not in vd for k in ("whatsapp", "telegram", "wechat")): - # если пользователь пытается очистить все контакты new_whatsapp = vd.get("whatsapp", instance.whatsapp) new_telegram = vd.get("telegram", instance.telegram) new_wechat = vd.get("wechat", instance.wechat) if not (new_whatsapp or new_telegram or new_wechat): return Response( - { - "detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)." - }, + {"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."}, status=status.HTTP_400_BAD_REQUEST, ) + ser.save() - return Response(ser.data) - + return Response(ser.data, status=status.HTTP_200_OK) + + @action(detail=True, methods=["post"]) + def add_skill(self, request, pk=None): + """ + body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" } + """ + emp = self.get_object() + sid = request.data.get("skill_id") + sname = (request.data.get("skill_name") or "").strip() + + sk = None + if sid: + sk = Skill.objects.filter(pk=sid).first() + if not sk and sname: + sk = Skill.objects.filter( + name__iexact=sname + ).first() or Skill.objects.create(name=sname) + if not sk: + return Response({"detail": "Навык не найден/не указан"}, status=400) + + emp.skills.add(sk) + return Response( + {"ok": True, "skill": {"id": sk.id, "name": sk.name}}, status=200 + ) + + @action(detail=True, methods=["post"]) + def remove_skill(self, request, pk=None): + """ + body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" } + """ + emp = self.get_object() + sid = request.data.get("skill_id") + sname = (request.data.get("skill_name") or "").strip() + + sk = None + if sid: + sk = Skill.objects.filter(pk=sid).first() + if not sk and sname: + sk = Skill.objects.filter(name__iexact=sname).first() + if not sk: + return Response({"detail": "Навык не найден"}, status=404) + + emp.skills.remove(sk) + return Response( + {"ok": True, "removed": {"id": sk.id, "name": sk.name}}, status=200 + ) + def get_serializer_context(self): ctx = super().get_serializer_context() if getattr(self, "action", None) in {"retrieve", "me"}: ctx["include_actions"] = True - ctx["include_action_history"] = True # <-- добавили + ctx["include_action_history"] = True return ctx @@ -769,27 +810,18 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet): queryset = Position.objects.all().prefetch_related("groups") serializer_class = PositionSerializer - permission_classes = [IsAuthenticated, IsStaffOrHasModelPerm] pagination_class = None filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["name", "description"] ordering_fields = ["name", "id"] ordering = ["name"] history_diff_fields = ["name", "description"] - - def get_permissions(self): - # важное место: выставляем требуемый perm под действие - if self.action == "create": - self.required_perm_code = "employees.add_position" - elif self.action in ("update", "partial_update"): - self.required_perm_code = "employees.change_position" - elif self.action == "destroy": - self.required_perm_code = "employees.delete_position" - elif self.action in ("set_groups", "add_groups", "remove_groups"): - self.required_perm_code = "employees.assign_position_groups" - else: - self.required_perm_code = None - return super().get_permissions() + permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms] + required_perms_by_action = { + "set_groups": "employees.assign_position_groups", + "add_groups": "employees.assign_position_groups", + "remove_groups": "employees.assign_position_groups", + } def _validate_groups_payload(self, request): ids = request.data.get("groups") @@ -861,15 +893,13 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet): class DepartmentRoleViewSet(viewsets.ModelViewSet): """ - /api/v1/department-roles/ - - GET list/retrieve — IsAuthenticated - - POST/PATCH/PUT/DELETE — руководитель отдела ИЛИ пользователь с нужной ролью отдела - * create/update/delete — perm: manage_department - * set-perms/add-perms/remove-perms — perm: assign_department_role - - Фильтры: - ?department= — роли конкретного отдела - ?search= — поиск по названию + /api/v1/positions/ + GET list/retrieve — IsAuthenticated + POST/PUT/PATCH/DELETE — Admin OR Django model perms (add/change/delete_position) + POST /{id}/set-groups — Admin OR employees.assign_position_groups + POST /{id}/add-groups — Admin OR employees.assign_position_groups + POST /{id}/remove-groups — Admin OR employees.assign_position_groups + GET /{id}/permissions — IsAuthenticated """ serializer_class = DepartmentRoleSerializer @@ -1016,22 +1046,14 @@ class SkillViewSet(viewsets.ModelViewSet): ordering_fields = ["name", "id"] ordering = ["name"] pagination_class = None + required_perms_by_action = { + "merge": "employees.change_skill", + } def get_permissions(self): - # create / bulk_create — любой аутентифицированный if self.action in ("create", "bulk_create"): return [IsAuthenticated()] - - # merge / update / destroy — требуются модельные права - if self.action in ("update", "partial_update"): - self.required_perm_code = "employees.change_skill" - elif self.action == "destroy": - self.required_perm_code = "employees.delete_skill" - elif self.action == "merge": - self.required_perm_code = "employees.change_skill" - else: - self.required_perm_code = None # SAFE methods → IsAuthenticated - return [IsAuthenticated(), IsStaffOrHasModelPerm()] + return [IsAuthenticated(), AdminOrActionOrModelPerms()] @action(detail=False, methods=["post"]) def bulk_create(self, request): @@ -1182,13 +1204,13 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): def get_permissions(self): if self.action == "create": self.required_perm_code = "employees.add_employeeaction" - return [IsAuthenticated(), IsStaffOrHasModelPerm()] + return [IsAuthenticated(), AdminOrActionOrModelPerms()] if self.action in ("update", "partial_update"): self.required_perm_code = "employees.change_employeeaction" - return [IsAuthenticated(), IsStaffOrHasModelPerm()] + return [IsAuthenticated(), AdminOrActionOrModelPerms()] if self.action == "destroy": self.required_perm_code = "employees.delete_employeeaction" - return [IsAuthenticated(), IsStaffOrHasModelPerm()] + return [IsAuthenticated(), AdminOrActionOrModelPerms()] self.required_perm_code = None return super().get_permissions() diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index 2d913f29..e749193d 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -1,7 +1,11 @@ # backend\api\v1\permissions.py from __future__ import annotations -from rest_framework.permissions import SAFE_METHODS, BasePermission +from rest_framework.permissions import ( + SAFE_METHODS, + BasePermission, + DjangoModelPermissions, +) from employees.models import Department, EmployeeDepartment @@ -79,47 +83,32 @@ class IsDeptManagerForWrite(BasePermission): class IsSelfOrStaff(BasePermission): - """ - Доступ к объекту Employee: - - staff/superuser — всегда - - сам сотрудник к своему объекту - """ - - message = "Недостаточно прав." + # На class-level пускаем любого аутентифицированного. + def has_permission(self, request, view): + return bool(request.user and request.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 user.is_superuser or user.is_staff: + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): return True - return getattr(obj, "id", None) == getattr(user, "id", None) + # Основной случай — объект сотрудника. + try: + from employees.models import ( + Employee, + ) # локальный импорт, чтобы избежать циклов + except Exception: + Employee = None -class IsStaffOrHasModelPerm(BasePermission): - """ - Read: любой аутентифицированный - Write: staff ИЛИ пользователь с правом, указанным во view.required_perm_code - """ + if Employee is not None and isinstance(obj, Employee): + return obj.pk == user.pk - def has_permission(self, request, view): - user = request.user - if request.method in SAFE_METHODS: - return bool(user and user.is_authenticated) - if not (user and user.is_authenticated): - return False - if user.is_staff or user.is_superuser: - return True - code = getattr(view, "required_perm_code", None) - # NOTE: в тестах права выдают уже после первого запроса; кэш прав у user остаётся. - # Сбросим кэш, чтобы has_perm перечитал права из БД в рамках текущего запроса. - for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): - if hasattr(user, attr): - try: - delattr(user, attr) - except Exception: - pass - return bool(code and user.has_perm(code)) + # Fallback: сравнить id из URL, если obj не Employee (на всякий случай) + lookup = getattr(view, "lookup_field", "pk") + return str(view.kwargs.get(lookup)) == str(user.pk) class IsAuthorOrStaffForComments(BasePermission): @@ -127,6 +116,7 @@ class IsAuthorOrStaffForComments(BasePermission): Создание/чтение — любой аутентифицированный (проверяет IsAuthenticated во view). Изменение/удаление — автор комментария или staff/superuser. """ + message = "Можно редактировать/удалять только свои комментарии." def has_permission(self, request, view): @@ -145,14 +135,49 @@ class IsAuthorOrStaffForComments(BasePermission): return getattr(obj, "author_id", None) == getattr(user, "id", None) +class AdminOrActionOrModelPerms(DjangoModelPermissions): + """ + Админ/суперпользователь → всегда True. + Если задан код права (view.required_perm_code или required_perms_by_action[action]), + проверяем его через user.has_perm(...). Иначе — стандартные DjangoModelPermissions. + """ + def has_permission(self, request, view): + user = request.user + if not user 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) + if code: + return user.has_perm(code) + + return super().has_permission(request, view) + + def has_object_permission(self, request, view, obj): + 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) + if code: + return user.has_perm(code) + + return super().has_object_permission(request, view, obj) + + __all__ = [ "MANAGE_PERM", "CHANGE_HEAD_PERM", "ASSIGN_ROLE_PERM", "IsDeptManagerForWrite", "IsSelfOrStaff", - "IsStaffOrHasModelPerm", "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 d84ac081..74b0510d 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -9,7 +9,7 @@ from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet, VerifyEmailAPIView) from .feed.views import CommentViewSet, PostViewSet -app_name = "api_v1" +app_name = "v1" router = DefaultRouter() router.register(r"calendar/company-events", CompanyEventsViewSet, basename="company-events") diff --git a/backend/calendar_app/migrations/0003_alter_departmentevent_options.py b/backend/calendar_app/migrations/0003_alter_departmentevent_options.py new file mode 100644 index 00000000..5ff90d38 --- /dev/null +++ b/backend/calendar_app/migrations/0003_alter_departmentevent_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-08-28 11:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar_app', '0002_departmentevent_companyevent_color_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='departmentevent', + options={'ordering': ['department', 'start_date'], 'permissions': [('manage_department_events', 'Может создавать/обновлять/ удалять события отдела')], 'verbose_name': 'Событие отдела', 'verbose_name_plural': 'События отделов'}, + ), + ] diff --git a/backend/communications/routing.py b/backend/communications/routing.py index 01633edc..5255a960 100644 --- a/backend/communications/routing.py +++ b/backend/communications/routing.py @@ -1,8 +1,9 @@ +# backend\communications\routing.py from django.urls import path from django.urls import re_path from .consumers import ChatConsumer, ChatListConsumer websocket_urlpatterns = [ - path("ws/chat//", ChatConsumer.as_asgi(), name="ws_chat"), - re_path(r"^ws/chats/$", ChatListConsumer.as_asgi()), + re_path(r"^ws/chat/(?P\d+)/$", ChatConsumer.as_asgi(), name="ws_chat"), + re_path(r"^ws/chats/$", ChatListConsumer.as_asgi(), name="ws_chat_list"), ] diff --git a/backend/employees/migrations/0020_alter_department_options_alter_employee_options_and_more.py b/backend/employees/migrations/0020_alter_department_options_alter_employee_options_and_more.py new file mode 100644 index 00000000..5ce4c6f5 --- /dev/null +++ b/backend/employees/migrations/0020_alter_department_options_alter_employee_options_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.4 on 2025-08-28 11:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0019_delete_education'), + ] + + operations = [ + migrations.AlterModelOptions( + name='department', + options={'permissions': [('change_department_head', 'Может изменять руководителя отдела'), ('assign_department_role', 'Может назначать роли в рамках отдела'), ('manage_department', 'Может управлять отделом')], 'verbose_name': 'Отдел', 'verbose_name_plural': 'Отделы'}, + ), + migrations.AlterModelOptions( + name='employee', + options={'ordering': ('last_name', 'first_name'), 'permissions': [('assign_employee_skills', 'Может назначать навыки сотрудникам')], 'verbose_name': 'Сотрудник', 'verbose_name_plural': 'Сотрудники'}, + ), + migrations.AlterModelOptions( + name='position', + options={'ordering': ['name'], 'permissions': [('assign_position_groups', 'Может назначать группы аутентификации для должностей')], 'verbose_name': 'Должность', 'verbose_name_plural': 'Должности'}, + ), + ] diff --git a/backend/employees/migrations/0021_alter_employee_options.py b/backend/employees/migrations/0021_alter_employee_options.py new file mode 100644 index 00000000..d3b43021 --- /dev/null +++ b/backend/employees/migrations/0021_alter_employee_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-08-28 12:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0020_alter_department_options_alter_employee_options_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='employee', + options={'ordering': ('last_name', 'first_name'), 'permissions': [('manage_employee_skills', 'Может управлять навыками сотрудников')], 'verbose_name': 'Сотрудник', 'verbose_name_plural': 'Сотрудники'}, + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index f00ba565..c70d944d 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -1,6 +1,5 @@ # backend\employees\models.py -from django.contrib.auth.models import (AbstractUser, BaseUserManager, Group, - Permission) +from django.contrib.auth.models import AbstractUser, BaseUserManager, Group, Permission from django.core.exceptions import ValidationError from django.db import IntegrityError, models, transaction from django.db.models.functions import Lower @@ -39,25 +38,37 @@ class EmployeeManager(BaseUserManager): # Явные проверки для дружелюбной ошибки + защита от гонок ниже if self.model.objects.filter(email__iexact=email).exists(): - raise ValidationError({"email": "Пользователь с таким email уже существует"}) + raise ValidationError( + {"email": "Пользователь с таким email уже существует"} + ) if self.model.objects.filter(phone_number=phone_number).exists(): - raise ValidationError({"phone_number": "Пользователь с таким номером уже существует"}) + raise ValidationError( + {"phone_number": "Пользователь с таким номером уже существует"} + ) try: with transaction.atomic(): - user = self.model(email=email, phone_number=phone_number, **extra_fields) + user = self.model( + email=email, phone_number=phone_number, **extra_fields + ) user.set_password(password) # для обычного пользователя подтверждение по email user.email_verified = False - user.email_activation_code = get_random_string(6, allowed_chars="0123456789") + user.email_activation_code = get_random_string( + 6, allowed_chars="0123456789" + ) user.save(using=self._db) except IntegrityError as e: # fallback — на случай гонки: маппим в поле-специфичную ошибку msg = str(e).lower() if "email" in msg or "uniq" in msg and "email" in msg: - raise ValidationError({"email": "Пользователь с таким email уже существует"}) + raise ValidationError( + {"email": "Пользователь с таким email уже существует"} + ) if "phone" in msg or "phone_number" in msg: - raise ValidationError({"phone_number": "Пользователь с таким номером уже существует"}) + raise ValidationError( + {"phone_number": "Пользователь с таким номером уже существует"} + ) raise if send_activation_email: @@ -128,7 +139,10 @@ class Position(models.Model): verbose_name_plural = "Должности" ordering = ["name"] permissions = [ - ("assign_position_groups", "Может назначать группы аутентификации для должностей"), + ( + "assign_position_groups", + "Может назначать группы аутентификации для должностей", + ), ] def __str__(self) -> str: @@ -182,6 +196,9 @@ class Employee(AbstractUser): models.Index(fields=["phone_number"]), models.Index(Lower("email"), name="employee_email_lower_idx"), ] + permissions = [ + ("manage_employee_skills", "Может управлять навыками сотрудников"), + ] def clean(self): super().clean() @@ -236,9 +253,7 @@ class EmployeeAction(models.Model): employee = models.ForeignKey( Employee, related_name="actions", on_delete=models.CASCADE ) - action = models.CharField( - "Кадровое событие", max_length=50, choices=ACTION_CHOICES - ) # Увеличено для расширения + action = models.CharField("Кадровое событие", max_length=50, choices=ACTION_CHOICES) date = models.DateTimeField("Дата действия") comment = models.TextField("Комментарий/причина", blank=True) extra = models.JSONField("Дополнительно", blank=True, null=True) @@ -398,9 +413,7 @@ class DepartmentRole(models.Model): Department, on_delete=models.CASCADE, related_name="roles" ) name = models.CharField(max_length=150) - permissions = models.ManyToManyField( - Permission, blank=True - ) # права в рамках ЭТОГО отдела + permissions = models.ManyToManyField(Permission, blank=True) class Meta: verbose_name = "Роль в отделе" diff --git a/backend/employees/urls_front.py b/backend/employees/urls_front.py index cdf9aaa7..f78b0c4f 100644 --- a/backend/employees/urls_front.py +++ b/backend/employees/urls_front.py @@ -20,9 +20,6 @@ urlpatterns = [ views_front.DepartmentCreateView.as_view(), name="department_add", ), - # path("departments//edit/", views_front.DepartmentUpdateView.as_view(), name="department_edit"), - # path("departments//delete/", views_front.DepartmentDeleteView.as_view(), name="department_delete"), - # path("departments//invite/", views_front.invite_to_department, name="invite_to_department"), path("departments//members//role/", views_front.edit_department_role, name="edit_department_role",), path("departments/", views_front.department_list, name="department_list"), path("departments//", views_front.department_detail, name="department_detail"), @@ -31,17 +28,9 @@ urlpatterns = [ path("departments//set-member-role/", views_front.department_set_member_role, name="department_set_member_role",), # --- Навыки --- - path("skills/", views_front.SkillListView.as_view(), name="skill_list"), - path("skills/add/", views_front.SkillCreateView.as_view(), name="skill_add"), - path( - "skills//edit/", - views_front.SkillUpdateView.as_view(), - name="skill_edit", - ), - path( - "skills//delete/", - views_front.SkillDeleteView.as_view(), - name="skill_delete", - ), + path("/skill/add/", views_front.skill_add, name="skill_add"), + path("/skill/remove/", views_front.skill_remove, name="skill_remove"), + + ] diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 19cccc29..40839e50 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -14,13 +14,18 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.utils import timezone -from django.utils.http import urlencode from django.views.decorators.http import require_http_methods from django.views.generic import CreateView, DeleteView, ListView, UpdateView +from django.views.decorators.http import require_POST +from django.http import JsonResponse -from .forms import (DepartmentEditForm, DepartmentForm, - DepartmentMemberRoleForm, - InviteToDepartmentForm, SkillForm) +from .forms import ( + DepartmentEditForm, + DepartmentForm, + DepartmentMemberRoleForm, + InviteToDepartmentForm, + SkillForm, +) from .forms_front import DepartmentEditForm, SetHeadForm, SetMemberRoleForm from .models import Department, EmployeeDepartment, Skill @@ -79,6 +84,24 @@ def _cleanup_payload(data: dict, keep_empty: set[str] = frozenset()) -> dict: return cleaned +def _extract_results(payload): + if isinstance(payload, dict): + return payload.get("results", payload.get("items", [])) or [] + return payload or [] + + +def _fetch_employee_posts(api, employee_id: int): + # сначала пробуем ?author=, если в API другое имя фильтра — fallback на ?employee= + for url in ( + f"v1/posts/?author={employee_id}&ordering=-created_at", + f"v1/posts/?employee={employee_id}&ordering=-created_at", + ): + r = api.get(url) + if r.ok: + data = r.json or [] + return _extract_results(data) + + # ========================= # Сотрудники # ========================= @@ -139,32 +162,50 @@ def employee_detail(request, pk: int): return redirect("employees:employees_list") emp = resp.json or {} - can_edit = bool(request.user and (request.user.is_staff or request.user.id == emp.get("id"))) - print("emp:", emp) - return render(request, "employees/employee_detail.html", { - "emp": emp, - "can_edit": can_edit, - # "employee_actions": employee_actions, # ← удалить - }) + can_edit = bool( + request.user and (request.user.is_staff or request.user.id == emp.get("id")) + ) + posts = _fetch_employee_posts(api, pk) + sresp = api.get("v1/skills/", params={"ordering": "name"}) + all_skills = sresp.json if sresp.ok else [] + return render( + request, + "employees/employee_detail.html", + {"emp": emp, "can_edit": can_edit, "all_skills": all_skills, "posts": posts}, + ) # ---------- ME ---------- @require_api_auth def employee_me(request): api = get_api_client(request) + resp = api.get("v1/employees/me/") if not resp.ok: messages.error(request, f"Ошибка API ({resp.status})") return redirect("employees:employees_list") emp = resp.json or {} - print("emp", emp) - return render(request, "employees/employee_detail.html", { - "emp": emp, - "can_edit": True, - # "employee_actions": employee_actions, # ← удалить - }) + # новости сотрудника + posts = _fetch_employee_posts(api, emp.get("id")) + + # все навыки для даталиста (разворачиваем results) + sresp = api.get("v1/skills/", params={"ordering": "name"}) + data = sresp.json if sresp.ok else [] + all_skills = data.get("results", data) if isinstance(data, dict) else data + + return render( + request, + "employees/employee_detail.html", + { + "emp": emp, + "can_edit": True, + "is_me": True, + "all_skills": all_skills, + "posts": posts, + }, + ) # ---------- EDIT (self or staff) ---------- @@ -623,9 +664,9 @@ def edit_department_role(request, dept_id, employee_id): ) -# ========================= -# Приглашение в отдел -# ========================= +# ---------- Приглашение в отдел ---------- + + @login_required def invite_to_department(request, pk): department = get_object_or_404(Department, pk=pk) @@ -668,57 +709,34 @@ def invite_to_department(request, pk): # ========================= -# Универсальный CRUD для Absence, Skill +# Навыки # ========================= -class BaseCRUDListView(LoginRequiredMixin, ListView): - """Базовый ListView: если у модели есть FK employee — показываем только свои записи.""" - - def get_queryset(self): - qs = super().get_queryset() - has_employee_fk = any(f.name == "employee" for f in self.model._meta.fields) - if has_employee_fk: - return qs.filter(employee=self.request.user) - return qs -class BaseCRUDCreateView(LoginRequiredMixin, CreateView): - def form_valid(self, form): - has_employee_fk = any(f.name == "employee" for f in self.model._meta.fields) - if has_employee_fk: - form.instance.employee = self.request.user - return super().form_valid(form) +@login_required +@require_POST +@require_api_auth +def skill_add(request, pk: int): + api = get_api_client(request) + skill_id = request.POST.get("skill_id") + skill_name = (request.POST.get("skill_name") or "").strip() + payload = { + k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v + } + resp = api.post(f"v1/employees/{pk}/add_skill/", data=payload) + return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500) -class BaseCRUDUpdateView(LoginRequiredMixin, UpdateView): - pass - - -class BaseCRUDDeleteView(LoginRequiredMixin, DeleteView): - pass - - -# Skill CRUD (через базовые вьюхи, чтобы ограничить видимость своими записями) -class SkillListView(BaseCRUDListView): - model = Skill - template_name = "employees/skill_list.html" - context_object_name = "skills" - - -class SkillCreateView(BaseCRUDCreateView): - model = Skill - form_class = SkillForm - template_name = "employees/skill_form.html" - success_url = reverse_lazy("employees:skill_list") - - -class SkillUpdateView(BaseCRUDUpdateView): - model = Skill - form_class = SkillForm - template_name = "employees/skill_form.html" - success_url = reverse_lazy("employees:skill_list") - - -class SkillDeleteView(BaseCRUDDeleteView): - model = Skill - template_name = "employees/skill_confirm_delete.html" - success_url = reverse_lazy("employees:skill_list") +@login_required +@require_POST +@require_api_auth +def skill_remove(request, pk: int): + api = get_api_client(request) + skill_id = request.POST.get("skill_id") + skill_name = (request.POST.get("skill_name") or "").strip() + payload = { + k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v + } + # NB: у DRF-экшена underscore: remove_skill + resp = api.post(f"v1/employees/{pk}/remove_skill/", data=payload) + return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500) diff --git a/backend/eusrr_backend/asgi.py b/backend/eusrr_backend/asgi.py index c9ae2775..3dbc3af6 100644 --- a/backend/eusrr_backend/asgi.py +++ b/backend/eusrr_backend/asgi.py @@ -1,13 +1,12 @@ import os +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings") + +from django.core.asgi import get_asgi_application +django_asgi_app = get_asgi_application() from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from communications.routing import websocket_urlpatterns -from django.core.asgi import get_asgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings") - -django_asgi_app = get_asgi_application() application = ProtocolTypeRouter( { diff --git a/backend/eusrr_backend/auth_backends.py b/backend/eusrr_backend/auth_backends.py index b5167ce4..4bdac15f 100644 --- a/backend/eusrr_backend/auth_backends.py +++ b/backend/eusrr_backend/auth_backends.py @@ -4,9 +4,8 @@ from typing import Optional from django.conf import settings from django.contrib.auth.backends import ModelBackend -from django.db.models import Q -from django.utils import timezone -from employees.models import Employee +from django.contrib.auth.models import Permission +from employees.models import Department, Employee, EmployeeDepartment # phonenumbers — опционально, но очень желательно: pip install phonenumbers try: @@ -100,68 +99,54 @@ class EmailOrPhoneBackend(ModelBackend): class PositionRoleBackend(ModelBackend): - """ - Добавляет: - - Глобальные права от активных EmployeePosition → PositionRole.permissions - - Объектные права на Department из DepartmentRole.permissions (через EmployeeDepartment.role_fk) - """ + def get_all_permissions(self, user_obj, obj=None): + perms = super().get_all_permissions(user_obj, obj) + if not getattr(user_obj, "is_authenticated", False) or obj is not None: + return perms - def _active_positions(self, user): - today = timezone.now().date() - return ( - user.positions.select_related("position_role") - .prefetch_related("position_role__permissions") - .filter(date_from__lte=today) - .filter(Q(date_to__isnull=True) | Q(date_to__gte=today)) - ) + # 1) Глобальные права от должности — оставить + pos = getattr(user_obj, "position", None) + if pos: + pos_perms = ( + Permission.objects.filter(group__positions=pos) + .select_related("content_type") + .distinct() + ) + perms |= {f"{p.content_type.app_label}.{p.codename}" for p in pos_perms} + + # 2) ⚠️ НЕ добавляем сюда пермишены из DepartmentRole. + # Они должны проверяться только на уровне конкретного Department в has_perm(..., obj=dept) - def get_user_permissions(self, user, obj=None): - # только глобальные (obj=None): соберём из активных должностей - if not user.is_authenticated or obj is not None: - return set() - perms = set() - for ep in self._active_positions(user): - pr = ep.position_role - if not pr: - continue - for p in pr.permissions.all(): - perms.add(f"{p.content_type.app_label}.{p.codename}") return perms - def has_perm(self, user, perm, obj=None): - # 1) Сначала стандартные механики Django (user.user_permissions, groups, superuser) - if super().has_perm(user, perm, obj=obj): + def has_perm(self, user_obj, perm, obj=None): + # стандартная ветка + суперюзер/группы/пользовательские + if super().has_perm(user_obj, perm, obj=obj): return True - if not user.is_authenticated: + if not getattr(user_obj, "is_authenticated", False): return False - # 2) Глобальные должности (obj=None) if obj is None: - return perm in self.get_user_permissions(user) - - # 3) Объектные права на Department — смотрим роль в ЭТОМ отделе - from employees.models import Department, EmployeeDepartment + # добавить глобальные (от должности) + return perm in self.get_all_permissions(user_obj) + # Объектные права на Department — как у вас if isinstance(obj, Department): - # начальнику отдела — всё - if obj.head_id == user.id: + if obj.head_id == user_obj.id: return True link = ( - EmployeeDepartment.objects.select_related("role_fk") - .filter(employee=user, department=obj, is_active=True) + EmployeeDepartment.objects.select_related("role") + .filter(employee=user_obj, department=obj, is_active=True) .first() ) - if not link or not link.role_fk: + if not (link and link.role_id): return False - - try: - app_label, codename = perm.split(".", 1) - except ValueError: + app_label, sep, codename = perm.partition(".") + if not sep: return False - - return link.role_fk.permissions.filter( - content_type__app_label=app_label, codename=codename + return link.role.permissions.filter( + content_type__app_label=app_label, + codename=codename, ).exists() - # для других obj вернёмся к базовой логике return False diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 4b81b55e..ff8d8569 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -215,6 +215,7 @@ ASGI_APPLICATION = "eusrr_backend.asgi.application" AUTHENTICATION_BACKENDS = [ "eusrr_backend.auth_backends.EmailOrPhoneBackend", + "eusrr_backend.auth_backends.PositionRoleBackend", "django.contrib.auth.backends.ModelBackend", ] @@ -231,7 +232,10 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication" ], - "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + "rest_framework.permissions.DjangoModelPermissions", + ], "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", ], diff --git a/backend/feed/migrations/0003_alter_post_options.py b/backend/feed/migrations/0003_alter_post_options.py new file mode 100644 index 00000000..1ffeb370 --- /dev/null +++ b/backend/feed/migrations/0003_alter_post_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-08-28 11:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('feed', '0002_alter_post_options'), + ] + + operations = [ + migrations.AlterModelOptions( + name='post', + options={'ordering': ['-pinned', '-created_at'], 'permissions': [('publish_company_post', 'Может публиковать новости на главной'), ('publish_department_post', 'Может публиковать новости в отделах')], 'verbose_name': 'Публикация', 'verbose_name_plural': 'Публикации'}, + ), + ] diff --git a/backend/requests_app/migrations/0006_alter_request_options.py b/backend/requests_app/migrations/0006_alter_request_options.py new file mode 100644 index 00000000..e9c88050 --- /dev/null +++ b/backend/requests_app/migrations/0006_alter_request_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-08-28 11:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('requests_app', '0005_alter_request_options'), + ] + + operations = [ + migrations.AlterModelOptions( + name='request', + options={'ordering': ['-created_at'], 'permissions': [('can_view_all_requests', 'Можно просмотреть все заявления'), ('can_process_requests', 'Может одобрять/отклонять заявления')], 'verbose_name': 'Заявление', 'verbose_name_plural': 'Заявления'}, + ), + ] diff --git a/backend/templates/employees/employee_detail.html b/backend/templates/employees/employee_detail.html index 2c6d3550..43e5a78f 100644 --- a/backend/templates/employees/employee_detail.html +++ b/backend/templates/employees/employee_detail.html @@ -152,301 +152,540 @@ border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%); } + + + + + {% endblock %} {% block content %} -
-
- -

- {% if is_me %}Мой профиль{% else %}Профиль сотрудника{% endif %} -

-
-
+
+
+ +

+ {% if is_me %}Мой профиль{% else %}Профиль сотрудника{% endif %} +

+
+
-
- -
-
-
+
+ +
+
+
- -
- {% if emp.avatar %} - Аватар {{ emp.last_name }} {{ emp.first_name }} - {% else %} -
- {% firstof emp.first_name emp.last_name "?" as initial %} - {{ initial|slice:":1"|upper }} -
- {% endif %} -
- - -
-
- {{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %} + +
+ {% if emp.avatar %} + Аватар {{ emp.last_name }} {{ emp.first_name }} + {% else %} +
+ {% firstof emp.first_name emp.last_name "?" as initial %} + {{ initial|slice:":1"|upper }} +
+ {% endif %}
- {% if can_edit %} - - - - {% endif %} -
-
- {% if emp.position.name %} - {{ emp.position.name }} - {% elif emp.position_label %} - {{ emp.position_label }} - {% elif emp.position %} - Должность #{{ emp.position }} - {% endif %} -
+ +
+
+ {{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %} +
+ {% if can_edit %} + + + + {% endif %} +
- {# === НАВЫКИ (из DRF: [{id,name}], без итерации по строкам) === #} - {% if emp.skills and emp.skills.0.name %} +
+ {% if emp.position.name %} + {{ emp.position.name }} + {% elif emp.position_label %} + {{ emp.position_label }} + {% elif emp.position %} + Должность #{{ emp.position }} + {% endif %} +
+ + +
+
+ + +
+
+
+ {% if emp.email %} + + {% endif %} + {% if emp.phone_number %} + + {% endif %} + {% if emp.telegram %}
Telegram: {{ emp.telegram }}
{% endif %} + {% if emp.whatsapp %}
WhatsApp: {{ emp.whatsapp }}
{% endif %} + {% if emp.wechat %}
WeChat: {{ emp.wechat }}
{% endif %} + {% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %} +
Контакты не указаны
+ {% endif %} +
+
+
+
+ + {# === ОТДЕЛЫ (без картинок; роль через тире; руководитель — визуально выделен) === #}
-
-
-
- {% for s in emp.skills %}{% if s.name %}{{ s.name }}{% endif %}{% endfor %} -
+ +
+
+
+ {% if emp.departments and emp.departments|length %} + {% for d in emp.departments %} + {% if d.name %} +
+
+ {% if d.id %} + {{ d.name }} + {% else %} + {{ d.name }} + {% endif %} + {% if d.role_name %}— {{ d.role_name }}{% endif %} +
+ {% if d.is_head %} + + + + {% endif %} +
+ {% endif %} + {% endfor %} + {% else %} +
Отделы не указаны
+ {% endif %}
+
+ {# === КАДРОВЫЕ СОБЫТИЯ (для аутентифицированных) === #} + {% if request.user.is_authenticated %} + {% with acts_src=employee_actions|default:emp.actions %} + {% with acts=acts_src.results|default:acts_src %} + {% if acts %} +
+
+ - - {% endif %} - - -
-
- - -
-
-
- {% if emp.email %} - - {% endif %} - {% if emp.phone_number %} - - {% endif %} - {% if emp.telegram %}
Telegram: {{ emp.telegram }}
{% endif %} - {% if emp.whatsapp %}
WhatsApp: {{ emp.whatsapp }}
{% endif %} - {% if emp.wechat %}
WeChat: {{ emp.wechat }}
{% endif %} - {% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %} -
Контакты не указаны
- {% endif %} -
-
-
-
- - {# === ОТДЕЛЫ (без картинок; роль через тире; руководитель — визуально выделен) === #} -
-
- - -
-
-
- {% if emp.departments and emp.departments|length %} - {% for d in emp.departments %} - {% if d.name %} -
-
- {% if d.id %} - {{ d.name }} - {% else %} - {{ d.name }} - {% endif %} - {% if d.role_name %}— {{ d.role_name }}{% endif %} -
- {% if d.is_head %} - - - - {% endif %} -
- {% endif %} - {% endfor %} - {% else %} -
Отделы не указаны
- {% endif %} -
-
-
- -
- {# === КАДРОВЫЕ СОБЫТИЯ (для аутентифицированных) === #} - {% if request.user.is_authenticated %} - {% with acts_src=employee_actions|default:emp.actions %} - {% with acts=acts_src.results|default:acts_src %} - {% if acts %} -
-
- - -
-
-
- {% for a in acts %} -
- -
-
{% firstof a.action_display a.action %}
- {% if a.comment %}
{{ a.comment }}
{% endif %} -
- -
- {% if a.extra %} -
- {% for k, v in a.extra.items %} -
{{ k }}: {{ v }}
- {% endfor %} +
+
+
+ {% for a in acts %} +
+ +
+
{% firstof a.action_display a.action %}
+ {% if a.comment %}
{{ a.comment }}
{% endif %} +
+
- {% endif %} + {% if a.extra %} +
+ {% for k, v in a.extra.items %} +
{{ k }}: {{ v }}
+ {% endfor %} +
+ {% endif %} - {# История этого события: без "создано/изменено", только название события и новые значения #} - {% if a.history and a.history|length %} -
- История -
- {% for h in a.history %} - {% with new_action=h.changes.action.new|default:a.action %} -
-
- - {# Переводим код события в человекочитаемый #} - {% if new_action == 'hired' %}Принят - {% elif new_action == 'dismissed' %}Уволен - {% elif new_action == 'on_leave' %}В отпуске - {% elif new_action == 'returned_from_leave' %}Вернулся из отпуска - {% elif new_action == 'on_maternity' %}В декрете - {% elif new_action == 'returned_from_maternity' %}Вернулся из декрета - {% elif new_action == 'transferred' %}Переведен - {% elif new_action == 'rehired' %}Восстановлен - {% else %}{% firstof a.action_display a.action %}{% endif %} - - - {% firstof h.history_date_display h.history_date %}{% if h.history_user %} • {{ h.history_user }}{% endif %} - -
+ {# История этого события: без "создано/изменено", только название события и новые значения #} + {% if a.history and a.history|length %} +
+ История +
+ {% for h in a.history %} + {% with new_action=h.changes.action.new|default:a.action %} +
+
+ + {# Переводим код события в человекочитаемый #} + {% if new_action == 'hired' %}Принят + {% elif new_action == 'dismissed' %}Уволен + {% elif new_action == 'on_leave' %}В отпуске + {% elif new_action == 'returned_from_leave' %}Вернулся из отпуска + {% elif new_action == 'on_maternity' %}В декрете + {% elif new_action == 'returned_from_maternity' %}Вернулся из декрета + {% elif new_action == 'transferred' %}Переведен + {% elif new_action == 'rehired' %}Восстановлен + {% else %}{% firstof a.action_display a.action %}{% endif %} + + + {% firstof h.history_date_display h.history_date %}{% if h.history_user %} • {{ h.history_user }}{% endif %} + +
- {% if h.changes %} -
- {# Показываем только НОВЫЕ значения (без зачёркнутых старых) #} - {% for field, diff in h.changes.items %} - {% if field != 'action' %} -
- - {% if field == 'date' %}Дата события - {% elif field == 'comment' %}Комментарий - {% elif field == 'extra' %}Дополнительно - {% elif field == 'employee_id' %}Сотрудник - {% else %}{{ field }}{% endif %} - : - - {% if field == 'extra' and diff.new %} - {% if diff.new.items %} - {% for ek, ev in diff.new.items %} - {% if not forloop.first %}, {% endif %}{{ ek }}: {{ ev }} - {% endfor %} + {% if h.changes %} +
+ {# Показываем только НОВЫЕ значения (без зачёркнутых старых) #} + {% for field, diff in h.changes.items %} + {% if field != 'action' %} +
+ + {% if field == 'date' %}Дата события + {% elif field == 'comment' %}Комментарий + {% elif field == 'extra' %}Дополнительно + {% elif field == 'employee_id' %}Сотрудник + {% else %}{{ field }}{% endif %} + : + + {% if field == 'extra' and diff.new %} + {% if diff.new.items %} + {% for ek, ev in diff.new.items %} + {% if not forloop.first %}, {% endif %}{{ ek }}: {{ ev }} + {% endfor %} + {% else %} + {{ diff.new }} + {% endif %} {% else %} {{ diff.new }} {% endif %} - {% else %} - {{ diff.new }} - {% endif %} -
- {% endif %} - {% endfor %} -
- {% endif %} -
- {% endwith %} - {% endfor %} -
-
- {% endif %} +
+ {% endif %} + {% endfor %} +
+ {% endif %} +
+ {% endwith %} + {% endfor %} +
+ + {% endif %} +
-
- {% empty %} -
Событий нет.
- {% endfor %} + {% empty %} +
Событий нет.
+ {% endfor %} +
+
-
-
- {% endif %} + {% endif %} + {% endwith %} {% endwith %} - {% endwith %} + {% endif %} + {# ====== НАВЫКИ ====== #} +
+
Навыки
+ +
+ {% with skills=emp.skills %} + {% if skills %} + {% for s in skills %} + {% firstof s.name s as sk_name %} + {% if sk_name %} + + {{ sk_name }} + + {# кнопка «минус» только если есть право #} + {% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %} + + {% endif %} + {% endif %} + {% endfor %} + {% else %} + + {% endif %} + {% endwith %} + {# плюс только если есть право #} + {% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %} + + {% endif %} +
- + {% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %} +
+
+ {% csrf_token %} +
+
+ + + {% for sk in all_skills %} + {% if sk.name %}{% endif %} + {% endfor %} + +
+
+ + +
-
+
+ {% endif %} +
- -
-
-
-
Активность
- {% if posts %} - {% include "feed/_feed_cards.html" with items=posts only %} - {% else %} -
Публикаций пока нет.
- {% endif %} -
-
+ +
+
+
+ + +
+
+
+
Активность
+ {% if posts %} +
+ {% include "feed/_feed_cards.html" with items=posts only %} +
+ + {% else %} +
Публикаций пока нет.
+ {% endif %} +
+
+
-
+ +{% block extra_js %} + + {% endblock %} +{% endblock %} + + + diff --git a/backend/templates/feed/_feed_cards.html b/backend/templates/feed/_feed_cards.html index ed909828..8e73eec6 100644 --- a/backend/templates/feed/_feed_cards.html +++ b/backend/templates/feed/_feed_cards.html @@ -60,7 +60,7 @@ {% if post.pinned %} - закреплено + {% endif %} diff --git a/backend/tests/api/auth/test_auth_and_registration.py b/backend/tests/api/auth/test_auth_and_registration.py index f983d3af..7caf76d8 100644 --- a/backend/tests/api/auth/test_auth_and_registration.py +++ b/backend/tests/api/auth/test_auth_and_registration.py @@ -56,18 +56,18 @@ def register_payload(**overrides): def register(api: APIClient, **overrides): - url = reverse("api:api_v1:register") + url = reverse("api:v1:register") return api.post(url, register_payload(**overrides), format="json") def verify(api: APIClient, *, email: str, code: str | None): - url = reverse("api:api_v1:verify-email") + url = reverse("api:v1:verify-email") # интерфейс верификации: email + code return api.post(url, {"email": email, "code": code}, format="json") def resend(api: APIClient, *, email: str): - url = reverse("api:api_v1:resend-email") + url = reverse("api:v1:resend-email") return api.post(url, {"email": email}, format="json") @@ -109,13 +109,13 @@ def test_register_success_sends_email_and_user_inactive(api): def test_register_missing_required_field(api, missing_field): payload = register_payload() payload.pop(missing_field) - resp = api.post(reverse("api:api_v1:register"), payload, format="json") + resp = api.post(reverse("api:v1:register"), payload, format="json") assert resp.status_code == status.HTTP_400_BAD_REQUEST def test_register_requires_at_least_one_contact(api): payload = register_payload(telegram="", whatsapp="", wechat="") - resp = api.post(reverse("api:api_v1:register"), payload, format="json") + resp = api.post(reverse("api:v1:register"), payload, format="json") assert resp.status_code == status.HTTP_400_BAD_REQUEST assert "WhatsApp" in resp.json().get("detail", "") or "WeChat" in resp.json().get("detail", "") or "Telegram" in resp.json().get("detail", "") 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 6245d97b..3c2373d6 100644 --- a/backend/tests/api/v1/employees/test_department_head_rights.py +++ b/backend/tests/api/v1/employees/test_department_head_rights.py @@ -33,7 +33,7 @@ def _make_user(staff=False, superuser=False) -> Employee: return u def _dept_set_head(api_client, dept_id, head_id): - url = reverse("api:api_v1:departments-set-head", args=[dept_id]) + url = reverse("api:v1:departments-set-head", args=[dept_id]) payload = {"head_id": head_id} if head_id is not None else {"head_id": None} return api_client.post(url, payload, format="json") @@ -47,7 +47,7 @@ def test_old_head_loses_rights_after_change(api_client): dept = Department.objects.create(name="Alpha", head=old_head) # Как глава — может править отдел (partial_update требует manage_department) - url_detail = reverse("api:api_v1:departments-detail", args=[dept.id]) + url_detail = reverse("api:v1:departments-detail", args=[dept.id]) resp = api_client.patch(url_detail, {"description": "init"}, format="json") assert resp.status_code == 200 @@ -79,7 +79,7 @@ def test_new_head_gets_rights_and_membership_created(api_client): # У нового главы есть права управления отделом api_client.force_authenticate(user=new_head) - url_detail = reverse("api:api_v1:departments-detail", args=[dept.id]) + url_detail = reverse("api:v1:departments-detail", args=[dept.id]) resp = api_client.patch(url_detail, {"description": "ok"}, format="json") assert resp.status_code == 200 @@ -97,7 +97,7 @@ def test_remove_head_deactivates_membership_and_revokes_rights(api_client): # как глава он может управлять api_client.force_authenticate(user=head) - url_detail = reverse("api:api_v1:departments-detail", args=[dept.id]) + url_detail = reverse("api:v1:departments-detail", args=[dept.id]) assert api_client.patch(url_detail, {"description": "ok"}, format="json").status_code == 200 # staff снимает главу (head -> null) @@ -138,7 +138,7 @@ def test_old_head_keeps_rights_if_role_grants_after_change(api_client): # старый глава уже не head, но он обладает ролью с manage_department -> должен иметь доступ api_client.force_authenticate(user=old_head) - url_detail = reverse("api:api_v1:departments-detail", args=[dept.id]) + url_detail = reverse("api:v1:departments-detail", args=[dept.id]) resp = api_client.patch(url_detail, {"description": "still ok"}, format="json") assert resp.status_code == 200 diff --git a/backend/tests/api/v1/employees/test_department_roles.py b/backend/tests/api/v1/employees/test_department_roles.py index 5b8c3e78..fe426a75 100644 --- a/backend/tests/api/v1/employees/test_department_roles.py +++ b/backend/tests/api/v1/employees/test_department_roles.py @@ -46,7 +46,7 @@ def _grant_perm(user: Employee, perm_code: str): @pytest.mark.django_db def test_list_and_filter_requires_auth(api_client): - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") # unauth -> 401/403 resp = api_client.get(url) @@ -77,7 +77,7 @@ def test_head_can_crud_in_own_department(api_client): # гарантируем активную связь головы с отделом EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True}) - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") # create в своём отделе resp = api_client.post(url, {"department": dept.id, "name": "Lead"}, format="json") @@ -85,7 +85,7 @@ def test_head_can_crud_in_own_department(api_client): role_id = resp.data["id"] # update - url_detail = reverse("api:api_v1:department-roles-detail", args=[role_id]) + url_detail = reverse("api:v1:department-roles-detail", args=[role_id]) resp = api_client.patch(url_detail, {"name": "Lead++"}, format="json") assert resp.status_code == 200 @@ -102,7 +102,7 @@ def test_head_cannot_crud_in_foreign_department(api_client): EmployeeDepartment.objects.get_or_create(employee=head, department=own, defaults={"is_active": True}) # попытка создать роль в чужом отделе -> 403 - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") resp = api_client.post(url, {"department": foreign.id, "name": "X"}, format="json") assert resp.status_code == status.HTTP_403_FORBIDDEN @@ -112,7 +112,7 @@ def test_non_head_needs_manage_for_crud_and_assign_for_perms(api_client): api_client.force_authenticate(user=user) dept = Department.objects.create(name="Ops") # нет ни head, ни ролей — CRUD запрещён - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") resp = api_client.post(url, {"department": dept.id, "name": "Role"}, format="json") assert resp.status_code == 403 @@ -122,12 +122,12 @@ def test_non_head_needs_manage_for_crud_and_assign_for_perms(api_client): assert resp.status_code == 201 role_id = resp.data["id"] - url_detail = reverse("api:api_v1:department-roles-detail", args=[role_id]) + url_detail = reverse("api:v1:department-roles-detail", args=[role_id]) resp = api_client.patch(url_detail, {"name": "Role2"}, format="json") assert resp.status_code == 200 # но set_perms всё ещё нельзя: нужен assign_department_role - url_set = reverse("api:api_v1:department-roles-set-perms", args=[role_id]) + url_set = reverse("api:v1:department-roles-set-perms", args=[role_id]) resp = api_client.post(url_set, {"permission_ids": []}, format="json") assert resp.status_code == 403 @@ -152,25 +152,25 @@ def test_perms_listing_and_add_remove(api_client): p_manage = Permission.objects.get(content_type__app_label="employees", codename="manage_department") # add_perms (как head) - url_add = reverse("api:api_v1:department-roles-add-perms", args=[role.id]) + url_add = reverse("api:v1:department-roles-add-perms", args=[role.id]) resp = api_client.post(url_add, {"permission_ids": [p_view.id]}, format="json") assert resp.status_code == 200 # perms (GET) - url_perms = reverse("api:api_v1:department-roles-perms", args=[role.id]) + url_perms = reverse("api:v1:department-roles-perms", args=[role.id]) resp = api_client.get(url_perms) assert resp.status_code == 200 codes = {row["codename"] for row in resp.data["results"]} assert "employees.view_employee" in codes # remove_perms - url_rm = reverse("api:api_v1:department-roles-remove-perms", args=[role.id]) + url_rm = reverse("api:v1:department-roles-remove-perms", args=[role.id]) resp = api_client.post(url_rm, {"permission_ids": [p_view.id]}, format="json") assert resp.status_code == 200 assert set(role.permissions.values_list("id", flat=True)) == set() # set_perms - url_set = reverse("api:api_v1:department-roles-set-perms", args=[role.id]) + url_set = reverse("api:v1:department-roles-set-perms", args=[role.id]) resp = api_client.post(url_set, {"permission_ids": [p_manage.id]}, format="json") assert resp.status_code == 200 assert set(role.permissions.values_list("id", flat=True)) == {p_manage.id} diff --git a/backend/tests/api/v1/employees/test_department_roles_extra.py b/backend/tests/api/v1/employees/test_department_roles_extra.py index 4959f875..e8a104bb 100644 --- a/backend/tests/api/v1/employees/test_department_roles_extra.py +++ b/backend/tests/api/v1/employees/test_department_roles_extra.py @@ -58,7 +58,7 @@ def test_perms_endpoint_requires_auth(api_client): d = Department.objects.create(name="A") role = DepartmentRole.objects.create(department=d, name="R") - url = reverse("api:api_v1:department-roles-perms", args=[role.id]) + url = reverse("api:v1:department-roles-perms", args=[role.id]) # unauth -> 401/403 resp = api_client.get(url) @@ -81,7 +81,7 @@ def test_filter_invalid_department_param(api_client): d = Department.objects.create(name="D") DepartmentRole.objects.create(department=d, name="R1") - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") # нечисловой фильтр department -> просто пустой список (ошибки быть не должно) resp = api_client.get(url, {"department": "oops"}) @@ -105,7 +105,7 @@ def test_member_role_with_manage_can_crud_in_own_department(api_client): # делаем пользователя членом отдела с этой ролью EmployeeDepartment.objects.create(employee=user, department=dept, role=role_mgr, is_active=True) - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") # create в своём отделе — должен пройти resp = api_client.post(url, {"department": dept.id, "name": "NewRole"}, format="json") @@ -113,7 +113,7 @@ def test_member_role_with_manage_can_crud_in_own_department(api_client): role_id = resp.data["id"] # update — тоже должен пройти - url_detail = reverse("api:api_v1:department-roles-detail", args=[role_id]) + url_detail = reverse("api:v1:department-roles-detail", args=[role_id]) resp = api_client.patch(url_detail, {"name": "NewRole++"}, format="json") assert resp.status_code == 200 @@ -140,7 +140,7 @@ def test_member_role_assign_can_set_perms_without_manage(api_client): p_view_emp = Permission.objects.get(content_type__app_label="employees", codename="view_employee") # set_perms должен позволяться с одним правом assign_department_role - url_set = reverse("api:api_v1:department-roles-set-perms", args=[target_role.id]) + url_set = reverse("api:v1:department-roles-set-perms", args=[target_role.id]) resp = api_client.post(url_set, {"permission_ids": [p_view_emp.id]}, format="json") assert resp.status_code == 200 assert set(target_role.permissions.values_list("id", flat=True)) == {p_view_emp.id} @@ -155,12 +155,12 @@ def test_global_manage_grants_crud_in_any_department(api_client): foreign = Department.objects.create(name="Foreign-Dept") - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") resp = api_client.post(url, {"department": foreign.id, "name": "GloballyAllowed"}, format="json") assert resp.status_code == 201 role_id = resp.data["id"] - url_detail = reverse("api:api_v1:department-roles-detail", args=[role_id]) + url_detail = reverse("api:v1:department-roles-detail", args=[role_id]) resp = api_client.patch(url_detail, {"name": "EditOK"}, format="json") assert resp.status_code == 200 @@ -182,7 +182,7 @@ def test_role_in_other_dept_does_not_grant_cross_access(api_client): EmployeeDepartment.objects.create(employee=user, department=dept_a, role=role_mgr_a, is_active=True) # create в отделе B -> 403 - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") resp = api_client.post(url, {"department": dept_b.id, "name": "NoAccess"}, format="json") assert resp.status_code == 403 @@ -201,7 +201,7 @@ def test_set_perms_replaces_completely_and_dedup(api_client): # сначала добавим одно право role.permissions.add(p_view) - url_set = reverse("api:api_v1:department-roles-set-perms", args=[role.id]) + url_set = reverse("api:v1:department-roles-set-perms", args=[role.id]) # передаём список с дублями -> в итоге только p_manage resp = api_client.post(url_set, {"permission_ids": [p_manage.id, p_manage.id]}, format="json") assert resp.status_code == 200 @@ -220,7 +220,7 @@ def test_add_and_remove_idempotent(api_client): p2 = Permission.objects.get(content_type__app_label="employees", codename="assign_department_role") # add_perms с дублями - url_add = reverse("api:api_v1:department-roles-add-perms", args=[role.id]) + url_add = reverse("api:v1:department-roles-add-perms", args=[role.id]) resp = api_client.post(url_add, {"permission_ids": [p1.id, p1.id, p2.id]}, format="json") assert resp.status_code == 200 assert set(role.permissions.values_list("id", flat=True)) == {p1.id, p2.id} @@ -231,7 +231,7 @@ def test_add_and_remove_idempotent(api_client): assert set(role.permissions.values_list("id", flat=True)) == {p1.id, p2.id} # remove_perms частичный и повторный (идемпотентность) - url_rm = reverse("api:api_v1:department-roles-remove-perms", args=[role.id]) + url_rm = reverse("api:v1:department-roles-remove-perms", args=[role.id]) resp = api_client.post(url_rm, {"permission_ids": [p1.id]}, format="json") assert resp.status_code == 200 assert set(role.permissions.values_list("id", flat=True)) == {p2.id} @@ -249,7 +249,7 @@ def test_missing_department_on_create_returns_400(api_client): dept = Department.objects.create(name="Doc", head=head) EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True}) - url = reverse("api:api_v1:department-roles-list") + url = reverse("api:v1:department-roles-list") # забыли передать department -> 400 (валидация сериализатора) resp = api_client.post(url, {"name": "RoleX"}, format="json") assert resp.status_code == 400 @@ -264,7 +264,7 @@ def test_permissions_payload_validation_errors(api_client): role = DepartmentRole.objects.create(department=dept, name="R") # не список -> 400 - url_set = reverse("api:api_v1:department-roles-set-perms", args=[role.id]) + url_set = reverse("api:v1:department-roles-set-perms", args=[role.id]) resp = api_client.post(url_set, {"permission_ids": "oops"}, format="json") assert resp.status_code == 400 @@ -280,6 +280,6 @@ def test_delete_nonexistent_returns_404(api_client): dept = Department.objects.create(name="Del", head=head) EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True}) - url_detail = reverse("api:api_v1:department-roles-detail", args=[999999]) + url_detail = reverse("api:v1:department-roles-detail", args=[999999]) resp = api_client.delete(url_detail) assert resp.status_code == 404 diff --git a/backend/tests/api/v1/employees/test_departments.py b/backend/tests/api/v1/employees/test_departments.py index 02746c8a..499323c4 100644 --- a/backend/tests/api/v1/employees/test_departments.py +++ b/backend/tests/api/v1/employees/test_departments.py @@ -130,7 +130,7 @@ def extract_results(data): def test_list_requires_auth(api_client: APIClient): - url = reverse("api:api_v1:departments-list") + url = reverse("api:v1:departments-list") resp = api_client.get(url) assert resp.status_code == status.HTTP_401_UNAUTHORIZED @@ -142,7 +142,7 @@ def test_list_ok_for_authenticated(api_client: APIClient): Department.objects.create(name="A") Department.objects.create(name="B") - url = reverse("api:api_v1:departments-list") + url = reverse("api:v1:departments-list") resp = api_client.get(url) assert resp.status_code == status.HTTP_200_OK items = extract_results(resp.json()) @@ -158,7 +158,7 @@ def test_search_and_ordering(api_client: APIClient): Department.objects.create(name="Beta", description="second") Department.objects.create(name="Gamma", description="third") - base = reverse("api:api_v1:departments-list") + base = reverse("api:v1:departments-list") # поиск resp = api_client.get(base, {"search": "be"}) @@ -193,7 +193,7 @@ def test_employees_count_adds_head_if_not_in_links(api_client: APIClient): EmployeeDepartment.objects.create(employee=e1, department=d, is_active=True) EmployeeDepartment.objects.create(employee=e2, department=d, is_active=True) - url = reverse("api:api_v1:departments-detail", args=[d.pk]) + url = reverse("api:v1:departments-detail", args=[d.pk]) resp = api_client.get(url) assert resp.status_code == 200 data = resp.json() @@ -212,7 +212,7 @@ def test_employees_count_not_double_count_head_if_in_links(api_client: APIClient EmployeeDepartment.objects.create(employee=e1, department=d, is_active=True) EmployeeDepartment.objects.create(employee=e2, department=d, is_active=True) - url = reverse("api:api_v1:departments-detail", args=[d.pk]) + url = reverse("api:v1:departments-detail", args=[d.pk]) resp = api_client.get(url) assert resp.status_code == 200 data = resp.json() @@ -226,7 +226,7 @@ def test_create_requires_staff(api_client: APIClient): user = make_user("u@example.com") api_client.force_authenticate(user=user) - url = reverse("api:api_v1:departments-list") + url = reverse("api:v1:departments-list") # не staff resp = api_client.post(url, {"name": "New"}, format="json") @@ -242,7 +242,7 @@ def test_create_requires_staff(api_client: APIClient): def test_destroy_requires_staff(api_client: APIClient): d = Department.objects.create(name="X") - url = reverse("api:api_v1:departments-detail", args=[d.pk]) + url = reverse("api:v1:departments-detail", args=[d.pk]) user = make_user("u@example.com") api_client.force_authenticate(user=user) @@ -267,7 +267,7 @@ def test_partial_update_name_requires_manage_perm(api_client: APIClient): api_client.force_authenticate(user=emp) EmployeeDepartment.objects.create(employee=emp, department=d, is_active=True) - url = reverse("api:api_v1:departments-detail", args=[d.pk]) + url = reverse("api:v1:departments-detail", args=[d.pk]) # без прав assert ( api_client.patch(url, {"name": "New Name"}, format="json").status_code @@ -298,7 +298,7 @@ def test_partial_update_head_requires_change_head_perm(api_client: APIClient): employee=emp, department=d, is_active=True, role=r_manage ) - url = reverse("api:api_v1:departments-detail", args=[d.pk]) + url = reverse("api:v1:departments-detail", args=[d.pk]) # только manage → 403 assert ( api_client.patch(url, {"head_id": b.id}, format="json").status_code @@ -332,7 +332,7 @@ def test_partial_update_head_same_value_does_not_require_extra_perm( employee=emp, department=d, is_active=True, role=r_manage ) - url = reverse("api:api_v1:departments-detail", args=[d.pk]) + url = reverse("api:v1:departments-detail", args=[d.pk]) assert ( api_client.patch(url, {"head_id": a.id}, format="json").status_code == status.HTTP_200_OK @@ -356,7 +356,7 @@ def test_set_head_by_role_with_perm(api_client: APIClient): employee=manager, department=d, is_active=True, role=r ) - url = reverse("api:api_v1:departments-set-head", args=[d.pk]) + url = reverse("api:v1:departments-set-head", args=[d.pk]) resp = api_client.post(url, {"head_id": candidate.id}, format="json") assert resp.status_code == 200 d.refresh_from_db() @@ -377,7 +377,7 @@ def test_set_head_validation_inactive_employee(api_client: APIClient): employee=manager, department=d, is_active=True, role=r ) - url = reverse("api:api_v1:departments-set-head", args=[d.pk]) + url = reverse("api:v1:departments-set-head", args=[d.pk]) assert ( api_client.post(url, {"head_id": inactive.id}, format="json").status_code == status.HTTP_400_BAD_REQUEST @@ -395,7 +395,7 @@ def test_set_head_remove_with_null(api_client: APIClient): employee=manager, department=d, is_active=True, role=r ) - url = reverse("api:api_v1:departments-set-head", args=[d.pk]) + url = reverse("api:v1:departments-set-head", args=[d.pk]) assert ( api_client.post(url, {"head_id": None}, format="json").status_code == status.HTTP_204_NO_CONTENT @@ -415,7 +415,7 @@ def test_set_head_requires_perm(api_client: APIClient): employee=emp, department=d, is_active=True ) # без роли - url = reverse("api:api_v1:departments-set-head", args=[d.pk]) + url = reverse("api:v1:departments-set-head", args=[d.pk]) assert ( api_client.post(url, {"head_id": candidate.id}, format="json").status_code == status.HTTP_403_FORBIDDEN @@ -440,7 +440,7 @@ def test_set_member_role_happy_path(api_client: APIClient): r_worker = make_role(d, "worker", []) - url = reverse("api:api_v1:departments-set-member-role", args=[d.pk]) + url = reverse("api:v1:departments-set-member-role", args=[d.pk]) payload = {"employee_id": emp.id, "role_id": r_worker.id, "is_active": True} resp = api_client.post(url, payload, format="json") assert resp.status_code == 200 @@ -466,7 +466,7 @@ def test_set_member_role_reject_foreign_role(api_client: APIClient): foreign_role = make_role(d2, "other", []) - url = reverse("api:api_v1:departments-set-member-role", args=[d1.pk]) + url = reverse("api:v1:departments-set-member-role", args=[d1.pk]) resp = api_client.post( url, {"employee_id": emp.id, "role_id": foreign_role.id}, format="json" ) @@ -485,7 +485,7 @@ def test_set_member_role_requires_perm(api_client: APIClient): EmployeeDepartment.objects.create(employee=emp, department=d, is_active=True) role = make_role(d, "worker", []) - url = reverse("api:api_v1:departments-set-member-role", args=[d.pk]) + url = reverse("api:v1:departments-set-member-role", args=[d.pk]) resp = api_client.post( url, {"employee_id": emp.id, "role_id": role.id}, format="json" ) @@ -501,6 +501,6 @@ def test_set_member_role_missing_employee_id(api_client: APIClient): employee=manager, department=d, is_active=True, role=r_assign ) - url = reverse("api:api_v1:departments-set-member-role", args=[d.pk]) + url = reverse("api:v1:departments-set-member-role", args=[d.pk]) resp = api_client.post(url, {"role_id": None}, format="json") assert resp.status_code == status.HTTP_400_BAD_REQUEST diff --git a/backend/tests/api/v1/employees/test_employee_actions.py b/backend/tests/api/v1/employees/test_employee_actions.py index 117127c0..55df0aed 100644 --- a/backend/tests/api/v1/employees/test_employee_actions.py +++ b/backend/tests/api/v1/employees/test_employee_actions.py @@ -63,7 +63,7 @@ def _any_non_dismissed(): @pytest.mark.django_db def test_list_requires_auth_and_filtering(api_client): - url = reverse("api:api_v1:employee-actions-list") + url = reverse("api:v1:employee-actions-list") # unauth assert api_client.get(url).status_code in (401, 403) @@ -100,7 +100,7 @@ def test_create_requires_perm_or_staff(api_client): actor = _user() target = _user() api_client.force_authenticate(user=actor) - url = reverse("api:api_v1:employee-actions-list") + url = reverse("api:v1:employee-actions-list") payload = { "employee": target.id, "action": _any_non_dismissed(), @@ -137,7 +137,7 @@ def test_dismissal_deactivates_employee_and_links(api_client): dept = Department.objects.create(name="D") EmployeeDepartment.objects.create(employee=emp, department=dept, is_active=True) - url = reverse("api:api_v1:employee-actions-list") + url = reverse("api:v1:employee-actions-list") resp = api_client.post( url, { @@ -163,7 +163,7 @@ def test_non_dismissal_activates_employee(api_client): emp.is_active = False emp.save(update_fields=["is_active"]) - url = reverse("api:api_v1:employee-actions-list") + url = reverse("api:v1:employee-actions-list") resp = api_client.post( url, { @@ -190,7 +190,7 @@ def test_update_to_dismissed_applies_effects(api_client): employee=emp, action=_any_non_dismissed(), date=timezone.now() ) - url_det = reverse("api:api_v1:employee-actions-detail", args=[act.id]) + url_det = reverse("api:v1:employee-actions-detail", args=[act.id]) resp = api_client.patch(url_det, {"action": ACTION_DISMISSED}, format="json") assert resp.status_code == 200 emp.refresh_from_db() @@ -205,7 +205,7 @@ def test_delete_requires_perm_or_staff(api_client): act = EmployeeAction.objects.create( employee=emp, action=_any_non_dismissed(), date=timezone.now() ) - url_det = reverse("api:api_v1:employee-actions-detail", args=[act.id]) + url_det = reverse("api:v1:employee-actions-detail", args=[act.id]) # no perm -> 403 assert api_client.delete(url_det).status_code == 403 diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index c29e05d8..df59bd26 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -75,7 +75,7 @@ def extract_results(data): # ---------- basic auth / list / retrieve ---------- def test_list_requires_auth(api_client: APIClient): - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") resp = api_client.get(url) assert resp.status_code == status.HTTP_401_UNAUTHORIZED @@ -86,7 +86,7 @@ def test_list_ok_for_authenticated(api_client: APIClient): e2 = make_user("b@example.com") api_client.force_authenticate(user=user) - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") resp = api_client.get(url) assert resp.status_code == status.HTTP_200_OK @@ -97,7 +97,7 @@ def test_list_ok_for_authenticated(api_client: APIClient): def test_retrieve_requires_auth(api_client: APIClient): e = make_user("x@example.com") - url = reverse("api:api_v1:employees-detail", args=[e.pk]) + url = reverse("api:v1:employees-detail", args=[e.pk]) assert api_client.get(url).status_code == status.HTTP_401_UNAUTHORIZED @@ -106,7 +106,7 @@ def test_retrieve_ok(api_client: APIClient): e = make_user("x@example.com") api_client.force_authenticate(user=user) - url = reverse("api:api_v1:employees-detail", args=[e.pk]) + url = reverse("api:v1:employees-detail", args=[e.pk]) resp = api_client.get(url) assert resp.status_code == status.HTTP_200_OK data = resp.json() @@ -124,7 +124,7 @@ def test_search_by_email_and_last_name(api_client: APIClient): b = make_user("beta@example.com", first_name="Bob", last_name="Beta") _ = make_user("gamma@example.com", first_name="Garry", last_name="Gamma") - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") # поиск по части email r = api_client.get(url, {"search": "bet"}) @@ -161,7 +161,7 @@ def test_filter_by_department_includes_members_and_head(api_client: APIClient): d = Department.objects.create(name="Dept", head=head) EmployeeDepartment.objects.create(employee=member, department=d, is_active=True) - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") r = api_client.get(url, {"department": d.id}) assert r.status_code == 200 ids = {it["id"] for it in extract_results(r.json())} @@ -186,7 +186,7 @@ def test_filter_by_position(api_client: APIClient): _ = make_user("q1@example.com", position=p2) - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") r = api_client.get(url, {"position": p1.id}) assert r.status_code == 200 ids = {it["id"] for it in extract_results(r.json())} @@ -208,7 +208,7 @@ def test_filter_by_skills_any_of(api_client: APIClient): _ = make_user("e3@example.com") # без навыков - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") r = api_client.get(url, [("skill", s1.id), ("skill", s2.id)]) assert r.status_code == 200 ids = {it["id"] for it in extract_results(r.json())} @@ -223,7 +223,7 @@ def test_filter_by_email_verified_and_active(api_client: APIClient): v0 = make_user("v0@example.com", verified=False, active=True) a0 = make_user("a0@example.com", verified=True, active=False) - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") r = api_client.get(url, {"email_verified": "true"}) ids = {it["id"] for it in extract_results(r.json())} @@ -250,7 +250,7 @@ def test_filter_actually_active_logic(api_client: APIClient): # e_unver: email_verified=False, даже при active=True — не включается при actually_active=true e_unver = make_user("unver@example.com", verified=False, active=True) - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") r = api_client.get(url, {"actually_active": "true"}) ids = {it["id"] for it in extract_results(r.json())} @@ -270,7 +270,7 @@ def test_create_requires_staff(api_client: APIClient): user = make_user("u@example.com") api_client.force_authenticate(user=user) - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") payload = { "email": "new@example.com", "phone_number": _unique_phone(), @@ -301,7 +301,7 @@ def test_create_requires_staff(api_client: APIClient): def test_partial_update_self_allowed(api_client: APIClient): e = make_user("self@example.com") api_client.force_authenticate(user=e) - url = reverse("api:api_v1:employees-detail", args=[e.pk]) + url = reverse("api:v1:employees-detail", args=[e.pk]) resp = api_client.patch(url, {"first_name": "Me"}, format="json") assert resp.status_code == status.HTTP_200_OK @@ -313,7 +313,7 @@ def test_partial_update_other_forbidden_for_regular_user(api_client: APIClient): owner = make_user("owner@example.com") other = make_user("other@example.com") api_client.force_authenticate(user=other) - url = reverse("api:api_v1:employees-detail", args=[owner.pk]) + url = reverse("api:v1:employees-detail", args=[owner.pk]) resp = api_client.patch(url, {"first_name": "Hack"}, format="json") assert resp.status_code == status.HTTP_403_FORBIDDEN @@ -323,7 +323,7 @@ def test_partial_update_other_allowed_for_staff(api_client: APIClient): target = make_user("target@example.com") staff = make_user("admin@example.com", staff=True) api_client.force_authenticate(user=staff) - url = reverse("api:api_v1:employees-detail", args=[target.pk]) + url = reverse("api:v1:employees-detail", args=[target.pk]) resp = api_client.patch(url, {"first_name": "Ok"}, format="json") assert resp.status_code == status.HTTP_200_OK @@ -336,7 +336,7 @@ def test_destroy_requires_admin(api_client: APIClient): regular = make_user("regular@example.com") staff = make_user("staff@example.com", staff=True) - url = reverse("api:api_v1:employees-detail", args=[victim.pk]) + url = reverse("api:v1:employees-detail", args=[victim.pk]) api_client.force_authenticate(user=regular) assert api_client.delete(url).status_code in ( @@ -356,7 +356,7 @@ def test_me_get_returns_current_user(api_client: APIClient): me = make_user("me@example.com") api_client.force_authenticate(user=me) - url = reverse("api:api_v1:employees-me") + url = reverse("api:v1:employees-me") r = api_client.get(url) assert r.status_code == 200 assert r.json()["id"] == me.id @@ -366,7 +366,7 @@ def test_me_patch_updates_profile(api_client: APIClient): me = make_user("me@example.com", telegram="@me") # уже есть контакт api_client.force_authenticate(user=me) - url = reverse("api:api_v1:employees-me") + url = reverse("api:v1:employees-me") r = api_client.patch(url, {"first_name": "Updated"}, format="json") assert r.status_code == 200 me.refresh_from_db() @@ -382,6 +382,6 @@ def test_me_patch_rejects_when_all_contacts_empty(api_client: APIClient): me = make_user("nocc@example.com", telegram="", whatsapp="", wechat="") api_client.force_authenticate(user=me) - url = reverse("api:api_v1:employees-me") + url = reverse("api:v1:employees-me") r = api_client.patch(url, {"first_name": "Try"}, format="json") assert r.status_code == status.HTTP_400_BAD_REQUEST diff --git a/backend/tests/api/v1/employees/test_employees_fields_in_list.py b/backend/tests/api/v1/employees/test_employees_fields_in_list.py index 86f6e481..01b7b734 100644 --- a/backend/tests/api/v1/employees/test_employees_fields_in_list.py +++ b/backend/tests/api/v1/employees/test_employees_fields_in_list.py @@ -83,7 +83,7 @@ def test_list_includes_basic_fields(api): "emp@example.com", first_name="Ivan", last_name="Ivanov", position=pos ) - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") r = api.get(url) assert r.status_code == status.HTTP_200_OK items = extract_results(r.json()) @@ -127,7 +127,7 @@ def test_list_departments_include_role(api): employee=emp, department=dept2, is_active=True ) # без роли - url = reverse("api:api_v1:employees-list") + url = reverse("api:v1:employees-list") r = api.get(url) assert r.status_code == status.HTTP_200_OK items = extract_results(r.json()) diff --git a/backend/tests/api/v1/employees/test_positions.py b/backend/tests/api/v1/employees/test_positions.py index 676c9dd6..c2c4cdb8 100644 --- a/backend/tests/api/v1/employees/test_positions.py +++ b/backend/tests/api/v1/employees/test_positions.py @@ -2,6 +2,7 @@ from itertools import count import pytest +from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, Permission from django.urls import reverse from employees.models import Employee, Position @@ -16,18 +17,19 @@ def _unique_phone() -> str: return f"+7999{next(_phone_seq):07d}" -def _grant_perm(user: Employee, perm_code: str): - """ - perm_code формат: 'app_label.codename', например 'employees.add_position' - """ - try: - app_label, codename = perm_code.split(".", 1) - except ValueError: - raise AssertionError("perm_code must be 'app_label.codename'") +def _grant_perm(user, code: str): + app_label, codename = code.split(".") perm = Permission.objects.get(content_type__app_label=app_label, codename=codename) user.user_permissions.add(perm) - user.save() - return perm + + # Сброс кэшей пермишенов у этого инстанса + for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): + if hasattr(user, attr): + delattr(user, attr) + + # Перечитать из БД (на всякий случай) + User = get_user_model() + return User.objects.get(pk=user.pk) def _make_user(email: str, staff: bool = False, superuser: bool = False) -> Employee: @@ -53,7 +55,7 @@ def _make_user(email: str, staff: bool = False, superuser: bool = False) -> Empl @pytest.mark.django_db def test_list_requires_auth(api_client): - url = reverse("api:api_v1:positions-list") + url = reverse("api:v1:positions-list") # unauth -> 401 resp = api_client.get(url) @@ -74,7 +76,7 @@ def test_list_requires_auth(api_client): @pytest.mark.django_db def test_create_permissions(api_client): - url = reverse("api:api_v1:positions-list") + url = reverse("api:v1:positions-list") # обычный пользователь без прав -> 403 user = _make_user("user@example.com") @@ -104,7 +106,7 @@ def test_update_permissions_and_search_ordering(api_client): api_client.force_authenticate(user=user) # без прав change_position -> 403 - url_detail = reverse("api:api_v1:positions-detail", args=[p1.id]) + url_detail = reverse("api:v1:positions-detail", args=[p1.id]) resp = api_client.patch(url_detail, {"description": "updated"}, format="json") assert resp.status_code == status.HTTP_403_FORBIDDEN @@ -116,7 +118,7 @@ def test_update_permissions_and_search_ordering(api_client): assert p1.description == "updated" # проверим search & ordering - url_list = reverse("api:api_v1:positions-list") + url_list = reverse("api:v1:positions-list") resp = api_client.get(url_list, {"search": "alp"}) assert resp.status_code == 200 assert len(resp.data) == 1 @@ -133,7 +135,7 @@ def test_delete_permissions(api_client): pos = Position.objects.create(name="ToDelete") user = _make_user("user@example.com") api_client.force_authenticate(user=user) - url_detail = reverse("api:api_v1:positions-detail", args=[pos.id]) + url_detail = reverse("api:v1:positions-detail", args=[pos.id]) # без delete_position -> 403 resp = api_client.delete(url_detail) @@ -155,7 +157,7 @@ def test_set_groups_requires_special_perm(api_client): user = _make_user("user@example.com") api_client.force_authenticate(user=user) - url_set = reverse("api:api_v1:positions-set-groups", args=[pos.id]) + url_set = reverse("api:v1:positions-set-groups", args=[pos.id]) # даже с change_position НЕЛЬЗЯ менять группы — нужен assign_position_groups _grant_perm(user, "employees.change_position") @@ -192,7 +194,7 @@ def test_add_and_remove_groups_and_permissions_endpoint(api_client): _grant_perm(user, "employees.assign_position_groups") # add_groups - url_add = reverse("api:api_v1:positions-add-groups", args=[pos.id]) + url_add = reverse("api:v1:positions-add-groups", args=[pos.id]) resp = api_client.post(url_add, {"groups": [gA.id]}, format="json") assert resp.status_code == 200 assert set(pos.groups.values_list("id", flat=True)) == {gA.id} @@ -203,7 +205,7 @@ def test_add_and_remove_groups_and_permissions_endpoint(api_client): assert set(pos.groups.values_list("id", flat=True)) == {gA.id, gB.id} # permissions endpoint - url_perms = reverse("api:api_v1:positions-permissions", args=[pos.id]) + url_perms = reverse("api:v1:positions-permissions", args=[pos.id]) resp = api_client.get(url_perms) assert resp.status_code == 200 codes = {row["codename"] for row in resp.data["results"]} @@ -211,13 +213,13 @@ def test_add_and_remove_groups_and_permissions_endpoint(api_client): assert "auth.view_group" in codes # remove_groups (уберём A) - url_rm = reverse("api:api_v1:positions-remove-groups", args=[pos.id]) + url_rm = reverse("api:v1:positions-remove-groups", args=[pos.id]) resp = api_client.post(url_rm, {"groups": [gA.id]}, format="json") assert resp.status_code == 200 assert set(pos.groups.values_list("id", flat=True)) == {gB.id} # retrieve содержит groups_verbose - url_detail = reverse("api:api_v1:positions-detail", args=[pos.id]) + url_detail = reverse("api:v1:positions-detail", args=[pos.id]) resp = api_client.get(url_detail) assert resp.status_code == 200 assert "groups_verbose" in resp.data @@ -231,7 +233,7 @@ def test_groups_payload_validation(api_client): api_client.force_authenticate(user=user) _grant_perm(user, "employees.assign_position_groups") - url_set = reverse("api:api_v1:positions-set-groups", args=[pos.id]) + url_set = reverse("api:v1:positions-set-groups", args=[pos.id]) # groups не список -> 400 resp = api_client.post(url_set, {"groups": "not-a-list"}, format="json") diff --git a/backend/tests/api/v1/employees/test_skills.py b/backend/tests/api/v1/employees/test_skills.py index 0e49781b..6586e4b4 100644 --- a/backend/tests/api/v1/employees/test_skills.py +++ b/backend/tests/api/v1/employees/test_skills.py @@ -57,7 +57,7 @@ def _grant_perm(user: Employee, perm_code: str): @pytest.mark.django_db def test_list_search_ordering_requires_auth(api_client): - url = reverse("api:api_v1:skills-list") + url = reverse("api:v1:skills-list") # unauth resp = api_client.get(url) assert resp.status_code in (401, 403) @@ -89,7 +89,7 @@ def test_list_search_ordering_requires_auth(api_client): def test_create_allowed_for_regular_user(api_client): user = _mk_user() api_client.force_authenticate(user=user) - url = reverse("api:api_v1:skills-list") + url = reverse("api:v1:skills-list") resp = api_client.post(url, {"name": "Go"}, format="json") assert resp.status_code == 201 assert Skill.objects.filter(name="Go").exists() @@ -102,7 +102,7 @@ def test_update_delete_permissions(api_client): s = Skill.objects.create(name="Docker") # update -> 403 - url_det = reverse("api:api_v1:skills-detail", args=[s.id]) + url_det = reverse("api:v1:skills-detail", args=[s.id]) resp = api_client.patch(url_det, {"name": "Docker+"}, format="json") assert resp.status_code == 403 @@ -129,7 +129,7 @@ def test_staff_bypasses_permissions(api_client): staff = _mk_user(staff=True) api_client.force_authenticate(user=staff) s = Skill.objects.create(name="K8s") - url_det = reverse("api:api_v1:skills-detail", args=[s.id]) + url_det = reverse("api:v1:skills-detail", args=[s.id]) assert ( api_client.patch(url_det, {"name": "Kubernetes"}, format="json").status_code == 200 @@ -141,7 +141,7 @@ def test_staff_bypasses_permissions(api_client): def test_bulk_create_by_regular_user(api_client): user = _mk_user() api_client.force_authenticate(user=user) - url = reverse("api:api_v1:skills-bulk-create") + url = reverse("api:v1:skills-bulk-create") resp = api_client.post( url, {"names": [" Python ", "python", "SQL", ""]}, format="json" @@ -166,7 +166,7 @@ def test_merge_requires_perm_and_reassigns(api_client): e1.skills.add(src) e2.skills.add(src) - url = reverse("api:api_v1:skills-merge") + url = reverse("api:v1:skills-merge") # без прав -> 403 resp = api_client.post( @@ -195,7 +195,7 @@ def test_merge_same_id_and_invalid_ids(api_client): _grant_perm(user, "employees.change_skill") s = Skill.objects.create(name="A") - url = reverse("api:api_v1:skills-merge") + url = reverse("api:v1:skills-merge") # same id resp = api_client.post(url, {"source_id": s.id, "target_id": s.id}, format="json") assert resp.status_code == 400 @@ -216,7 +216,7 @@ def test_merge_without_reassign_or_delete(api_client): e = _mk_user() e.skills.add(src) - url = reverse("api:api_v1:skills-merge") + url = reverse("api:v1:skills-merge") resp = api_client.post( url, { @@ -248,7 +248,7 @@ def test_merge_idempotent_if_employee_already_has_target(api_client): e = _mk_user() e.skills.add(src, dst) - url = reverse("api:api_v1:skills-merge") + url = reverse("api:v1:skills-merge") resp = api_client.post( url, {"source_id": src.id, "target_id": dst.id}, format="json" ) diff --git a/backend/tests/api/v1/feed/test_comments.py b/backend/tests/api/v1/feed/test_comments.py index 61733b49..3be84bb0 100644 --- a/backend/tests/api/v1/feed/test_comments.py +++ b/backend/tests/api/v1/feed/test_comments.py @@ -53,7 +53,7 @@ def _items(resp): @pytest.mark.django_db def test_list_requires_auth_and_filters_ordering(api_client): - url = reverse("api:api_v1:comments-list") + url = reverse("api:v1:comments-list") assert api_client.get(url).status_code in (401, 403) author = _user() @@ -88,7 +88,7 @@ def test_create_text_only_and_files(api_client): api_client.force_authenticate(user=user) p = Post.objects.create(author=user, type=TYPE_COMPANY, title="news", body="b") - url = reverse("api:api_v1:comments-list") + url = reverse("api:v1:comments-list") # text only resp = api_client.post(url, {"post": p.id, "text": "hi"}, format="multipart") @@ -112,7 +112,7 @@ def test_create_text_only_and_files(api_client): def test_create_empty_payload_is_400(api_client): user = _user(); api_client.force_authenticate(user=user) p = Post.objects.create(author=user, type=TYPE_COMPANY, title="n", body="b") - url = reverse("api:api_v1:comments-list") + url = reverse("api:v1:comments-list") resp = api_client.post(url, {"post": p.id, "text": ""}, format="multipart") assert resp.status_code == 400 @@ -128,7 +128,7 @@ def test_update_permissions_and_validation(api_client): p = Post.objects.create(author=author, type=TYPE_COMPANY, title="n", body="b") c = Comment.objects.create(post=p, author=author, text="orig") - url_detail = reverse("api:api_v1:comments-detail", args=[c.id]) + url_detail = reverse("api:v1:comments-detail", args=[c.id]) # не автор -> 403 api_client.force_authenticate(user=other) @@ -156,7 +156,7 @@ def test_delete_permissions(api_client): author = _user(); staff = _user(staff=True); other = _user() p = Post.objects.create(author=author, type=TYPE_COMPANY, title="n", body="b") c = Comment.objects.create(post=p, author=author, text="delme") - url_detail = reverse("api:api_v1:comments-detail", args=[c.id]) + url_detail = reverse("api:v1:comments-detail", args=[c.id]) # другой пользователь -> 403 api_client.force_authenticate(user=other) @@ -169,4 +169,4 @@ def test_delete_permissions(api_client): # staff тоже может (создадим новый) c2 = Comment.objects.create(post=p, author=author, text="del2") api_client.force_authenticate(user=staff) - assert api_client.delete(reverse("api:api_v1:comments-detail", args=[c2.id])).status_code == 204 + assert api_client.delete(reverse("api:v1:comments-detail", args=[c2.id])).status_code == 204 diff --git a/backend/tests/api/v1/feed/test_posts.py b/backend/tests/api/v1/feed/test_posts.py index 728844fb..f8c81e4d 100644 --- a/backend/tests/api/v1/feed/test_posts.py +++ b/backend/tests/api/v1/feed/test_posts.py @@ -77,7 +77,7 @@ def _items(resp): @pytest.mark.django_db def test_list_requires_auth(api_client): - url = reverse("api:api_v1:posts-list") + url = reverse("api:v1:posts-list") assert api_client.get(url).status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) user = _user() @@ -87,7 +87,7 @@ def test_list_requires_auth(api_client): @pytest.mark.django_db def test_create_company_requires_staff_or_perm(api_client): - url = reverse("api:api_v1:posts-list") + url = reverse("api:v1:posts-list") author = _user() api_client.force_authenticate(user=author) @@ -118,7 +118,7 @@ def test_create_company_requires_staff_or_perm(api_client): @pytest.mark.django_db def test_create_department_requires_head_or_role_or_staff(api_client): - url = reverse("api:api_v1:posts-list") + url = reverse("api:v1:posts-list") dept = Department.objects.create(name="R&D") u = _user() @@ -159,7 +159,7 @@ def test_create_department_requires_head_or_role_or_staff(api_client): @pytest.mark.django_db def test_employee_type_is_forbidden_and_department_field_validation(api_client): - url = reverse("api:api_v1:posts-list") + url = reverse("api:v1:posts-list") staff = _user(staff=True) api_client.force_authenticate(user=staff) dept = Department.objects.create(name="Ops") @@ -190,7 +190,7 @@ def test_update_and_delete_permissions_match_create_rules(api_client): # создаём пост отдела от лица главы api_client.force_authenticate(user=head) - url = reverse("api:api_v1:posts-list") + url = reverse("api:v1:posts-list") resp = api_client.post( url, {"type": TYPE_DEPARTMENT, "title": "t1", "body": "b1", "department": dept.id}, @@ -199,7 +199,7 @@ def test_update_and_delete_permissions_match_create_rules(api_client): assert resp.status_code == status.HTTP_201_CREATED post_id = resp.data["id"] - url_detail = reverse("api:api_v1:posts-detail", args=[post_id]) + url_detail = reverse("api:v1:posts-detail", args=[post_id]) # другой обычный пользователь не может править/удалять stranger = _user() @@ -224,7 +224,7 @@ def test_list_by_author_shows_all_authors_posts(api_client): _grant_dept_role_perm(author, dept, "feed.publish_department_post") api_client.force_authenticate(user=author) - url = reverse("api:api_v1:posts-list") + url = reverse("api:v1:posts-list") # company пост r1 = api_client.post(