1243 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend/api/v1/employees/views.py
from __future__ import annotations
from collections import defaultdict
from datetime import timedelta
import phonenumbers
from common.emails import send_templated_mail
from django.conf import settings
from django.contrib.auth.models import Group, Permission
from django.db import IntegrityError, transaction
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef,
Prefetch, Q, Subquery, Value, When)
from django.utils import timezone
from django.utils.crypto import get_random_string
from employees.constants import ACTION_DISMISSED
from employees.models import (Department, DepartmentRole, Employee,
EmployeeAction, EmployeeDepartment, Position,
Skill)
from phonenumbers import PhoneNumberFormat
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import (AllowAny, DjangoModelPermissions,
IsAdminUser, IsAuthenticated)
from rest_framework.response import Response
from rest_framework.views import APIView
from ..permissions import (ASSIGN_ROLE_PERM, MANAGE_PERM,
AdminOrActionOrModelPerms, IsDeptManagerForWrite,
IsSelfOrStaff)
from .serializers import (DepartmentRoleSerializer, DepartmentSerializer,
EmailSerializer, EmailVerifySerializer,
EmployeeActionSerializer, EmployeeListSerializer,
EmployeeSerializer, PositionSerializer,
RegisterSerializer, SkillSerializer)
def _to_bool(val: str | None) -> bool | None:
if val is None:
return None
s = str(val).strip().lower()
if s in {"1", "true", "yes", "да"}:
return True
if s in {"0", "false", "no", "нет"}:
return False
return None
def _normalize_phone(raw: str | None) -> str | None:
if not raw:
return None
if phonenumbers is None:
return str(raw).strip()
region = getattr(settings, "PHONE_DEFAULT_REGION", "RU")
try:
pn = phonenumbers.parse(str(raw), region)
if not phonenumbers.is_valid_number(pn):
return None
return phonenumbers.format_number(pn, PhoneNumberFormat.E164)
except Exception:
return None
def _detect_phone_field() -> str | None:
for n in ("phone", "phone_number", "mobile", "msisdn", "tel"):
if any(f.name == n for f in Employee._meta.fields):
return n
return 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(
Q(history_user__id__iexact=q_user)
| 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": "..."}"""
throttle_scope = "anon"
permission_classes = [AllowAny]
def post(self, request):
ser = EmailSerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"]
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response({"ok": False, "error": "user_not_found"}, status=404)
if user.email_verified:
return Response({"ok": False, "error": "already_verified"}, status=400)
user.email_activation_code = get_random_string(6, "0123456789")
user.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[user.email],
template_base="emails/registration_verify_code",
context={"code": user.email_activation_code, "user": user},
)
return Response({"ok": True}, status=200)
class VerifyEmailAPIView(APIView):
throttle_scope = "anon"
permission_classes = [AllowAny]
def post(self, request):
ser = EmailVerifySerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"]
code = ser.validated_data["code"].strip()
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response({"ok": False, "error": "user_not_found"}, status=404)
if not code:
return Response({"ok": False, "error": "empty_code"}, status=400)
created = getattr(user, "created_at", None) or getattr(
user, "date_joined", None
)
if created and not user.email_verified:
if timezone.now() - created > timedelta(minutes=5):
user.delete()
return Response({"ok": False, "error": "expired"}, status=400)
if user.verify_email(code):
return Response({"ok": True, "user_id": user.id}, status=200)
return Response({"ok": False, "error": "invalid_code"}, status=400)
class RegisterAPIView(APIView):
"""
POST /api/v1/auth/register/
Требует: first_name, last_name, phone_number, email, birth_date, password,
и хотя бы один из: telegram / whatsapp / wechat
"""
throttle_scope = "anon"
permission_classes = [AllowAny]
@transaction.atomic
def post(self, request):
if not (
request.data.get("telegram")
or request.data.get("whatsapp")
or request.data.get("wechat")
):
return Response(
{
"detail": "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
},
status=400,
)
ser = RegisterSerializer(data=request.data)
ser.is_valid(raise_exception=True)
v = ser.validated_data
email = v["email"].strip().lower()
password = v["password"]
phone_raw = v.get("phone_number") or request.data.get("phone")
phone_norm = _normalize_phone(phone_raw)
# Телефон обязателен и должен быть валиден (E.164)
if not phone_norm:
return Response({"ok": False, "error": "invalid_phone"}, status=400)
# 1) Дубликат телефона — жёстко 400
if Employee.objects.filter(phone_number=phone_norm).exists():
return Response({"ok": False, "error": "phone_taken"}, status=400)
# 2) Логика по email
user = Employee.objects.filter(email__iexact=email).first()
if user and user.email_verified:
return Response({"ok": False, "error": "email_taken"}, status=400)
if user and not user.email_verified:
# без регенерации кода/письма — просто мягкий ответ
return Response(
{"ok": True, "pending_verification": True, "user_id": user.id},
status=200,
)
# 3) Создание нового
extra = {
"first_name": v["first_name"],
"last_name": v["last_name"],
"birth_date": v["birth_date"],
"telegram": v.get("telegram", ""),
"whatsapp": v.get("whatsapp", ""),
"wechat": v.get("wechat", ""),
}
phone_for_create = phone_norm
try:
user = Employee.objects.create_user(
email=email,
password=password,
phone_number=phone_for_create,
**extra,
)
except IntegrityError as e:
msg = str(e).lower()
if "phone" in msg or "phone_number" in msg:
return Response({"ok": False, "error": "phone_taken"}, status=400)
if "email" in msg:
return Response({"ok": False, "error": "email_taken"}, status=400)
raise
# доп. поля
pos_id = v.get("position")
if pos_id:
Position.objects.filter(pk=pos_id).first() and setattr(
user, "position_id", pos_id
)
skills_ids = v.get("skills") or []
if skills_ids:
user.skills.set(Skill.objects.filter(pk__in=skills_ids))
user.save()
return Response(
{
"id": user.id,
"email": user.email,
"email_verified": user.email_verified,
"is_active": user.is_active,
},
status=status.HTTP_201_CREATED,
)
class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet):
"""
/api/departments/
- GET (list/detail) — только аутентифицированные пользователи
- POST/DELETE — только staff/superuser или пользователи с правами
- PATCH/PUT — руководитель отдела ИЛИ роль с perm "manage_department"
- Смена head — руководитель отдела ИЛИ роль с perm "change_department_head"
- Управление ролями — руководитель отдела ИЛИ роль с perm "assign_department_role"
"""
serializer_class = DepartmentSerializer
permission_classes = [IsAuthenticated]
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 среди связей отдела
head_in_links_qs = EmployeeDepartment.objects.filter(
department=OuterRef("pk"),
employee_id=OuterRef("head_id"),
is_active=True,
)
return (
Department.objects.select_related("head")
.annotate(
active_links_count=Count(
"employeedepartment",
filter=Q(employeedepartment__is_active=True),
distinct=True,
),
# +1, если head задан и не присутствует среди активных связей
head_extra=Case(
When(
Q(head__isnull=False) & ~Exists(head_in_links_qs),
then=Value(1),
),
default=Value(0),
output_field=IntegerField(),
),
)
.annotate(employees_count=F("active_links_count") + F("head_extra"))
)
def get_permissions(self):
# Создание/удаление — только админы
if self.action in ["create", "destroy"]:
return [AdminOrActionOrModelPerms()]
# Общие правки отдела — право manage_department на объекте
if self.action in ["update", "partial_update"]:
self.required_perm_code = "manage_department"
return [IsAuthenticated(), IsDeptManagerForWrite()]
return super().get_permissions()
def perform_create(self, serializer):
# head_appointed_at у тебя auto_now — обновится сам
serializer.save()
def _check_head_change_perm(self, request, dept: Department) -> Response | None:
if "head_id" not in request.data:
return None
raw = request.data.get("head_id", None)
if raw is None:
new_head_id = None
else:
try:
new_head_id = int(raw)
except (TypeError, ValueError):
new_head_id = None
if new_head_id != dept.head_id:
self.required_perm_code = "change_department_head"
perm = IsDeptManagerForWrite()
if not perm.has_object_permission(request, self, dept):
return Response(
{"detail": "Недостаточно прав для смены руководителя отдела"},
status=status.HTTP_403_FORBIDDEN,
)
return None
def partial_update(self, request, *args, **kwargs):
dept = self.get_object()
resp = self._check_head_change_perm(request, dept)
if resp is not None:
return resp
return super().partial_update(request, *args, **kwargs)
def update(self, request, *args, **kwargs):
dept = self.get_object()
resp = self._check_head_change_perm(request, dept)
if resp is not None:
return resp
return super().update(request, *args, **kwargs)
@action(detail=True, methods=["post"])
def set_head(self, request, pk=None):
dept: Department = self.get_object()
self.required_perm_code = "change_department_head"
perm = IsDeptManagerForWrite()
if not perm.has_object_permission(request, self, dept):
return Response(
{"detail": "Недостаточно прав для смены руководителя отдела"},
status=status.HTTP_403_FORBIDDEN,
)
if "head_id" not in request.data:
return Response(
{
"detail": "Параметр 'head_id' обязателен (null — чтобы снять руководителя)."
},
status=400,
)
head_id = request.data.get("head_id", None)
# снять руководителя (явно передан null)
if head_id is None:
if dept.head_id is not None:
dept.head = None
dept.save(update_fields=["head"]) # auto_now обновит head_appointed_at
return Response(status=status.HTTP_204_NO_CONTENT)
try:
emp = Employee.objects.get(pk=head_id)
except Employee.DoesNotExist:
return Response({"detail": "Сотрудник не найден"}, status=404)
if not emp.is_actually_active:
return Response({"detail": "Сотрудник неактивен"}, status=400)
if dept.head_id != emp.id:
dept.head = emp
dept.save(update_fields=["head"])
return Response({"head_id": dept.head_id}, status=200)
@action(detail=True, methods=["post"])
def set_member_role(self, request, pk=None):
"""
Body:
{
"employee_id": 123,
"role_id": 7 | null, # null = снять роль (оставив сотрудника в отделе)
"is_active": true|false (необязательно)
}
"""
dept: Department = self.get_object()
self.required_perm_code = "assign_department_role"
perm = IsDeptManagerForWrite()
if not perm.has_object_permission(request, self, dept):
return Response(
{"detail": "Недостаточно прав для управления ролями отдела"},
status=status.HTTP_403_FORBIDDEN,
)
employee_id = request.data.get("employee_id")
role_id = request.data.get("role_id", None)
is_active = request.data.get("is_active", None)
if not employee_id:
return Response({"detail": "employee_id обязателен"}, status=400)
try:
emp = Employee.objects.get(pk=employee_id)
except Employee.DoesNotExist:
return Response({"detail": "Сотрудник не найден"}, status=404)
role = None
if role_id is not None:
try:
role = DepartmentRole.objects.get(pk=role_id)
except DepartmentRole.DoesNotExist:
return Response({"detail": "Роль не найдена"}, status=404)
if role.department_id != dept.id:
return Response(
{"detail": "Роль принадлежит другому отделу"}, status=400
)
with transaction.atomic():
link, _ = EmployeeDepartment.objects.select_for_update().get_or_create(
employee=emp, department=dept, defaults={"is_active": True}
)
link.role = role
if is_active is not None:
link.is_active = bool(is_active)
link.full_clean()
link.save()
return Response(
{
"employee_id": emp.id,
"role_id": role.id if role else None,
"is_active": link.is_active,
},
status=200,
)
class EmployeeViewSet(viewsets.ModelViewSet):
"""
/api/v1/employees/
Доступ:
- GET list/retrieve — только аутентифицированные пользователи.
- POST (create) — только staff/superuser (в методе create есть явная проверка).
- PATCH/PUT/DELETE {id} — staff/superuser ИЛИ пользователи с модельными правами
на Employee (через AdminOrActionOrModelPerms).
- GET/PATCH /employees/me/ — только аутентифицированные; PATCH правит профиль текущего пользователя.
- POST {id}/add_skill|remove_skill — требуется спец-пермишен
"employees.manage_employee_skills" ИЛИ staff/superuser
(через AdminOrActionOrModelPerms).
Поиск: last_name, first_name, patronymic, email, phone_number
Фильтры:
?department=<id> — участники отдела + head
?position=<id> — по должности
?skill=<id>&skill=<id> — любые из навыков (OR)
?email_verified=true|false
?active=true|false
?actually_active=true|false — реально активные: email_verified=True и не DISMISSED
(или нет действий, но is_active=True)
Сортировка:
?ordering=last_name|first_name|created_at|id (+ '-' для DESC)
Примечания:
- В detail/retrieve и в me мы добавляем в контекст сериализатора флаги
include_actions и include_action_history — в ответе придут кадровые
события сотрудника и их история.
- Создание /employees/ доступно ТОЛЬКО staff/superuser, даже если у пользователя
есть модельный пермишен add_employee (это отмечено явно в create()).
"""
serializer_class = EmployeeSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["last_name", "first_name", "patronymic", "email", "phone_number"]
ordering_fields = ["last_name", "first_name", "created_at", "id"]
ordering = ["last_name", "first_name"]
required_perms_by_action = {
"add_skill": "employees.manage_employee_skills",
"remove_skill": "employees.manage_employee_skills",
}
def get_permissions(self):
if self.action in {"add_skill", "remove_skill"}:
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
elif self.action in {"create", "update", "partial_update", "destroy"}:
if self.action in {"update", "partial_update"}:
return [
IsAuthenticated(),
(IsSelfOrStaff | AdminOrActionOrModelPerms)(),
]
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
else:
return [IsAuthenticated()]
def get_queryset(self):
last_action_code_sq = Subquery(
EmployeeAction.objects.filter(employee_id=OuterRef("pk"))
.order_by("-date")
.values("action")[:1]
)
dep_links_prefetch = Prefetch(
"departments_links",
queryset=EmployeeDepartment.objects.filter(is_active=True).select_related(
"department", "role"
),
to_attr="dept_links",
)
prefetches = [
"skills",
dep_links_prefetch,
Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")),
]
qs = (
Employee.objects.select_related("position")
.prefetch_related(*prefetches)
.annotate(last_action_code=last_action_code_sq)
.order_by(*self.ordering)
)
qp = self.request.query_params
# по отделу: связи + руководитель
dep = qp.get("department")
if dep:
try:
dep_id = int(dep)
except (TypeError, ValueError):
dep_id = None
if dep_id:
member_ids = EmployeeDepartment.objects.filter(
department_id=dep_id
).values("employee_id")
head_ids = Department.objects.filter(id=dep_id).values("head_id")
qs = qs.filter(Q(id__in=member_ids) | Q(id__in=head_ids))
# по должности
position = qp.get("position")
if position:
qs = qs.filter(position_id=position)
# по навыкам (any-of)
skill_ids = qp.getlist("skill")
if skill_ids:
qs = qs.filter(skills__in=skill_ids).distinct()
# статусы
email_verified = _to_bool(qp.get("email_verified"))
if email_verified is not None:
qs = qs.filter(email_verified=email_verified)
active = _to_bool(qp.get("active"))
if active is not None:
qs = qs.filter(is_active=active)
# реально активен
actually = _to_bool(qp.get("actually_active"))
if actually is True:
qs = qs.filter(
Q(email_verified=True)
& (
Q(last_action_code__isnull=True, is_active=True)
| ~Q(last_action_code=ACTION_DISMISSED)
)
)
elif actually is False:
qs = qs.exclude(
Q(email_verified=True)
& (
Q(last_action_code__isnull=True, is_active=True)
| ~Q(last_action_code=ACTION_DISMISSED)
)
)
return qs
def get_serializer_class(self):
# для списка отдаём облегчённый, но с нужными полями
if self.action == "list":
return EmployeeListSerializer
return EmployeeSerializer
def create(self, request, *args, **kwargs):
if not (request.user.is_staff or request.user.is_superuser):
return Response(
{"detail": "Only staff can create users."},
status=status.HTTP_403_FORBIDDEN,
)
ser = self.get_serializer(data=request.data)
ser.is_valid(raise_exception=True)
vd = dict(ser.validated_data)
# Бизнес-валидация контактов
if not (vd.get("whatsapp") or vd.get("telegram") or vd.get("wechat")):
return Response(
{
"detail": "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
},
status=status.HTTP_400_BAD_REQUEST,
)
# пароль
password = request.data.get("password")
if not password:
import secrets
password = secrets.token_urlsafe(12)
# вынести из vd, чтобы не передать дважды
email = vd.pop("email", None)
phone_number = vd.pop("phone_number", None)
if not email or not phone_number:
return Response(
{"detail": "email и phone_number обязательны для создания."},
status=status.HTTP_400_BAD_REQUEST,
)
skills = vd.pop("skills_ids", [])
# теперь в vd нет email/phone_number — дублирования не будет
user = Employee.objects.create_user(
email=email,
password=password,
phone_number=phone_number,
**vd, # avatar / names / position и т.п.
)
if skills:
user.skills.set(skills)
out = self.get_serializer(user)
headers = self.get_success_headers(out.data)
return Response(out.data, status=status.HTTP_201_CREATED, headers=headers)
@action(detail=False, methods=["get", "patch"])
def me(self, request):
"""
GET /api/v1/employees/me/ — ваш профиль (ТОТ ЖЕ формат, что retrieve)
PATCH /api/v1/employees/me/ — частичное обновление своего профиля
"""
instance: Employee = request.user # type: ignore
if request.method == "GET":
instance = (
Employee.objects.select_related("position")
.prefetch_related(
"skills",
Prefetch(
"departments_links",
queryset=EmployeeDepartment.objects.filter(is_active=True)
.select_related("department", "role"),
to_attr="dept_links",
),
Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")),
)
.get(pk=request.user.pk)
)
ctx = self.get_serializer_context()
ctx["include_actions"] = True
ctx["include_action_history"] = True
data = self.get_serializer(instance, context=ctx).data
return Response(data, status=status.HTTP_200_OK)
ser = self.get_serializer(instance, data=request.data, partial=True)
ser.is_valid(raise_exception=True)
vd = ser.validated_data
if all(k not in vd for k in ("whatsapp", "telegram", "wechat")):
new_whatsapp = vd.get("whatsapp", instance.whatsapp)
new_telegram = vd.get("telegram", instance.telegram)
new_wechat = vd.get("wechat", instance.wechat)
if not (new_whatsapp or new_telegram or new_wechat):
return Response(
{"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."},
status=status.HTTP_400_BAD_REQUEST,
)
ser.save()
return Response(ser.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def add_skill(self, request, pk=None):
"""
body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }
"""
emp = self.get_object()
sid = request.data.get("skill_id")
sname = (request.data.get("skill_name") or "").strip()
sk = None
if sid:
sk = Skill.objects.filter(pk=sid).first()
if not sk and sname:
sk = Skill.objects.filter(
name__iexact=sname
).first() or Skill.objects.create(name=sname)
if not sk:
return Response({"detail": "Навык не найден/не указан"}, status=400)
emp.skills.add(sk)
return Response(
{"ok": True, "skill": {"id": sk.id, "name": sk.name}}, status=200
)
@action(detail=True, methods=["post"])
def remove_skill(self, request, pk=None):
"""
body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }
"""
emp = self.get_object()
sid = request.data.get("skill_id")
sname = (request.data.get("skill_name") or "").strip()
sk = None
if sid:
sk = Skill.objects.filter(pk=sid).first()
if not sk and sname:
sk = Skill.objects.filter(name__iexact=sname).first()
if not sk:
return Response({"detail": "Навык не найден"}, status=404)
emp.skills.remove(sk)
return Response(
{"ok": True, "removed": {"id": sk.id, "name": sk.name}}, status=200
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
if getattr(self, "action", None) in {"retrieve", "me"}:
ctx["include_actions"] = True
ctx["include_action_history"] = True
return ctx
class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
"""
/api/v1/positions/
GET list/retrieve — аутентифицированные
POST/PUT/PATCH/DEL — staff/superuser ИЛИ пользователь с model perms
Экшены:
POST /{id}/set-groups
POST /{id}/add-groups
POST /{id}/remove-groups
GET /{id}/permissions
"""
queryset = Position.objects.all().prefetch_related("groups")
serializer_class = PositionSerializer
pagination_class = None
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name", "description"]
ordering_fields = ["name", "id"]
ordering = ["name"]
history_diff_fields = ["name", "description"]
permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms]
required_perms_by_action = {
"set_groups": "employees.assign_position_groups",
"add_groups": "employees.assign_position_groups",
"remove_groups": "employees.assign_position_groups",
}
def _validate_groups_payload(self, request):
ids = request.data.get("groups")
if not isinstance(ids, list):
return None, Response(
{"detail": "Поле 'groups' должно быть списком id"}, status=400
)
qs = Group.objects.filter(id__in=ids)
if qs.count() != len(set(ids)):
return None, Response({"detail": "Некоторые группы не найдены"}, status=400)
return qs, None
@action(detail=True, methods=["post"])
def set_groups(self, request, pk=None):
pos = self.get_object()
qs, err = self._validate_groups_payload(request)
if err:
return err
pos.groups.set(qs)
return Response(
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))},
status=200,
)
@action(detail=True, methods=["post"])
def add_groups(self, request, pk=None):
pos = self.get_object()
qs, err = self._validate_groups_payload(request)
if err:
return err
pos.groups.add(*qs)
return Response(
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))},
status=200,
)
@action(detail=True, methods=["post"])
def remove_groups(self, request, pk=None):
pos = self.get_object()
qs, err = self._validate_groups_payload(request)
if err:
return err
pos.groups.remove(*qs)
return Response(
{"ok": True, "group_ids": list(pos.groups.values_list("id", flat=True))},
status=200,
)
@action(detail=True, methods=["get"])
def permissions(self, request, pk=None):
pos = self.get_object()
perms = (
Permission.objects.filter(group__positions=pos)
.select_related("content_type")
.distinct()
)
data = [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
"app": p.content_type.app_label,
"model": p.content_type.model,
}
for p in perms
]
return Response({"count": len(data), "results": data}, status=200)
class DepartmentRoleViewSet(viewsets.ModelViewSet):
"""
/api/v1/positions/
GET list/retrieve — IsAuthenticated
POST/PUT/PATCH/DELETE — Admin OR Django model perms (add/change/delete_position)
POST /{id}/set-groups — Admin OR employees.assign_position_groups
POST /{id}/add-groups — Admin OR employees.assign_position_groups
POST /{id}/remove-groups — Admin OR employees.assign_position_groups
GET /{id}/permissions — IsAuthenticated
"""
serializer_class = DepartmentRoleSerializer
permission_classes = [IsAuthenticated, IsDeptManagerForWrite]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
ordering_fields = ["name", "id"]
ordering = ["name"]
pagination_class = None
def get_queryset(self):
qs = DepartmentRole.objects.select_related("department").prefetch_related(
"permissions__content_type"
)
dep = self.request.query_params.get("department")
if dep:
try:
dep_id = int(dep)
except (TypeError, ValueError):
# нечисловой параметр -> пустой список без ошибки
return DepartmentRole.objects.none()
qs = qs.filter(department_id=dep_id)
return qs
# --- аккуратно задаём требуемые права под экшены ---
def get_permissions(self):
# по умолчанию чтение — только аутентификация
if self.action in ("create", "update", "partial_update", "destroy"):
self.required_perm_code = MANAGE_PERM
elif self.action in ("set_perms", "add_perms", "remove_perms"):
self.required_perm_code = ASSIGN_ROLE_PERM
else:
self.required_perm_code = None
return super().get_permissions()
# create — объект ещё не существует, делаем явную проверку на отдел
def perform_create(self, serializer):
dept: Department = serializer.validated_data["department"]
checker = IsDeptManagerForWrite()
if not checker.has_object_permission(self.request, self, dept):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
"Недостаточно прав для управления ролями этого отдела"
)
serializer.save()
# update/destroy — has_object_permission отработает, т.к. мы добавили IsDeptManagerForWrite
# и он умеет извлекать department из объекта роли (см. патч выше)
# ---- экшены управления правами роли ----
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
)
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 perms, None
@action(detail=True, methods=["get"])
def perms(self, request, pk=None):
role: DepartmentRole = self.get_object()
data = [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
}
for p in role.permissions.select_related("content_type").all()
]
return Response({"count": len(data), "results": data})
@action(detail=True, methods=["post"])
def set_perms(self, request, pk=None):
role: DepartmentRole = self.get_object()
# required_perm_code уже выставлен -> IsDeptManagerForWrite проверит доступ по role.department
perms, err = self._validate_perm_ids(request)
if err:
return err
role.permissions.set(perms)
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):
role: DepartmentRole = self.get_object()
perms, err = self._validate_perm_ids(request)
if err:
return err
role.permissions.add(*perms)
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):
role: DepartmentRole = self.get_object()
perms, err = self._validate_perm_ids(request)
if err:
return err
role.permissions.remove(*perms)
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
required_perms_by_action = {
"merge": "employees.change_skill",
}
def get_permissions(self):
if self.action in ("create", "bulk_create"):
return [IsAuthenticated()]
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
@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(), AdminOrActionOrModelPerms()]
if self.action in ("update", "partial_update"):
self.required_perm_code = "employees.change_employeeaction"
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
if self.action == "destroy":
self.required_perm_code = "employees.delete_employeeaction"
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
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)