**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:
parent
cfc76e43b7
commit
d7f0090c76
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
215
backend/tests/api/v1/employees/test_employee_actions.py
Normal file
215
backend/tests/api/v1/employees/test_employee_actions.py
Normal 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
|
||||
259
backend/tests/api/v1/employees/test_skills.py
Normal file
259
backend/tests/api/v1/employees/test_skills.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user