From d7f0090c768a5f94867840995ee88cd951ef93c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 26 Aug 2025 09:00:01 +0300 Subject: [PATCH] =?UTF-8?q?**feat(employees):=20Skill=20API=20+=20Employee?= =?UTF-8?q?Action=20API=20=D1=81=20=D0=B1=D0=B8=D0=B7=D0=BD=D0=B5=D1=81-?= =?UTF-8?q?=D1=8D=D1=84=D1=84=D0=B5=D0=BA=D1=82=D0=B0=D0=BC=D0=B8,=20?= =?UTF-8?q?=D1=80=D0=BE=D1=83=D1=82=D0=B8=D0=BD=D0=B3=20=D0=B8=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B**?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Skill * Добавлен `SkillViewSet`: * CRUD: `/api/v1/skills/` * Любой авторизованный может **создавать** навыки. * Изменение/удаление — только `staff/superuser` или при наличии модельных прав: * `employees.change_skill` (PATCH/PUT, а также `merge`) * `employees.delete_skill` (DELETE) * Поиск/сортировка: `?search=` по `name`, `?ordering=name|-name`, без пагинации. * Экшены: * `POST /api/v1/skills/bulk_create` — массовое создание (`names: string[]`, нормализация, дедуп). * `POST /api/v1/skills/merge` — объединение навыков: * body: `{ source_id, target_id, reassign=true, delete_source=true }` * переносит связь сотрудников `source → target`, опционально удаляет `source`. * идемпотентно, учитывает случаи когда у сотрудника уже есть `target`. * Сериализатор: `SkillSerializer`. * Роутинг: зарегистрирован `skills`. ### EmployeeAction * Добавлен `EmployeeActionViewSet`: * CRUD: `/api/v1/employee-actions/` * Чтение — для авторизованных. * Запись — `staff/superuser` или держатели модельных прав: * `employees.add_employeeaction` * `employees.change_employeeaction` * `employees.delete_employeeaction` * Фильтры: `?employee=`, `?date_from=`, `?date_to=`. * Сортировка: по умолчанию `-date`. * Бизнес-эффекты: * `ACTION_DISMISSED` — деактивация сотрудника (`employee.is_active=False`) и всех активных `EmployeeDepartment` с выставлением `date_to=today`. * Любое иное действие — активирует сотрудника (`employee.is_active=True`), без автоматического восстановления отделов. * Сериализатор: `EmployeeActionSerializer` (если `date` не передан — проставляется `now()`). * Роутинг: зарегистрирован `employee-actions`. ### Тесты * `tests/api/v1/employees/test_skills.py`: * список/поиск/сортировка; * создание обычным пользователем; * права на обновление/удаление (включая staff bypass); * `bulk_create` (нормализация и дедуп); * `merge` (переназначение, параметры `reassign/delete_source`, идемпотентность). * `tests/api/v1/employees/test_employee_actions.py`: * доступ и фильтрация по сотруднику; * создание с правами/без, staff bypass; * эффекты увольнения (деактивация сотрудника и связей); * активация при неувольняющем действии; * эффекты при изменении существующего события на `DISMISSED`; * удаление с правами. * **Учёт авто-создаваемых событий**: проверки построены на изменении/baseline, а не на «пустой истории». ### Изменённые файлы (основные) * `backend/api/v1/employees/serializers.py` — `SkillSerializer`, `EmployeeActionSerializer` * `backend/api/v1/employees/views.py` — `SkillViewSet`, `EmployeeActionViewSet` * `backend/api/v1/urls.py` — роуты `skills`, `employee-actions` * `tests/api/v1/employees/test_skills.py` — новый * `tests/api/v1/employees/test_employee_actions.py` — обновлён с учётом baseline и добавленными сценариями ### Заметки * Миграций нет (использованы существующие модели). * Поведение по автособытиям при создании сотрудника поддержано в тестах (baseline). --- backend/api/v1/employees/serializers.py | 32 +- backend/api/v1/employees/views.py | 343 +++++++++++++++++- backend/api/v1/urls.py | 5 +- backend/employees/models.py | 2 +- backend/eusrr_backend/settings.py | 13 +- .../api/v1/employees/test_employee_actions.py | 215 +++++++++++ backend/tests/api/v1/employees/test_skills.py | 259 +++++++++++++ 7 files changed, 850 insertions(+), 19 deletions(-) create mode 100644 backend/tests/api/v1/employees/test_employee_actions.py create mode 100644 backend/tests/api/v1/employees/test_skills.py diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 7f3d0201..8a148c56 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -5,11 +5,19 @@ 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, EmployeeDepartment, - Position) +from django.utils import timezone from PIL import Image from rest_framework import serializers +from employees.models import ( + Department, + DepartmentRole, + EmployeeAction, + EmployeeDepartment, + Position, + Skill, +) + Employee = get_user_model() @@ -284,3 +292,23 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): } for p in obj.permissions.select_related("content_type").all() ] + + +class SkillSerializer(serializers.ModelSerializer): + class Meta: + model = Skill + fields = ["id", "name"] + + +class EmployeeActionSerializer(serializers.ModelSerializer): + # по умолчанию ставим текущее время, если не передали + date = serializers.DateTimeField(required=False) + + class Meta: + model = EmployeeAction + fields = ["id", "employee", "action", "date", "comment", "extra"] + + def validate(self, attrs): + if "date" not in attrs: + attrs["date"] = timezone.now() + return attrs diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index f2f5aec9..c40c32e5 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -28,8 +28,9 @@ from rest_framework.views import APIView from .serializers import (DepartmentRoleSerializer, DepartmentSerializer, EmailSerializer, EmailVerifySerializer, - EmployeeListSerializer, EmployeeSerializer, - PositionSerializer, RegisterSerializer) + EmployeeActionSerializer, EmployeeListSerializer, + EmployeeSerializer, PositionSerializer, + RegisterSerializer, SkillSerializer) def _to_bool(val: str | None) -> bool | None: @@ -68,6 +69,74 @@ def _detect_phone_field() -> str | None: PHONE_FIELD = _detect_phone_field() +class HistoryActionMixin: + """ + Добавляет GET /{basename}/{pk}/history/ + Параметры (необяз.): ?from=ISO ?to=ISO ?user= ?type=+|~|- + """ + + history_diff_fields = None # список полей для diff; если None — попытаемся угадать + + @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + def history(self, request, pk=None): + obj = self.get_object() + qs = obj.history.select_related("history_user").order_by( + "-history_date", "-history_id" + ) + + q_from = request.query_params.get("from") + q_to = request.query_params.get("to") + q_user = request.query_params.get("user") + q_type = request.query_params.get("type") + + if q_from: + try: + qs = qs.filter(history_date__gte=q_from) + except: + pass + if q_to: + try: + qs = qs.filter(history_date__lte=q_to) + except: + pass + if q_user: + qs = qs.filter( + models.Q(history_user__id__iexact=q_user) + | models.Q(history_user__email__iexact=q_user) + ) + if q_type in {"+", "~", "-"}: + qs = qs.filter(history_type=q_type) + + items = list(qs) + results = [] + for i, cur in enumerate(items): + prev = items[i + 1] if i + 1 < len(items) else None + changes = {} + # какие поля сравнивать + if self.history_diff_fields is not None: + fields = self.history_diff_fields + else: + # берём только реальные field.name модели (без M2M) + fields = [f.name for f in obj._meta.fields] + + for name in fields: + new = getattr(cur, name, None) + old = getattr(prev, name, None) if prev else None + if old != new: + changes[name] = {"old": old, "new": new} + + results.append( + { + "history_id": cur.history_id, + "history_date": cur.history_date, + "history_type": cur.history_type, # "+", "~", "-" + "history_user": getattr(cur.history_user, "email", None), + "changes": changes, + } + ) + return Response({"count": len(results), "results": results}) + + class ResendEmailAPIView(APIView): """POST /api/v1/auth/resend-email/ body: {"email": "..."}""" @@ -227,7 +296,7 @@ class RegisterAPIView(APIView): ) -class DepartmentViewSet(viewsets.ModelViewSet): +class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet): """ /api/departments/ - GET (list/detail) — только аутентифицированные пользователи @@ -242,6 +311,7 @@ class DepartmentViewSet(viewsets.ModelViewSet): search_fields = ["name", "description"] ordering = ["name"] filter_backends = [filters.SearchFilter, filters.OrderingFilter] + history_diff_fields = ["name", "description", "head_id", "head_appointed_at"] def get_queryset(self): # подзапрос: есть ли head среди связей отдела @@ -623,7 +693,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): return Response(ser.data) -class PositionViewSet(viewsets.ModelViewSet): +class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet): """ /api/v1/positions/ GET list/retrieve — аутентифицированные @@ -643,6 +713,7 @@ class PositionViewSet(viewsets.ModelViewSet): search_fields = ["name", "description"] ordering_fields = ["name", "id"] ordering = ["name"] + history_diff_fields = ["name", "description"] def get_permissions(self): # важное место: выставляем требуемый perm под действие @@ -738,6 +809,7 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): ?department= — роли конкретного отдела ?search= — поиск по названию """ + serializer_class = DepartmentRoleSerializer permission_classes = [IsAuthenticated, IsDeptManagerForWrite] filter_backends = [filters.SearchFilter, filters.OrderingFilter] @@ -777,7 +849,10 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): checker = IsDeptManagerForWrite() if not checker.has_object_permission(self.request, self, dept): from rest_framework.exceptions import PermissionDenied - raise PermissionDenied("Недостаточно прав для управления ролями этого отдела") + + raise PermissionDenied( + "Недостаточно прав для управления ролями этого отдела" + ) serializer.save() # update/destroy — has_object_permission отработает, т.к. мы добавили IsDeptManagerForWrite @@ -787,10 +862,14 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): def _validate_perm_ids(self, request): ids = request.data.get("permission_ids") if not isinstance(ids, list): - return None, Response({"detail": "Поле 'permission_ids' должно быть списком id"}, status=400) + return None, Response( + {"detail": "Поле 'permission_ids' должно быть списком id"}, status=400 + ) perms = Permission.objects.filter(id__in=ids).select_related("content_type") if perms.count() != len(set(ids)): - return None, Response({"detail": "Некоторые permissions не найдены"}, status=400) + return None, Response( + {"detail": "Некоторые permissions не найдены"}, status=400 + ) return perms, None @action(detail=True, methods=["get"]) @@ -814,7 +893,12 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): if err: return err role.permissions.set(perms) - return Response({"ok": True, "permission_ids": list(role.permissions.values_list("id", flat=True))}) + return Response( + { + "ok": True, + "permission_ids": list(role.permissions.values_list("id", flat=True)), + } + ) @action(detail=True, methods=["post"]) def add_perms(self, request, pk=None): @@ -823,7 +907,12 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): if err: return err role.permissions.add(*perms) - return Response({"ok": True, "permission_ids": list(role.permissions.values_list("id", flat=True))}) + return Response( + { + "ok": True, + "permission_ids": list(role.permissions.values_list("id", flat=True)), + } + ) @action(detail=True, methods=["post"]) def remove_perms(self, request, pk=None): @@ -832,4 +921,238 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): if err: return err role.permissions.remove(*perms) - return Response({"ok": True, "permission_ids": list(role.permissions.values_list("id", flat=True))}) \ No newline at end of file + return Response( + { + "ok": True, + "permission_ids": list(role.permissions.values_list("id", flat=True)), + } + ) + + +class SkillViewSet(viewsets.ModelViewSet): + """ + /api/v1/skills/ + - GET list/retrieve — IsAuthenticated + - POST (create) — IsAuthenticated (любой авторизованный может создавать новые навыки) + - PATCH/PUT — staff/superuser ИЛИ perm employees.change_skill + - DELETE — staff/superuser ИЛИ perm employees.delete_skill + Экшены: + - POST /skills/bulk_create — как create (любой авторизованный) + - POST /skills/merge — объединить source->target (perm employees.change_skill) + body: { + "source_id": 1, + "target_id": 2, + "reassign": true, # переназначить сотрудникам source -> target (по умолч. true) + "delete_source": true # удалить исходный навык после переназначения (по умолч. true) + } + """ + + queryset = Skill.objects.all().order_by("name") + serializer_class = SkillSerializer + filter_backends = [filters.SearchFilter, filters.OrderingFilter] + search_fields = ["name"] + ordering_fields = ["name", "id"] + ordering = ["name"] + pagination_class = None + + 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()] + + @action(detail=False, methods=["post"]) + def bulk_create(self, request): + names = request.data.get("names") + if not isinstance(names, list) or not names: + return Response( + {"detail": "Поле 'names' должно быть непустым списком строк"}, + status=400, + ) + + # нормализация: trim + отбраковка пустых, дедуп (case-insensitive) + cleaned = [] + seen = set() + for n in names: + if not isinstance(n, str): + continue + s = n.strip() + if not s: + continue + key = s.lower() + if key in seen: + continue + seen.add(key) + cleaned.append(s) + + # отфильтруем уже существующие (по точному совпадению имени) + existing = set( + Skill.objects.filter(name__in=cleaned).values_list("name", flat=True) + ) + to_create = [Skill(name=n) for n in cleaned if n not in existing] + Skill.objects.bulk_create(to_create, ignore_conflicts=True) + + created = Skill.objects.filter( + name__in=[n for n in cleaned if n not in existing] + ).order_by("name") + return Response( + { + "created_count": created.count(), + "created": SkillSerializer(created, many=True).data, + }, + status=201, + ) + + @action(detail=False, methods=["post"]) + @transaction.atomic + def merge(self, request): + """ + Заменяет навык source на target, опционально переносит всех сотрудников и удаляет source. + Требуется право employees.change_skill. + """ + sid = request.data.get("source_id") + tid = request.data.get("target_id") + reassign = bool(request.data.get("reassign", True)) + delete_source = bool(request.data.get("delete_source", True)) + + if not sid or not tid: + return Response({"detail": "source_id и target_id обязательны"}, status=400) + try: + sid = int(sid) + tid = int(tid) + except (TypeError, ValueError): + return Response( + {"detail": "source_id и target_id должны быть числами"}, status=400 + ) + if sid == tid: + return Response( + {"detail": "source_id и target_id не должны совпадать"}, status=400 + ) + + try: + source = Skill.objects.get(pk=sid) + target = Skill.objects.get(pk=tid) + except Skill.DoesNotExist: + return Response({"detail": "Skill не найден"}, status=404) + + moved_count = 0 + if reassign: + # переназначить всем сотрудникам: source -> target + qs = Employee.objects.filter(skills=source).only("id").distinct() + for emp in qs: + emp.skills.add(target) + emp.skills.remove(source) + moved_count += 1 + + # удалить исходный навык при необходимости + if delete_source: + source.delete() + + return Response( + { + "ok": True, + "source_id": sid, + "target_id": tid, + "reassigned_employees": moved_count, + "source_deleted": bool(delete_source), + }, + status=200, + ) + + +class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): + """ + /api/v1/employee-actions/ + - GET list/retrieve — IsAuthenticated + - POST create — staff/superuser ИЛИ perm employees.add_employeeaction + - PATCH/PUT update — staff/superuser ИЛИ perm employees.change_employeeaction + - DELETE destroy — staff/superuser ИЛИ perm employees.delete_employeeaction + + Фильтры: + ?employee= — по сотруднику + ?date_from=ISO — >= + ?date_to=ISO — <= + """ + + serializer_class = EmployeeActionSerializer + permission_classes = [IsAuthenticated] + filter_backends = [filters.OrderingFilter] + ordering_fields = ["date", "id"] + ordering = ["-date"] + pagination_class = None + history_diff_fields = ["action", "date", "comment", "extra", "employee_id"] + + def get_queryset(self): + qs = EmployeeAction.objects.select_related("employee").order_by(*self.ordering) + qp = self.request.query_params + emp = qp.get("employee") + if emp: + try: + emp_id = int(emp) + qs = qs.filter(employee_id=emp_id) + except (TypeError, ValueError): + return EmployeeAction.objects.none() + df = qp.get("date_from") + dt = qp.get("date_to") + if df: + try: + qs = qs.filter(date__gte=df) + except Exception: + pass + if dt: + try: + qs = qs.filter(date__lte=dt) + except Exception: + pass + return qs + + # --- права на запись через модельные пермишены --- + def get_permissions(self): + if self.action == "create": + self.required_perm_code = "employees.add_employeeaction" + return [IsAuthenticated(), IsStaffOrHasModelPerm()] + if self.action in ("update", "partial_update"): + self.required_perm_code = "employees.change_employeeaction" + return [IsAuthenticated(), IsStaffOrHasModelPerm()] + if self.action == "destroy": + self.required_perm_code = "employees.delete_employeeaction" + return [IsAuthenticated(), IsStaffOrHasModelPerm()] + self.required_perm_code = None + return super().get_permissions() + + # --- бизнес-эффекты после записи события --- + def _apply_effects(self, action_obj: EmployeeAction): + emp = action_obj.employee + if action_obj.action == ACTION_DISMISSED: + # деактивируем сотрудника и связи + EmployeeDepartment.objects.filter(employee=emp, is_active=True).update( + is_active=False, date_to=timezone.now().date() + ) + if emp.is_active: + emp.is_active = False + emp.save(update_fields=["is_active"]) + else: + # любое иное событие делает сотрудника активным + if not emp.is_active: + emp.is_active = True + emp.save(update_fields=["is_active"]) + + @transaction.atomic + def perform_create(self, serializer): + obj = serializer.save() + self._apply_effects(obj) + + @transaction.atomic + def perform_update(self, serializer): + obj = serializer.save() + self._apply_effects(obj) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 77382309..e52dd4f7 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -5,7 +5,7 @@ from .calendar.viewsets import CompanyEventsViewSet, DepartmentEventsViewSet from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet, EmployeeViewSet, PositionViewSet, RegisterAPIView, ResendEmailAPIView, - VerifyEmailAPIView) + VerifyEmailAPIView, SkillViewSet, EmployeeActionViewSet,) app_name = "api_v1" @@ -13,9 +13,10 @@ router = DefaultRouter() router.register(r"calendar/company-events", CompanyEventsViewSet, basename="company-events") router.register(r"departments", DepartmentViewSet, basename="departments") router.register(r"employees", EmployeeViewSet, basename="employees") +router.register(r"employee-actions", EmployeeActionViewSet, basename="employee-actions") router.register(r"positions", PositionViewSet, basename="positions") router.register(r"department-roles", DepartmentRoleViewSet, basename="department-roles") - +router.register(r"skills", SkillViewSet, basename="skills") urlpatterns = [ path("auth/register/", RegisterAPIView.as_view(), name="register"), diff --git a/backend/employees/models.py b/backend/employees/models.py index 1a2a7dc7..6e677ee8 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -73,7 +73,7 @@ class EmployeeManager(BaseUserManager): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_active", True) - extra_fields.setdefault("email_verified", True) # суперюзер уже подтверждён + extra_fields.setdefault("email_verified", True) if extra_fields.get("is_staff") is not True: raise ValueError("Суперпользователь должен иметь is_staff=True") diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 0cbb6091..4b81b55e 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -13,7 +13,6 @@ DEBUG = os.getenv("DEBUG", "true").lower() == "true" ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") - INSTALLED_APPS = [ "daphne", "channels", @@ -200,14 +199,18 @@ CSRF_TRUSTED_ORIGINS = os.getenv( "CSRF_TRUSTED_ORIGINS", default="https://*.sytes.net" ).split(",") -EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") +EMAIL_BACKEND = os.getenv( + "EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend" +) EMAIL_HOST = os.getenv("EMAIL_HOST", "") EMAIL_PORT = int(os.getenv("EMAIL_PORT", "465")) EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "false").lower() == "true" EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "true").lower() == "true" -DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER or "webmaster@localhost") +DEFAULT_FROM_EMAIL = os.getenv( + "DEFAULT_FROM_EMAIL", EMAIL_HOST_USER or "webmaster@localhost" +) ASGI_APPLICATION = "eusrr_backend.asgi.application" AUTHENTICATION_BACKENDS = [ @@ -225,7 +228,9 @@ CHANNEL_LAYERS = { } REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework_simplejwt.authentication.JWTAuthentication"], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication" + ], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", diff --git a/backend/tests/api/v1/employees/test_employee_actions.py b/backend/tests/api/v1/employees/test_employee_actions.py new file mode 100644 index 00000000..117127c0 --- /dev/null +++ b/backend/tests/api/v1/employees/test_employee_actions.py @@ -0,0 +1,215 @@ +import pytest +from django.urls import reverse +from django.utils import timezone +from django.contrib.auth.models import Permission +from rest_framework import status + +from employees.models import Employee, Department, EmployeeDepartment, EmployeeAction +from employees.constants import ACTION_DISMISSED, ACTION_CHOICES + +# helpers +_seq = 1 + + +def _email(p="u"): + global _seq + _seq += 1 + return f"{p}{_seq}@example.com" + + +def _phone(): + global _seq + _seq += 1 + return f"+7999{_seq:07d}" + + +def _user(staff=False, superuser=False) -> Employee: + u = Employee.objects.create_user( + email=_email(), + password="pass", + phone_number=_phone(), + send_activation_email=False, + first_name="T", + last_name="U", + ) + u.is_staff = staff + u.is_superuser = superuser + u.email_verified = True + u.is_active = True + u.save(update_fields=["is_staff", "is_superuser", "email_verified", "is_active"]) + return u + + +def _grant(user: Employee, code: str): + app, codename = code.split(".", 1) + p = Permission.objects.get(content_type__app_label=app, codename=codename) + user.user_permissions.add(p) + user.save() + for a in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): + if hasattr(user, a): + try: + delattr(user, a) + except: + pass + return p + + +def _any_non_dismissed(): + for k, _ in ACTION_CHOICES: + if k != ACTION_DISMISSED: + return k + pytest.skip("No non-dismissed action in ACTION_CHOICES") + + +@pytest.mark.django_db +def test_list_requires_auth_and_filtering(api_client): + url = reverse("api:api_v1:employee-actions-list") + # unauth + assert api_client.get(url).status_code in (401, 403) + + actor = _user() + api_client.force_authenticate(user=actor) + emp = _user() + other = _user() + # фиксируем базовую линию: авто-события могли уже создаться сигналами + baseline_all = set(EmployeeAction.objects.values_list("id", flat=True)) + baseline_emp = set( + EmployeeAction.objects.filter(employee=emp).values_list("id", flat=True) + ) + + a1 = EmployeeAction.objects.create( + employee=emp, action=_any_non_dismissed(), date=timezone.now() + ) + a2 = EmployeeAction.objects.create( + employee=other, action=_any_non_dismissed(), date=timezone.now() + ) + + resp = api_client.get(url) + assert resp.status_code == 200 + got_all = {row["id"] for row in resp.data} + assert got_all == baseline_all | {a1.id, a2.id} + + resp = api_client.get(url, {"employee": emp.id}) + assert resp.status_code == 200 + ids = {row["id"] for row in resp.data} + assert ids == baseline_emp | {a1.id} + + +@pytest.mark.django_db +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") + payload = { + "employee": target.id, + "action": _any_non_dismissed(), + "date": timezone.now().isoformat(), + "comment": "note", + "extra": {"k": "v"}, + } + + # no perm -> 403 + resp = api_client.post(url, payload, format="json") + assert resp.status_code == 403 + + # grant add perm -> 201 + _grant(actor, "employees.add_employeeaction") + baseline = EmployeeAction.objects.filter(employee=target).count() + resp = api_client.post(url, payload, format="json") + assert resp.status_code == 201 + assert EmployeeAction.objects.filter(employee=target).count() == baseline + 1 + + # staff bypass -> 201 + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + baseline2 = EmployeeAction.objects.filter(employee=target).count() + resp = api_client.post(url, payload, format="json") + assert resp.status_code == 201 + assert EmployeeAction.objects.filter(employee=target).count() == baseline2 + 1 + + +@pytest.mark.django_db +def test_dismissal_deactivates_employee_and_links(api_client): + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + dept = Department.objects.create(name="D") + EmployeeDepartment.objects.create(employee=emp, department=dept, is_active=True) + + url = reverse("api:api_v1:employee-actions-list") + resp = api_client.post( + url, + { + "employee": emp.id, + "action": ACTION_DISMISSED, + "date": timezone.now().isoformat(), + }, + format="json", + ) + assert resp.status_code == 201 + + emp.refresh_from_db() + assert emp.is_active is False + link = EmployeeDepartment.objects.get(employee=emp, department=dept) + assert link.is_active is False and link.date_to is not None + + +@pytest.mark.django_db +def test_non_dismissal_activates_employee(api_client): + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + emp.is_active = False + emp.save(update_fields=["is_active"]) + + url = reverse("api:api_v1:employee-actions-list") + resp = api_client.post( + url, + { + "employee": emp.id, + "action": _any_non_dismissed(), + "date": timezone.now().isoformat(), + }, + format="json", + ) + assert resp.status_code == 201 + emp.refresh_from_db() + assert emp.is_active is True + + +@pytest.mark.django_db +def test_update_to_dismissed_applies_effects(api_client): + actor = _user() + api_client.force_authenticate(user=actor) + _grant(actor, "employees.add_employeeaction") + _grant(actor, "employees.change_employeeaction") + + emp = _user() + 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]) + resp = api_client.patch(url_det, {"action": ACTION_DISMISSED}, format="json") + assert resp.status_code == 200 + emp.refresh_from_db() + assert emp.is_active is False + + +@pytest.mark.django_db +def test_delete_requires_perm_or_staff(api_client): + actor = _user() + api_client.force_authenticate(user=actor) + emp = _user() + 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]) + + # no perm -> 403 + assert api_client.delete(url_det).status_code == 403 + + # grant -> 204 + _grant(actor, "employees.delete_employeeaction") + assert api_client.delete(url_det).status_code == 204 diff --git a/backend/tests/api/v1/employees/test_skills.py b/backend/tests/api/v1/employees/test_skills.py new file mode 100644 index 00000000..0e49781b --- /dev/null +++ b/backend/tests/api/v1/employees/test_skills.py @@ -0,0 +1,259 @@ +import pytest +from django.contrib.auth.models import Permission +from django.urls import reverse +from employees.models import Employee, Skill +from rest_framework import status + +# --- helpers --- + +_seq = 1 + + +def _uniq_email(prefix="u"): + global _seq + _seq += 1 + return f"{prefix}{_seq}@example.com" + + +def _uniq_phone(): + global _seq + _seq += 1 + return f"+7999{_seq:07d}" + + +def _mk_user(staff=False, superuser=False) -> Employee: + u = Employee.objects.create_user( + email=_uniq_email(), + password="pass", + phone_number=_uniq_phone(), + send_activation_email=False, + first_name="T", + last_name="U", + ) + u.is_staff = staff + u.is_superuser = superuser + u.email_verified = True + u.is_active = True + u.save(update_fields=["is_staff", "is_superuser", "email_verified", "is_active"]) + return u + + +def _grant_perm(user: Employee, perm_code: str): + app_label, codename = perm_code.split(".", 1) + p = Permission.objects.get(content_type__app_label=app_label, codename=codename) + user.user_permissions.add(p) + user.save() + for a in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): + if hasattr(user, a): + try: + delattr(user, a) + except: + pass + return p + + +# --- tests --- + + +@pytest.mark.django_db +def test_list_search_ordering_requires_auth(api_client): + url = reverse("api:api_v1:skills-list") + # unauth + resp = api_client.get(url) + assert resp.status_code in (401, 403) + + # auth + user = _mk_user() + api_client.force_authenticate(user=user) + Skill.objects.create(name="Excel") + Skill.objects.create(name="SQL") + + resp = api_client.get(url) + assert resp.status_code == 200 + assert isinstance(resp.data, list) + assert {s["name"] for s in resp.data} == {"Excel", "SQL"} + + # search + resp = api_client.get(url, {"search": "ex"}) + assert resp.status_code == 200 + assert len(resp.data) == 1 and resp.data[0]["name"] == "Excel" + + # ordering + resp = api_client.get(url, {"ordering": "-name"}) + assert resp.status_code == 200 + names = [r["name"] for r in resp.data] + assert names == sorted(names, reverse=True) + + +@pytest.mark.django_db +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") + resp = api_client.post(url, {"name": "Go"}, format="json") + assert resp.status_code == 201 + assert Skill.objects.filter(name="Go").exists() + + +@pytest.mark.django_db +def test_update_delete_permissions(api_client): + user = _mk_user() + api_client.force_authenticate(user=user) + s = Skill.objects.create(name="Docker") + + # update -> 403 + url_det = reverse("api:api_v1:skills-detail", args=[s.id]) + resp = api_client.patch(url_det, {"name": "Docker+"}, format="json") + assert resp.status_code == 403 + + # delete -> 403 + resp = api_client.delete(url_det) + assert resp.status_code == 403 + + # с правом change_skill -> PATCH разрешён + _grant_perm(user, "employees.change_skill") + resp = api_client.patch(url_det, {"name": "Docker+"}, format="json") + assert resp.status_code == 200 + s.refresh_from_db() + assert s.name == "Docker+" + + # с правом delete_skill -> DELETE разрешён + _grant_perm(user, "employees.delete_skill") + resp = api_client.delete(url_det) + assert resp.status_code == 204 + assert not Skill.objects.filter(id=s.id).exists() + + +@pytest.mark.django_db +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]) + assert ( + api_client.patch(url_det, {"name": "Kubernetes"}, format="json").status_code + == 200 + ) + assert api_client.delete(url_det).status_code == 204 + + +@pytest.mark.django_db +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") + + resp = api_client.post( + url, {"names": [" Python ", "python", "SQL", ""]}, format="json" + ) + assert resp.status_code == 201 + data = resp.data + assert data["created_count"] == 2 + created_names = {row["name"] for row in data["created"]} + assert created_names == {"Python", "SQL"} + + +@pytest.mark.django_db +def test_merge_requires_perm_and_reassigns(api_client): + user = _mk_user() + api_client.force_authenticate(user=user) + + src = Skill.objects.create(name="Js") + dst = Skill.objects.create(name="JavaScript") + + e1 = _mk_user() + e2 = _mk_user() + e1.skills.add(src) + e2.skills.add(src) + + url = reverse("api:api_v1:skills-merge") + + # без прав -> 403 + resp = api_client.post( + url, {"source_id": src.id, "target_id": dst.id}, format="json" + ) + assert resp.status_code == 403 + + _grant_perm(user, "employees.change_skill") + resp = api_client.post( + url, {"source_id": src.id, "target_id": dst.id}, format="json" + ) + assert resp.status_code == 200 + e1.refresh_from_db() + e2.refresh_from_db() + assert dst in e1.skills.all() and dst in e2.skills.all() + assert src not in e1.skills.all() and src not in e2.skills.all() + assert not Skill.objects.filter( + id=src.id + ).exists() # по умолчанию delete_source=True + + +@pytest.mark.django_db +def test_merge_same_id_and_invalid_ids(api_client): + user = _mk_user() + api_client.force_authenticate(user=user) + _grant_perm(user, "employees.change_skill") + s = Skill.objects.create(name="A") + + url = reverse("api: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 + + # not found + resp = api_client.post(url, {"source_id": 999999, "target_id": s.id}, format="json") + assert resp.status_code in (404,) + + +@pytest.mark.django_db +def test_merge_without_reassign_or_delete(api_client): + user = _mk_user() + api_client.force_authenticate(user=user) + _grant_perm(user, "employees.change_skill") + + src = Skill.objects.create(name="ML") + dst = Skill.objects.create(name="Machine Learning") + e = _mk_user() + e.skills.add(src) + + url = reverse("api:api_v1:skills-merge") + resp = api_client.post( + url, + { + "source_id": src.id, + "target_id": dst.id, + "reassign": False, + "delete_source": False, + }, + format="json", + ) + assert resp.status_code == 200 + + e.refresh_from_db() + # не переназначили + assert src in e.skills.all() + assert dst not in e.skills.all() + # источник не удалили + assert Skill.objects.filter(id=src.id).exists() + + +@pytest.mark.django_db +def test_merge_idempotent_if_employee_already_has_target(api_client): + user = _mk_user() + api_client.force_authenticate(user=user) + _grant_perm(user, "employees.change_skill") + + src = Skill.objects.create(name="Golang") + dst = Skill.objects.create(name="Go") + e = _mk_user() + e.skills.add(src, dst) + + url = reverse("api:api_v1:skills-merge") + resp = api_client.post( + url, {"source_id": src.id, "target_id": dst.id}, format="json" + ) + assert resp.status_code == 200 + + e.refresh_from_db() + assert dst in e.skills.all() + assert src not in e.skills.all()