**feat(employees): Skill API + EmployeeAction API с бизнес-эффектами, роутинг и тесты**

### 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=<id>`, `?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).
This commit is contained in:
admin i 2025-08-26 09:00:01 +03:00
parent cfc76e43b7
commit d7f0090c76
7 changed files with 850 additions and 19 deletions

View File

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

View File

@ -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=<id|email> ?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=<id> — роли конкретного отдела
?search=<name> — поиск по названию
"""
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))})
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=<id> — по сотруднику
?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)

View File

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

View File

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

View File

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

View File

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

View File

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