мелкие доработки тестов и шаблонов
This commit is contained in:
parent
c78d1e5fea
commit
da183c8fbd
@ -6,3 +6,5 @@ from .serializers import PhoneOrEmailTokenObtainPairSerializer
|
||||
|
||||
class PhoneOrEmailTokenObtainPairView(TokenObtainPairView):
|
||||
serializer_class = PhoneOrEmailTokenObtainPairSerializer
|
||||
|
||||
|
||||
|
||||
@ -4,11 +4,10 @@ from django.urls import include, path
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
|
||||
from .auth.views import PhoneOrEmailTokenObtainPairView
|
||||
|
||||
app_name = "api"
|
||||
|
||||
urlpatterns = [
|
||||
path("v1/", include(("api.v1.urls", "api_v1"), namespace="api_v1")),
|
||||
path("v1/", include(("api.v1.urls", "v1"), namespace="v1")),
|
||||
path(
|
||||
"auth/token/",
|
||||
PhoneOrEmailTokenObtainPairView.as_view(),
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
import base64
|
||||
import binascii
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.core.files.base import ContentFile
|
||||
from employees.models import (Department, DepartmentRole, EmployeeAction,
|
||||
EmployeeDepartment, Position, Skill)
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentRole,
|
||||
EmployeeAction,
|
||||
EmployeeDepartment,
|
||||
Position,
|
||||
Skill,
|
||||
)
|
||||
from PIL import Image
|
||||
from rest_framework import serializers
|
||||
|
||||
@ -148,7 +155,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer):
|
||||
"date_display",
|
||||
"comment",
|
||||
"extra",
|
||||
"history", # ← добавили
|
||||
"history", # ← добавили
|
||||
]
|
||||
|
||||
def get_action_display(self, obj):
|
||||
@ -162,8 +169,9 @@ class EmployeeActionSerializer(serializers.ModelSerializer):
|
||||
return []
|
||||
|
||||
items = list(
|
||||
obj.history.select_related("history_user")
|
||||
.order_by("-history_date", "-history_id")
|
||||
obj.history.select_related("history_user").order_by(
|
||||
"-history_date", "-history_id"
|
||||
)
|
||||
)
|
||||
|
||||
action_labels = dict(ACTION_CHOICES)
|
||||
@ -236,6 +244,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
departments = serializers.SerializerMethodField()
|
||||
auth = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Employee
|
||||
@ -264,6 +273,8 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
"last_login",
|
||||
"date_joined",
|
||||
"auth",
|
||||
|
||||
)
|
||||
read_only_fields = (
|
||||
"is_active",
|
||||
@ -272,6 +283,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
"last_login",
|
||||
"date_joined",
|
||||
"auth",
|
||||
)
|
||||
extra_kwargs = {
|
||||
# чтобы точно не принимали/не отдавали пароль и коды
|
||||
@ -310,21 +322,57 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
return out
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
Показываем actions только в деталке сотрудника и в /employees/me,
|
||||
либо если явно передан context['include_actions']=True.
|
||||
Во всех остальных контекстах поле скрываем, чтобы не раздувать ответы.
|
||||
"""
|
||||
fields = super().get_fields()
|
||||
if self.context.get("include_actions") is True:
|
||||
return fields
|
||||
|
||||
# actions — как и было
|
||||
if self.context.get("include_actions") is not True:
|
||||
view = self.context.get("view")
|
||||
action = getattr(view, "action", None) if view else None
|
||||
if action not in {"retrieve", "me"}:
|
||||
fields.pop("actions", None)
|
||||
|
||||
# ⬇️ Новое: auth показываем только в /me или при include_auth=True
|
||||
include_auth = self.context.get("include_auth") is True
|
||||
view = self.context.get("view")
|
||||
action = getattr(view, "action", None) if view else None
|
||||
if action not in {"retrieve", "me"}:
|
||||
fields.pop("actions", None)
|
||||
if not include_auth and action != "me":
|
||||
fields.pop("auth", None)
|
||||
|
||||
return fields
|
||||
|
||||
def get_auth(self, obj):
|
||||
"""
|
||||
Возвращает только ГЛОБАЛЬНЫЕ права текущего (request.user),
|
||||
уже с учётом должности (PositionRoleBackend).
|
||||
"""
|
||||
req = self.context.get("request")
|
||||
if not req or not getattr(req.user, "is_authenticated", False):
|
||||
return None
|
||||
|
||||
# Не светим auth другого сотрудника (кроме явного include_auth=True)
|
||||
include_auth = self.context.get("include_auth") is True
|
||||
if not include_auth and req.user.pk != getattr(obj, "pk", None):
|
||||
return None
|
||||
|
||||
u = req.user
|
||||
perms = sorted(u.get_all_permissions()) # уже включает Position-права
|
||||
|
||||
by_app = defaultdict(list)
|
||||
for p in perms:
|
||||
app, code = p.split(".", 1)
|
||||
by_app[app].append(code)
|
||||
perms_by_app = {app: sorted(codes) for app, codes in by_app.items()}
|
||||
|
||||
return {
|
||||
"id": u.id,
|
||||
"email": u.email or "",
|
||||
"is_staff": u.is_staff,
|
||||
"is_superuser": u.is_superuser,
|
||||
"groups": list(u.groups.values_list("name", flat=True)),
|
||||
"permissions": perms,
|
||||
"permissions_by_app": perms_by_app,
|
||||
}
|
||||
|
||||
|
||||
class DepartmentSerializer(serializers.ModelSerializer):
|
||||
head = EmployeeSerializer(read_only=True)
|
||||
|
||||
@ -1,64 +1,38 @@
|
||||
# backend/api/v1/employees/views.py
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from django.db.models import Prefetch
|
||||
|
||||
import phonenumbers
|
||||
from common.emails import send_templated_mail
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
IntegerField,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef,
|
||||
Prefetch, Q, Subquery, Value, When)
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from employees.constants import ACTION_DISMISSED
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentRole,
|
||||
Employee,
|
||||
EmployeeAction,
|
||||
EmployeeDepartment,
|
||||
Position,
|
||||
Skill,
|
||||
)
|
||||
from employees.models import (Department, DepartmentRole, Employee,
|
||||
EmployeeAction, EmployeeDepartment, Position,
|
||||
Skill)
|
||||
from phonenumbers import PhoneNumberFormat
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
|
||||
from rest_framework.permissions import (AllowAny, DjangoModelPermissions,
|
||||
IsAdminUser, IsAuthenticated)
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from ..permissions import (
|
||||
ASSIGN_ROLE_PERM,
|
||||
MANAGE_PERM,
|
||||
IsDeptManagerForWrite,
|
||||
IsSelfOrStaff,
|
||||
IsStaffOrHasModelPerm,
|
||||
)
|
||||
from .serializers import (
|
||||
DepartmentRoleSerializer,
|
||||
DepartmentSerializer,
|
||||
EmailSerializer,
|
||||
EmailVerifySerializer,
|
||||
EmployeeActionSerializer,
|
||||
EmployeeListSerializer,
|
||||
EmployeeSerializer,
|
||||
PositionSerializer,
|
||||
RegisterSerializer,
|
||||
SkillSerializer,
|
||||
)
|
||||
from ..permissions import (ASSIGN_ROLE_PERM, MANAGE_PERM,
|
||||
AdminOrActionOrModelPerms, IsDeptManagerForWrite,
|
||||
IsSelfOrStaff)
|
||||
from .serializers import (DepartmentRoleSerializer, DepartmentSerializer,
|
||||
EmailSerializer, EmailVerifySerializer,
|
||||
EmployeeActionSerializer, EmployeeListSerializer,
|
||||
EmployeeSerializer, PositionSerializer,
|
||||
RegisterSerializer, SkillSerializer)
|
||||
|
||||
|
||||
def _to_bool(val: str | None) -> bool | None:
|
||||
@ -328,7 +302,7 @@ class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
/api/departments/
|
||||
- GET (list/detail) — только аутентифицированные пользователи
|
||||
- POST/DELETE — только staff/superuser
|
||||
- POST/DELETE — только staff/superuser или пользователи с правами
|
||||
- PATCH/PUT — руководитель отдела ИЛИ роль с perm "manage_department"
|
||||
- Смена head — руководитель отдела ИЛИ роль с perm "change_department_head"
|
||||
- Управление ролями — руководитель отдела ИЛИ роль с perm "assign_department_role"
|
||||
@ -373,7 +347,7 @@ class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
def get_permissions(self):
|
||||
# Создание/удаление — только админы
|
||||
if self.action in ["create", "destroy"]:
|
||||
return [IsAdminUser()]
|
||||
return [AdminOrActionOrModelPerms()]
|
||||
# Общие правки отдела — право manage_department на объекте
|
||||
if self.action in ["update", "partial_update"]:
|
||||
self.required_perm_code = "manage_department"
|
||||
@ -526,28 +500,61 @@ class DepartmentViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
/api/v1/employees/
|
||||
- GET list/retrieve — только аутентифицированные
|
||||
- POST (create) — только staff/superuser (через EmployeeManager.create_user)
|
||||
- PATCH/PUT (update/partial) — staff/superuser ИЛИ владелец (self)
|
||||
- DELETE — только staff/superuser
|
||||
|
||||
Доступ:
|
||||
- GET list/retrieve — только аутентифицированные пользователи.
|
||||
- POST (create) — только staff/superuser (в методе create есть явная проверка).
|
||||
- PATCH/PUT/DELETE {id} — staff/superuser ИЛИ пользователи с модельными правами
|
||||
на Employee (через AdminOrActionOrModelPerms).
|
||||
- GET/PATCH /employees/me/ — только аутентифицированные; PATCH правит профиль текущего пользователя.
|
||||
- POST {id}/add_skill|remove_skill — требуется спец-пермишен
|
||||
"employees.manage_employee_skills" ИЛИ staff/superuser
|
||||
(через AdminOrActionOrModelPerms).
|
||||
|
||||
Поиск: last_name, first_name, patronymic, email, phone_number
|
||||
|
||||
Фильтры:
|
||||
?department=<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)
|
||||
?actually_active=true|false — реально активные: email_verified=True и не DISMISSED
|
||||
(или нет действий, но is_active=True)
|
||||
|
||||
Сортировка:
|
||||
?ordering=last_name|first_name|created_at|id (+ '-' для DESC)
|
||||
|
||||
Примечания:
|
||||
- В detail/retrieve и в me мы добавляем в контекст сериализатора флаги
|
||||
include_actions и include_action_history — в ответе придут кадровые
|
||||
события сотрудника и их история.
|
||||
- Создание /employees/ доступно ТОЛЬКО staff/superuser, даже если у пользователя
|
||||
есть модельный пермишен add_employee (это отмечено явно в create()).
|
||||
"""
|
||||
|
||||
serializer_class = EmployeeSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ["last_name", "first_name", "patronymic", "email", "phone_number"]
|
||||
ordering_fields = ["last_name", "first_name", "created_at", "id"]
|
||||
ordering = ["last_name", "first_name"]
|
||||
required_perms_by_action = {
|
||||
"add_skill": "employees.manage_employee_skills",
|
||||
"remove_skill": "employees.manage_employee_skills",
|
||||
}
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in {"add_skill", "remove_skill"}:
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
elif self.action in {"create", "update", "partial_update", "destroy"}:
|
||||
if self.action in {"update", "partial_update"}:
|
||||
return [
|
||||
IsAuthenticated(),
|
||||
(IsSelfOrStaff | AdminOrActionOrModelPerms)(),
|
||||
]
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
else:
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
last_action_code_sq = Subquery(
|
||||
@ -557,7 +564,9 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
dep_links_prefetch = Prefetch(
|
||||
"departments_links",
|
||||
queryset=EmployeeDepartment.objects.filter(is_active=True).select_related("department", "role"),
|
||||
queryset=EmployeeDepartment.objects.filter(is_active=True).select_related(
|
||||
"department", "role"
|
||||
),
|
||||
to_attr="dept_links",
|
||||
)
|
||||
|
||||
@ -630,18 +639,6 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return qs
|
||||
|
||||
# --- права ---
|
||||
def get_permissions(self):
|
||||
if self.action in {"create", "destroy"}:
|
||||
return [IsAdminUser()]
|
||||
if self.action in {"update", "partial_update"}:
|
||||
# class-level: аутентификация
|
||||
# object-level: IsSelfOrStaff
|
||||
return [IsAuthenticated(), IsSelfOrStaff()]
|
||||
if self.action in {"me"}:
|
||||
return [IsAuthenticated()]
|
||||
return super().get_permissions()
|
||||
|
||||
def get_serializer_class(self):
|
||||
# для списка отдаём облегчённый, но с нужными полями
|
||||
if self.action == "list":
|
||||
@ -700,22 +697,23 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
headers = self.get_success_headers(out.data)
|
||||
return Response(out.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
# --- профиль текущего пользователя ---
|
||||
@action(detail=False, methods=["get", "patch"])
|
||||
def me(self, request):
|
||||
"""
|
||||
GET /api/v1/employees/me/ — ваш профиль
|
||||
GET /api/v1/employees/me/ — ваш профиль (ТОТ ЖЕ формат, что retrieve)
|
||||
PATCH /api/v1/employees/me/ — частичное обновление своего профиля
|
||||
"""
|
||||
instance: Employee = request.user # type: ignore
|
||||
if request.method.lower() == "get":
|
||||
|
||||
if request.method == "GET":
|
||||
instance = (
|
||||
Employee.objects.select_related("position")
|
||||
.prefetch_related(
|
||||
"skills",
|
||||
Prefetch(
|
||||
"departments_links",
|
||||
queryset=EmployeeDepartment.objects.filter(is_active=True).select_related("department", "role"),
|
||||
queryset=EmployeeDepartment.objects.filter(is_active=True)
|
||||
.select_related("department", "role"),
|
||||
to_attr="dept_links",
|
||||
),
|
||||
Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")),
|
||||
@ -724,34 +722,77 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
ctx = self.get_serializer_context()
|
||||
ctx["include_actions"] = True
|
||||
ctx["include_action_history"] = True # <-- добавили
|
||||
return Response(self.get_serializer(instance, context=ctx).data)
|
||||
|
||||
|
||||
ctx["include_action_history"] = True
|
||||
data = self.get_serializer(instance, context=ctx).data
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
ser = self.get_serializer(instance, data=request.data, partial=True)
|
||||
ser.is_valid(raise_exception=True)
|
||||
# Можно повторить контакт-валидацию для PATCH:
|
||||
|
||||
vd = ser.validated_data
|
||||
if all(k not in vd for k in ("whatsapp", "telegram", "wechat")):
|
||||
# если пользователь пытается очистить все контакты
|
||||
new_whatsapp = vd.get("whatsapp", instance.whatsapp)
|
||||
new_telegram = vd.get("telegram", instance.telegram)
|
||||
new_wechat = vd.get("wechat", instance.wechat)
|
||||
if not (new_whatsapp or new_telegram or new_wechat):
|
||||
return Response(
|
||||
{
|
||||
"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."
|
||||
},
|
||||
{"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
ser.save()
|
||||
return Response(ser.data)
|
||||
|
||||
return Response(ser.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def add_skill(self, request, pk=None):
|
||||
"""
|
||||
body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }
|
||||
"""
|
||||
emp = self.get_object()
|
||||
sid = request.data.get("skill_id")
|
||||
sname = (request.data.get("skill_name") or "").strip()
|
||||
|
||||
sk = None
|
||||
if sid:
|
||||
sk = Skill.objects.filter(pk=sid).first()
|
||||
if not sk and sname:
|
||||
sk = Skill.objects.filter(
|
||||
name__iexact=sname
|
||||
).first() or Skill.objects.create(name=sname)
|
||||
if not sk:
|
||||
return Response({"detail": "Навык не найден/не указан"}, status=400)
|
||||
|
||||
emp.skills.add(sk)
|
||||
return Response(
|
||||
{"ok": True, "skill": {"id": sk.id, "name": sk.name}}, status=200
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def remove_skill(self, request, pk=None):
|
||||
"""
|
||||
body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }
|
||||
"""
|
||||
emp = self.get_object()
|
||||
sid = request.data.get("skill_id")
|
||||
sname = (request.data.get("skill_name") or "").strip()
|
||||
|
||||
sk = None
|
||||
if sid:
|
||||
sk = Skill.objects.filter(pk=sid).first()
|
||||
if not sk and sname:
|
||||
sk = Skill.objects.filter(name__iexact=sname).first()
|
||||
if not sk:
|
||||
return Response({"detail": "Навык не найден"}, status=404)
|
||||
|
||||
emp.skills.remove(sk)
|
||||
return Response(
|
||||
{"ok": True, "removed": {"id": sk.id, "name": sk.name}}, status=200
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
if getattr(self, "action", None) in {"retrieve", "me"}:
|
||||
ctx["include_actions"] = True
|
||||
ctx["include_action_history"] = True # <-- добавили
|
||||
ctx["include_action_history"] = True
|
||||
return ctx
|
||||
|
||||
|
||||
@ -769,27 +810,18 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
|
||||
queryset = Position.objects.all().prefetch_related("groups")
|
||||
serializer_class = PositionSerializer
|
||||
permission_classes = [IsAuthenticated, IsStaffOrHasModelPerm]
|
||||
pagination_class = None
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ["name", "description"]
|
||||
ordering_fields = ["name", "id"]
|
||||
ordering = ["name"]
|
||||
history_diff_fields = ["name", "description"]
|
||||
|
||||
def get_permissions(self):
|
||||
# важное место: выставляем требуемый perm под действие
|
||||
if self.action == "create":
|
||||
self.required_perm_code = "employees.add_position"
|
||||
elif self.action in ("update", "partial_update"):
|
||||
self.required_perm_code = "employees.change_position"
|
||||
elif self.action == "destroy":
|
||||
self.required_perm_code = "employees.delete_position"
|
||||
elif self.action in ("set_groups", "add_groups", "remove_groups"):
|
||||
self.required_perm_code = "employees.assign_position_groups"
|
||||
else:
|
||||
self.required_perm_code = None
|
||||
return super().get_permissions()
|
||||
permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms]
|
||||
required_perms_by_action = {
|
||||
"set_groups": "employees.assign_position_groups",
|
||||
"add_groups": "employees.assign_position_groups",
|
||||
"remove_groups": "employees.assign_position_groups",
|
||||
}
|
||||
|
||||
def _validate_groups_payload(self, request):
|
||||
ids = request.data.get("groups")
|
||||
@ -861,15 +893,13 @@ class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
|
||||
class DepartmentRoleViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
/api/v1/department-roles/
|
||||
- GET list/retrieve — IsAuthenticated
|
||||
- POST/PATCH/PUT/DELETE — руководитель отдела ИЛИ пользователь с нужной ролью отдела
|
||||
* create/update/delete — perm: manage_department
|
||||
* set-perms/add-perms/remove-perms — perm: assign_department_role
|
||||
|
||||
Фильтры:
|
||||
?department=<id> — роли конкретного отдела
|
||||
?search=<name> — поиск по названию
|
||||
/api/v1/positions/
|
||||
GET list/retrieve — IsAuthenticated
|
||||
POST/PUT/PATCH/DELETE — Admin OR Django model perms (add/change/delete_position)
|
||||
POST /{id}/set-groups — Admin OR employees.assign_position_groups
|
||||
POST /{id}/add-groups — Admin OR employees.assign_position_groups
|
||||
POST /{id}/remove-groups — Admin OR employees.assign_position_groups
|
||||
GET /{id}/permissions — IsAuthenticated
|
||||
"""
|
||||
|
||||
serializer_class = DepartmentRoleSerializer
|
||||
@ -1016,22 +1046,14 @@ class SkillViewSet(viewsets.ModelViewSet):
|
||||
ordering_fields = ["name", "id"]
|
||||
ordering = ["name"]
|
||||
pagination_class = None
|
||||
required_perms_by_action = {
|
||||
"merge": "employees.change_skill",
|
||||
}
|
||||
|
||||
def get_permissions(self):
|
||||
# create / bulk_create — любой аутентифицированный
|
||||
if self.action in ("create", "bulk_create"):
|
||||
return [IsAuthenticated()]
|
||||
|
||||
# merge / update / destroy — требуются модельные права
|
||||
if self.action in ("update", "partial_update"):
|
||||
self.required_perm_code = "employees.change_skill"
|
||||
elif self.action == "destroy":
|
||||
self.required_perm_code = "employees.delete_skill"
|
||||
elif self.action == "merge":
|
||||
self.required_perm_code = "employees.change_skill"
|
||||
else:
|
||||
self.required_perm_code = None # SAFE methods → IsAuthenticated
|
||||
return [IsAuthenticated(), IsStaffOrHasModelPerm()]
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def bulk_create(self, request):
|
||||
@ -1182,13 +1204,13 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
def get_permissions(self):
|
||||
if self.action == "create":
|
||||
self.required_perm_code = "employees.add_employeeaction"
|
||||
return [IsAuthenticated(), IsStaffOrHasModelPerm()]
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
if self.action in ("update", "partial_update"):
|
||||
self.required_perm_code = "employees.change_employeeaction"
|
||||
return [IsAuthenticated(), IsStaffOrHasModelPerm()]
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
if self.action == "destroy":
|
||||
self.required_perm_code = "employees.delete_employeeaction"
|
||||
return [IsAuthenticated(), IsStaffOrHasModelPerm()]
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
self.required_perm_code = None
|
||||
return super().get_permissions()
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
# backend\api\v1\permissions.py
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
from rest_framework.permissions import (
|
||||
SAFE_METHODS,
|
||||
BasePermission,
|
||||
DjangoModelPermissions,
|
||||
)
|
||||
|
||||
from employees.models import Department, EmployeeDepartment
|
||||
|
||||
@ -79,47 +83,32 @@ class IsDeptManagerForWrite(BasePermission):
|
||||
|
||||
|
||||
class IsSelfOrStaff(BasePermission):
|
||||
"""
|
||||
Доступ к объекту Employee:
|
||||
- staff/superuser — всегда
|
||||
- сам сотрудник к своему объекту
|
||||
"""
|
||||
|
||||
message = "Недостаточно прав."
|
||||
# На class-level пускаем любого аутентифицированного.
|
||||
def has_permission(self, request, view):
|
||||
return bool(request.user and request.user.is_authenticated)
|
||||
|
||||
# На object-level: сам объект, либо staff/superuser.
|
||||
def has_object_permission(self, request, view, obj):
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser or user.is_staff:
|
||||
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
|
||||
return True
|
||||
return getattr(obj, "id", None) == getattr(user, "id", None)
|
||||
|
||||
# Основной случай — объект сотрудника.
|
||||
try:
|
||||
from employees.models import (
|
||||
Employee,
|
||||
) # локальный импорт, чтобы избежать циклов
|
||||
except Exception:
|
||||
Employee = None
|
||||
|
||||
class IsStaffOrHasModelPerm(BasePermission):
|
||||
"""
|
||||
Read: любой аутентифицированный
|
||||
Write: staff ИЛИ пользователь с правом, указанным во view.required_perm_code
|
||||
"""
|
||||
if Employee is not None and isinstance(obj, Employee):
|
||||
return obj.pk == user.pk
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
if request.method in SAFE_METHODS:
|
||||
return bool(user and user.is_authenticated)
|
||||
if not (user and user.is_authenticated):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
code = getattr(view, "required_perm_code", None)
|
||||
# NOTE: в тестах права выдают уже после первого запроса; кэш прав у user остаётся.
|
||||
# Сбросим кэш, чтобы has_perm перечитал права из БД в рамках текущего запроса.
|
||||
for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
|
||||
if hasattr(user, attr):
|
||||
try:
|
||||
delattr(user, attr)
|
||||
except Exception:
|
||||
pass
|
||||
return bool(code and user.has_perm(code))
|
||||
# Fallback: сравнить id из URL, если obj не Employee (на всякий случай)
|
||||
lookup = getattr(view, "lookup_field", "pk")
|
||||
return str(view.kwargs.get(lookup)) == str(user.pk)
|
||||
|
||||
|
||||
class IsAuthorOrStaffForComments(BasePermission):
|
||||
@ -127,6 +116,7 @@ class IsAuthorOrStaffForComments(BasePermission):
|
||||
Создание/чтение — любой аутентифицированный (проверяет IsAuthenticated во view).
|
||||
Изменение/удаление — автор комментария или staff/superuser.
|
||||
"""
|
||||
|
||||
message = "Можно редактировать/удалять только свои комментарии."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
@ -145,14 +135,49 @@ class IsAuthorOrStaffForComments(BasePermission):
|
||||
return getattr(obj, "author_id", None) == getattr(user, "id", None)
|
||||
|
||||
|
||||
class AdminOrActionOrModelPerms(DjangoModelPermissions):
|
||||
"""
|
||||
Админ/суперпользователь → всегда True.
|
||||
Если задан код права (view.required_perm_code или required_perms_by_action[action]),
|
||||
проверяем его через user.has_perm(...). Иначе — стандартные DjangoModelPermissions.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser or user.is_staff:
|
||||
return True
|
||||
|
||||
mapping = getattr(view, "required_perms_by_action", {}) or {}
|
||||
action = getattr(view, "action", None)
|
||||
code = getattr(view, "required_perm_code", None) or mapping.get(action)
|
||||
if code:
|
||||
return user.has_perm(code)
|
||||
|
||||
return super().has_permission(request, view)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
user = request.user
|
||||
if user.is_superuser or user.is_staff:
|
||||
return True
|
||||
|
||||
mapping = getattr(view, "required_perms_by_action", {}) or {}
|
||||
action = getattr(view, "action", None)
|
||||
code = getattr(view, "required_perm_code", None) or mapping.get(action)
|
||||
if code:
|
||||
return user.has_perm(code)
|
||||
|
||||
return super().has_object_permission(request, view, obj)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MANAGE_PERM",
|
||||
"CHANGE_HEAD_PERM",
|
||||
"ASSIGN_ROLE_PERM",
|
||||
"IsDeptManagerForWrite",
|
||||
"IsSelfOrStaff",
|
||||
"IsStaffOrHasModelPerm",
|
||||
"IsAuthorOrStaffForComments",
|
||||
"AdminOrActionOrModelPerms",
|
||||
"user_is_dept_head",
|
||||
"user_has_dept_perm",
|
||||
]
|
||||
|
||||
@ -9,7 +9,7 @@ from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet,
|
||||
VerifyEmailAPIView)
|
||||
from .feed.views import CommentViewSet, PostViewSet
|
||||
|
||||
app_name = "api_v1"
|
||||
app_name = "v1"
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"calendar/company-events", CompanyEventsViewSet, basename="company-events")
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-28 11:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('calendar_app', '0002_departmentevent_companyevent_color_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='departmentevent',
|
||||
options={'ordering': ['department', 'start_date'], 'permissions': [('manage_department_events', 'Может создавать/обновлять/ удалять события отдела')], 'verbose_name': 'Событие отдела', 'verbose_name_plural': 'События отделов'},
|
||||
),
|
||||
]
|
||||
@ -1,8 +1,9 @@
|
||||
# backend\communications\routing.py
|
||||
from django.urls import path
|
||||
from django.urls import re_path
|
||||
from .consumers import ChatConsumer, ChatListConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/chat/<int:chat_id>/", ChatConsumer.as_asgi(), name="ws_chat"),
|
||||
re_path(r"^ws/chats/$", ChatListConsumer.as_asgi()),
|
||||
re_path(r"^ws/chat/(?P<chat_id>\d+)/$", ChatConsumer.as_asgi(), name="ws_chat"),
|
||||
re_path(r"^ws/chats/$", ChatListConsumer.as_asgi(), name="ws_chat_list"),
|
||||
]
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-28 11:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0019_delete_education'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='department',
|
||||
options={'permissions': [('change_department_head', 'Может изменять руководителя отдела'), ('assign_department_role', 'Может назначать роли в рамках отдела'), ('manage_department', 'Может управлять отделом')], 'verbose_name': 'Отдел', 'verbose_name_plural': 'Отделы'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='employee',
|
||||
options={'ordering': ('last_name', 'first_name'), 'permissions': [('assign_employee_skills', 'Может назначать навыки сотрудникам')], 'verbose_name': 'Сотрудник', 'verbose_name_plural': 'Сотрудники'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='position',
|
||||
options={'ordering': ['name'], 'permissions': [('assign_position_groups', 'Может назначать группы аутентификации для должностей')], 'verbose_name': 'Должность', 'verbose_name_plural': 'Должности'},
|
||||
),
|
||||
]
|
||||
17
backend/employees/migrations/0021_alter_employee_options.py
Normal file
17
backend/employees/migrations/0021_alter_employee_options.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-28 12:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0020_alter_department_options_alter_employee_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='employee',
|
||||
options={'ordering': ('last_name', 'first_name'), 'permissions': [('manage_employee_skills', 'Может управлять навыками сотрудников')], 'verbose_name': 'Сотрудник', 'verbose_name_plural': 'Сотрудники'},
|
||||
),
|
||||
]
|
||||
@ -1,6 +1,5 @@
|
||||
# backend\employees\models.py
|
||||
from django.contrib.auth.models import (AbstractUser, BaseUserManager, Group,
|
||||
Permission)
|
||||
from django.contrib.auth.models import AbstractUser, BaseUserManager, Group, Permission
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models.functions import Lower
|
||||
@ -39,25 +38,37 @@ class EmployeeManager(BaseUserManager):
|
||||
|
||||
# Явные проверки для дружелюбной ошибки + защита от гонок ниже
|
||||
if self.model.objects.filter(email__iexact=email).exists():
|
||||
raise ValidationError({"email": "Пользователь с таким email уже существует"})
|
||||
raise ValidationError(
|
||||
{"email": "Пользователь с таким email уже существует"}
|
||||
)
|
||||
if self.model.objects.filter(phone_number=phone_number).exists():
|
||||
raise ValidationError({"phone_number": "Пользователь с таким номером уже существует"})
|
||||
raise ValidationError(
|
||||
{"phone_number": "Пользователь с таким номером уже существует"}
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
user = self.model(email=email, phone_number=phone_number, **extra_fields)
|
||||
user = self.model(
|
||||
email=email, phone_number=phone_number, **extra_fields
|
||||
)
|
||||
user.set_password(password)
|
||||
# для обычного пользователя подтверждение по email
|
||||
user.email_verified = False
|
||||
user.email_activation_code = get_random_string(6, allowed_chars="0123456789")
|
||||
user.email_activation_code = get_random_string(
|
||||
6, allowed_chars="0123456789"
|
||||
)
|
||||
user.save(using=self._db)
|
||||
except IntegrityError as e:
|
||||
# fallback — на случай гонки: маппим в поле-специфичную ошибку
|
||||
msg = str(e).lower()
|
||||
if "email" in msg or "uniq" in msg and "email" in msg:
|
||||
raise ValidationError({"email": "Пользователь с таким email уже существует"})
|
||||
raise ValidationError(
|
||||
{"email": "Пользователь с таким email уже существует"}
|
||||
)
|
||||
if "phone" in msg or "phone_number" in msg:
|
||||
raise ValidationError({"phone_number": "Пользователь с таким номером уже существует"})
|
||||
raise ValidationError(
|
||||
{"phone_number": "Пользователь с таким номером уже существует"}
|
||||
)
|
||||
raise
|
||||
|
||||
if send_activation_email:
|
||||
@ -128,7 +139,10 @@ class Position(models.Model):
|
||||
verbose_name_plural = "Должности"
|
||||
ordering = ["name"]
|
||||
permissions = [
|
||||
("assign_position_groups", "Может назначать группы аутентификации для должностей"),
|
||||
(
|
||||
"assign_position_groups",
|
||||
"Может назначать группы аутентификации для должностей",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -182,6 +196,9 @@ class Employee(AbstractUser):
|
||||
models.Index(fields=["phone_number"]),
|
||||
models.Index(Lower("email"), name="employee_email_lower_idx"),
|
||||
]
|
||||
permissions = [
|
||||
("manage_employee_skills", "Может управлять навыками сотрудников"),
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@ -236,9 +253,7 @@ class EmployeeAction(models.Model):
|
||||
employee = models.ForeignKey(
|
||||
Employee, related_name="actions", on_delete=models.CASCADE
|
||||
)
|
||||
action = models.CharField(
|
||||
"Кадровое событие", max_length=50, choices=ACTION_CHOICES
|
||||
) # Увеличено для расширения
|
||||
action = models.CharField("Кадровое событие", max_length=50, choices=ACTION_CHOICES)
|
||||
date = models.DateTimeField("Дата действия")
|
||||
comment = models.TextField("Комментарий/причина", blank=True)
|
||||
extra = models.JSONField("Дополнительно", blank=True, null=True)
|
||||
@ -398,9 +413,7 @@ class DepartmentRole(models.Model):
|
||||
Department, on_delete=models.CASCADE, related_name="roles"
|
||||
)
|
||||
name = models.CharField(max_length=150)
|
||||
permissions = models.ManyToManyField(
|
||||
Permission, blank=True
|
||||
) # права в рамках ЭТОГО отдела
|
||||
permissions = models.ManyToManyField(Permission, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Роль в отделе"
|
||||
|
||||
@ -20,9 +20,6 @@ urlpatterns = [
|
||||
views_front.DepartmentCreateView.as_view(),
|
||||
name="department_add",
|
||||
),
|
||||
# path("departments/<int:pk>/edit/", views_front.DepartmentUpdateView.as_view(), name="department_edit"),
|
||||
# path("departments/<int:pk>/delete/", views_front.DepartmentDeleteView.as_view(), name="department_delete"),
|
||||
# path("departments/<int:pk>/invite/", views_front.invite_to_department, name="invite_to_department"),
|
||||
path("departments/<int:dept_id>/members/<int:employee_id>/role/", views_front.edit_department_role, name="edit_department_role",),
|
||||
path("departments/", views_front.department_list, name="department_list"),
|
||||
path("departments/<int:pk>/", views_front.department_detail, name="department_detail"),
|
||||
@ -31,17 +28,9 @@ urlpatterns = [
|
||||
path("departments/<int:pk>/set-member-role/", views_front.department_set_member_role, name="department_set_member_role",),
|
||||
|
||||
# --- Навыки ---
|
||||
path("skills/", views_front.SkillListView.as_view(), name="skill_list"),
|
||||
path("skills/add/", views_front.SkillCreateView.as_view(), name="skill_add"),
|
||||
path(
|
||||
"skills/<int:pk>/edit/",
|
||||
views_front.SkillUpdateView.as_view(),
|
||||
name="skill_edit",
|
||||
),
|
||||
path(
|
||||
"skills/<int:pk>/delete/",
|
||||
views_front.SkillDeleteView.as_view(),
|
||||
name="skill_delete",
|
||||
),
|
||||
path("<int:pk>/skill/add/", views_front.skill_add, name="skill_add"),
|
||||
path("<int:pk>/skill/remove/", views_front.skill_remove, name="skill_remove"),
|
||||
|
||||
|
||||
|
||||
]
|
||||
|
||||
@ -14,13 +14,18 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.http import urlencode
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.http import JsonResponse
|
||||
|
||||
from .forms import (DepartmentEditForm, DepartmentForm,
|
||||
DepartmentMemberRoleForm,
|
||||
InviteToDepartmentForm, SkillForm)
|
||||
from .forms import (
|
||||
DepartmentEditForm,
|
||||
DepartmentForm,
|
||||
DepartmentMemberRoleForm,
|
||||
InviteToDepartmentForm,
|
||||
SkillForm,
|
||||
)
|
||||
from .forms_front import DepartmentEditForm, SetHeadForm, SetMemberRoleForm
|
||||
from .models import Department, EmployeeDepartment, Skill
|
||||
|
||||
@ -79,6 +84,24 @@ def _cleanup_payload(data: dict, keep_empty: set[str] = frozenset()) -> dict:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _extract_results(payload):
|
||||
if isinstance(payload, dict):
|
||||
return payload.get("results", payload.get("items", [])) or []
|
||||
return payload or []
|
||||
|
||||
|
||||
def _fetch_employee_posts(api, employee_id: int):
|
||||
# сначала пробуем ?author=, если в API другое имя фильтра — fallback на ?employee=
|
||||
for url in (
|
||||
f"v1/posts/?author={employee_id}&ordering=-created_at",
|
||||
f"v1/posts/?employee={employee_id}&ordering=-created_at",
|
||||
):
|
||||
r = api.get(url)
|
||||
if r.ok:
|
||||
data = r.json or []
|
||||
return _extract_results(data)
|
||||
|
||||
|
||||
# =========================
|
||||
# Сотрудники
|
||||
# =========================
|
||||
@ -139,32 +162,50 @@ def employee_detail(request, pk: int):
|
||||
return redirect("employees:employees_list")
|
||||
|
||||
emp = resp.json or {}
|
||||
can_edit = bool(request.user and (request.user.is_staff or request.user.id == emp.get("id")))
|
||||
print("emp:", emp)
|
||||
return render(request, "employees/employee_detail.html", {
|
||||
"emp": emp,
|
||||
"can_edit": can_edit,
|
||||
# "employee_actions": employee_actions, # ← удалить
|
||||
})
|
||||
can_edit = bool(
|
||||
request.user and (request.user.is_staff or request.user.id == emp.get("id"))
|
||||
)
|
||||
posts = _fetch_employee_posts(api, pk)
|
||||
sresp = api.get("v1/skills/", params={"ordering": "name"})
|
||||
all_skills = sresp.json if sresp.ok else []
|
||||
return render(
|
||||
request,
|
||||
"employees/employee_detail.html",
|
||||
{"emp": emp, "can_edit": can_edit, "all_skills": all_skills, "posts": posts},
|
||||
)
|
||||
|
||||
|
||||
# ---------- ME ----------
|
||||
@require_api_auth
|
||||
def employee_me(request):
|
||||
api = get_api_client(request)
|
||||
|
||||
resp = api.get("v1/employees/me/")
|
||||
if not resp.ok:
|
||||
messages.error(request, f"Ошибка API ({resp.status})")
|
||||
return redirect("employees:employees_list")
|
||||
|
||||
emp = resp.json or {}
|
||||
print("emp", emp)
|
||||
return render(request, "employees/employee_detail.html", {
|
||||
"emp": emp,
|
||||
"can_edit": True,
|
||||
# "employee_actions": employee_actions, # ← удалить
|
||||
})
|
||||
|
||||
# новости сотрудника
|
||||
posts = _fetch_employee_posts(api, emp.get("id"))
|
||||
|
||||
# все навыки для даталиста (разворачиваем results)
|
||||
sresp = api.get("v1/skills/", params={"ordering": "name"})
|
||||
data = sresp.json if sresp.ok else []
|
||||
all_skills = data.get("results", data) if isinstance(data, dict) else data
|
||||
|
||||
return render(
|
||||
request,
|
||||
"employees/employee_detail.html",
|
||||
{
|
||||
"emp": emp,
|
||||
"can_edit": True,
|
||||
"is_me": True,
|
||||
"all_skills": all_skills,
|
||||
"posts": posts,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------- EDIT (self or staff) ----------
|
||||
@ -623,9 +664,9 @@ def edit_department_role(request, dept_id, employee_id):
|
||||
)
|
||||
|
||||
|
||||
# =========================
|
||||
# Приглашение в отдел
|
||||
# =========================
|
||||
# ---------- Приглашение в отдел ----------
|
||||
|
||||
|
||||
@login_required
|
||||
def invite_to_department(request, pk):
|
||||
department = get_object_or_404(Department, pk=pk)
|
||||
@ -668,57 +709,34 @@ def invite_to_department(request, pk):
|
||||
|
||||
|
||||
# =========================
|
||||
# Универсальный CRUD для Absence, Skill
|
||||
# Навыки
|
||||
# =========================
|
||||
class BaseCRUDListView(LoginRequiredMixin, ListView):
|
||||
"""Базовый ListView: если у модели есть FK employee — показываем только свои записи."""
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
has_employee_fk = any(f.name == "employee" for f in self.model._meta.fields)
|
||||
if has_employee_fk:
|
||||
return qs.filter(employee=self.request.user)
|
||||
return qs
|
||||
|
||||
|
||||
class BaseCRUDCreateView(LoginRequiredMixin, CreateView):
|
||||
def form_valid(self, form):
|
||||
has_employee_fk = any(f.name == "employee" for f in self.model._meta.fields)
|
||||
if has_employee_fk:
|
||||
form.instance.employee = self.request.user
|
||||
return super().form_valid(form)
|
||||
@login_required
|
||||
@require_POST
|
||||
@require_api_auth
|
||||
def skill_add(request, pk: int):
|
||||
api = get_api_client(request)
|
||||
skill_id = request.POST.get("skill_id")
|
||||
skill_name = (request.POST.get("skill_name") or "").strip()
|
||||
payload = {
|
||||
k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v
|
||||
}
|
||||
resp = api.post(f"v1/employees/{pk}/add_skill/", data=payload)
|
||||
return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500)
|
||||
|
||||
|
||||
class BaseCRUDUpdateView(LoginRequiredMixin, UpdateView):
|
||||
pass
|
||||
|
||||
|
||||
class BaseCRUDDeleteView(LoginRequiredMixin, DeleteView):
|
||||
pass
|
||||
|
||||
|
||||
# Skill CRUD (через базовые вьюхи, чтобы ограничить видимость своими записями)
|
||||
class SkillListView(BaseCRUDListView):
|
||||
model = Skill
|
||||
template_name = "employees/skill_list.html"
|
||||
context_object_name = "skills"
|
||||
|
||||
|
||||
class SkillCreateView(BaseCRUDCreateView):
|
||||
model = Skill
|
||||
form_class = SkillForm
|
||||
template_name = "employees/skill_form.html"
|
||||
success_url = reverse_lazy("employees:skill_list")
|
||||
|
||||
|
||||
class SkillUpdateView(BaseCRUDUpdateView):
|
||||
model = Skill
|
||||
form_class = SkillForm
|
||||
template_name = "employees/skill_form.html"
|
||||
success_url = reverse_lazy("employees:skill_list")
|
||||
|
||||
|
||||
class SkillDeleteView(BaseCRUDDeleteView):
|
||||
model = Skill
|
||||
template_name = "employees/skill_confirm_delete.html"
|
||||
success_url = reverse_lazy("employees:skill_list")
|
||||
@login_required
|
||||
@require_POST
|
||||
@require_api_auth
|
||||
def skill_remove(request, pk: int):
|
||||
api = get_api_client(request)
|
||||
skill_id = request.POST.get("skill_id")
|
||||
skill_name = (request.POST.get("skill_name") or "").strip()
|
||||
payload = {
|
||||
k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v
|
||||
}
|
||||
# NB: у DRF-экшена underscore: remove_skill
|
||||
resp = api.post(f"v1/employees/{pk}/remove_skill/", data=payload)
|
||||
return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500)
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import os
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from communications.routing import websocket_urlpatterns
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
|
||||
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
application = ProtocolTypeRouter(
|
||||
{
|
||||
|
||||
@ -4,9 +4,8 @@ from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from employees.models import Employee
|
||||
from django.contrib.auth.models import Permission
|
||||
from employees.models import Department, Employee, EmployeeDepartment
|
||||
|
||||
# phonenumbers — опционально, но очень желательно: pip install phonenumbers
|
||||
try:
|
||||
@ -100,68 +99,54 @@ class EmailOrPhoneBackend(ModelBackend):
|
||||
|
||||
|
||||
class PositionRoleBackend(ModelBackend):
|
||||
"""
|
||||
Добавляет:
|
||||
- Глобальные права от активных EmployeePosition → PositionRole.permissions
|
||||
- Объектные права на Department из DepartmentRole.permissions (через EmployeeDepartment.role_fk)
|
||||
"""
|
||||
def get_all_permissions(self, user_obj, obj=None):
|
||||
perms = super().get_all_permissions(user_obj, obj)
|
||||
if not getattr(user_obj, "is_authenticated", False) or obj is not None:
|
||||
return perms
|
||||
|
||||
def _active_positions(self, user):
|
||||
today = timezone.now().date()
|
||||
return (
|
||||
user.positions.select_related("position_role")
|
||||
.prefetch_related("position_role__permissions")
|
||||
.filter(date_from__lte=today)
|
||||
.filter(Q(date_to__isnull=True) | Q(date_to__gte=today))
|
||||
)
|
||||
# 1) Глобальные права от должности — оставить
|
||||
pos = getattr(user_obj, "position", None)
|
||||
if pos:
|
||||
pos_perms = (
|
||||
Permission.objects.filter(group__positions=pos)
|
||||
.select_related("content_type")
|
||||
.distinct()
|
||||
)
|
||||
perms |= {f"{p.content_type.app_label}.{p.codename}" for p in pos_perms}
|
||||
|
||||
# 2) ⚠️ НЕ добавляем сюда пермишены из DepartmentRole.
|
||||
# Они должны проверяться только на уровне конкретного Department в has_perm(..., obj=dept)
|
||||
|
||||
def get_user_permissions(self, user, obj=None):
|
||||
# только глобальные (obj=None): соберём из активных должностей
|
||||
if not user.is_authenticated or obj is not None:
|
||||
return set()
|
||||
perms = set()
|
||||
for ep in self._active_positions(user):
|
||||
pr = ep.position_role
|
||||
if not pr:
|
||||
continue
|
||||
for p in pr.permissions.all():
|
||||
perms.add(f"{p.content_type.app_label}.{p.codename}")
|
||||
return perms
|
||||
|
||||
def has_perm(self, user, perm, obj=None):
|
||||
# 1) Сначала стандартные механики Django (user.user_permissions, groups, superuser)
|
||||
if super().has_perm(user, perm, obj=obj):
|
||||
def has_perm(self, user_obj, perm, obj=None):
|
||||
# стандартная ветка + суперюзер/группы/пользовательские
|
||||
if super().has_perm(user_obj, perm, obj=obj):
|
||||
return True
|
||||
if not user.is_authenticated:
|
||||
if not getattr(user_obj, "is_authenticated", False):
|
||||
return False
|
||||
|
||||
# 2) Глобальные должности (obj=None)
|
||||
if obj is None:
|
||||
return perm in self.get_user_permissions(user)
|
||||
|
||||
# 3) Объектные права на Department — смотрим роль в ЭТОМ отделе
|
||||
from employees.models import Department, EmployeeDepartment
|
||||
# добавить глобальные (от должности)
|
||||
return perm in self.get_all_permissions(user_obj)
|
||||
|
||||
# Объектные права на Department — как у вас
|
||||
if isinstance(obj, Department):
|
||||
# начальнику отдела — всё
|
||||
if obj.head_id == user.id:
|
||||
if obj.head_id == user_obj.id:
|
||||
return True
|
||||
link = (
|
||||
EmployeeDepartment.objects.select_related("role_fk")
|
||||
.filter(employee=user, department=obj, is_active=True)
|
||||
EmployeeDepartment.objects.select_related("role")
|
||||
.filter(employee=user_obj, department=obj, is_active=True)
|
||||
.first()
|
||||
)
|
||||
if not link or not link.role_fk:
|
||||
if not (link and link.role_id):
|
||||
return False
|
||||
|
||||
try:
|
||||
app_label, codename = perm.split(".", 1)
|
||||
except ValueError:
|
||||
app_label, sep, codename = perm.partition(".")
|
||||
if not sep:
|
||||
return False
|
||||
|
||||
return link.role_fk.permissions.filter(
|
||||
content_type__app_label=app_label, codename=codename
|
||||
return link.role.permissions.filter(
|
||||
content_type__app_label=app_label,
|
||||
codename=codename,
|
||||
).exists()
|
||||
|
||||
# для других obj вернёмся к базовой логике
|
||||
return False
|
||||
|
||||
@ -215,6 +215,7 @@ ASGI_APPLICATION = "eusrr_backend.asgi.application"
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"eusrr_backend.auth_backends.EmailOrPhoneBackend",
|
||||
"eusrr_backend.auth_backends.PositionRoleBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
]
|
||||
|
||||
@ -231,7 +232,10 @@ REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication"
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
"rest_framework.permissions.DjangoModelPermissions",
|
||||
],
|
||||
"DEFAULT_THROTTLE_CLASSES": [
|
||||
"rest_framework.throttling.AnonRateThrottle",
|
||||
],
|
||||
|
||||
17
backend/feed/migrations/0003_alter_post_options.py
Normal file
17
backend/feed/migrations/0003_alter_post_options.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-28 11:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feed', '0002_alter_post_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='post',
|
||||
options={'ordering': ['-pinned', '-created_at'], 'permissions': [('publish_company_post', 'Может публиковать новости на главной'), ('publish_department_post', 'Может публиковать новости в отделах')], 'verbose_name': 'Публикация', 'verbose_name_plural': 'Публикации'},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-28 11:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('requests_app', '0005_alter_request_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='request',
|
||||
options={'ordering': ['-created_at'], 'permissions': [('can_view_all_requests', 'Можно просмотреть все заявления'), ('can_process_requests', 'Может одобрять/отклонять заявления')], 'verbose_name': 'Заявление', 'verbose_name_plural': 'Заявления'},
|
||||
),
|
||||
]
|
||||
@ -152,301 +152,540 @@
|
||||
border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* Убираем внутренний паддинг у «Активности» и возвращаем его только заголовку */
|
||||
#activity .p-4{ padding:0 !important; }
|
||||
#activity .ios-section-title{ padding:1rem 1rem .5rem !important; margin:0 !important; }
|
||||
|
||||
/* Растягиваем ленту на всю ширину карточки активности */
|
||||
#activity .activity-feed{ margin:0 !important; }
|
||||
|
||||
/* Делаем посты «монолитом»: без рамок, без тени, без скруглений.
|
||||
Линия между постами — это бордеры .feed-card; отключаем их. */
|
||||
#activity .activity-feed .feed-card{
|
||||
width:100% !important;
|
||||
max-width:none !important;
|
||||
border:0 !important; /* ← убирает «черточку» между постами */
|
||||
box-shadow:none !important; /* без теней внутри монолитного блока */
|
||||
border-radius:0 !important;
|
||||
margin:0 !important; /* расстояния контролируем контейнером */
|
||||
}
|
||||
|
||||
/* Контейнер — в столбик с равномерным зазором между постами */
|
||||
#activity .activity-feed{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:1rem; /* мягкий просвет без линий */
|
||||
}
|
||||
|
||||
/* На случай, если внутри используешь .feed-stream/.row */
|
||||
#activity .activity-feed .feed-stream{ display:flex; flex-direction:column; gap:1rem; }
|
||||
#activity .activity-feed .row > *{ flex:0 0 100% !important; max-width:100% !important; }
|
||||
|
||||
|
||||
/* убираем линию между постом и блоком комментариев */
|
||||
#activity .feed-actions{ border-top: 0 !important; }
|
||||
|
||||
/* опционально: убрать рамку у превью последнего комментария */
|
||||
#activity .collapse .bg-body-tertiary.border{ border: 0 !important; }
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* базовые карточки/разметка — как у тебя */
|
||||
.ios-card{border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);border-radius:var(--feed-radius);background:var(--bs-body-bg);box-shadow:var(--feed-shadow);transition:box-shadow .18s ease, transform .18s ease, border-color .18s ease;overflow:hidden}
|
||||
.ios-card:hover{box-shadow:var(--feed-shadow-hover);transform:translateY(-1px)}
|
||||
.ios-header{position:sticky;top:var(--navbar-h,0px);z-index:5;padding:.75rem 0;backdrop-filter:saturate(180%) blur(12px);background:color-mix(in srgb, var(--bs-body-bg) 85%, transparent);border-bottom:none!important}
|
||||
.ios-title{font-weight:800;letter-spacing:-.01em}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:.5rem}
|
||||
.chip{padding:.35rem .65rem;border-radius:9999px;font-size:.85rem;line-height:1;background:var(--bs-secondary-bg);color:var(--bs-body-color);border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%)}
|
||||
|
||||
/* ====== SKILLS UI ====== */
|
||||
/* плюс как «пилюля» */
|
||||
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;border-style:dashed}
|
||||
#skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease}
|
||||
#skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem}
|
||||
|
||||
/* чип навыка */
|
||||
.chip.chip-skill{position:relative;padding:.35rem .65rem}
|
||||
.chip.chip-skill .lbl{transition:opacity .15s ease}
|
||||
|
||||
/* кнопка-«минус» по центру (поверх подписи), без кружка */
|
||||
.chip.chip-skill .chip-del-btn{
|
||||
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||||
background:transparent;border:0;padding:0;margin:0;cursor:pointer;
|
||||
color:var(--bs-danger);font-size:1rem;line-height:1;
|
||||
visibility:hidden;opacity:0;transition:opacity .15s ease;z-index:1
|
||||
}
|
||||
.chip.chip-skill .chip-del-btn .bi{pointer-events:none}
|
||||
|
||||
/* показываем минус и прячем подпись ТОЛЬКО если можно редактировать */
|
||||
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .lbl{opacity:0}
|
||||
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .chip-del-btn,
|
||||
#skillsBlock[data-editable="1"] .chip.chip-skill:focus-within .chip-del-btn{visibility:visible;opacity:1}
|
||||
|
||||
/* подсказка-пусто */
|
||||
.skills-empty{color:var(--bs-secondary-color);font-size:.875rem;margin-right:.25rem}
|
||||
</style>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container ios-page">
|
||||
<div class="ios-header feed-header d-flex align-items-center gap-2 flex-wrap">
|
||||
<i class="bi-person-badge fs-2 text-primary"></i>
|
||||
<h2 class="title ios-title mb-0">
|
||||
{% if is_me %}Мой профиль{% else %}Профиль сотрудника{% endif %}
|
||||
</h2>
|
||||
<div class="ms-auto"></div>
|
||||
</div>
|
||||
<div class="container ios-page">
|
||||
<div class="ios-header feed-header d-flex align-items-center gap-2 flex-wrap">
|
||||
<i class="bi-person-badge fs-2 text-primary"></i>
|
||||
<h2 class="title ios-title mb-0">
|
||||
{% if is_me %}Мой профиль{% else %}Профиль сотрудника{% endif %}
|
||||
</h2>
|
||||
<div class="ms-auto"></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 g-lg-5 g-ios">
|
||||
<!-- LEFT -->
|
||||
<div class="col-md-4">
|
||||
<article class="ios-card">
|
||||
<div class="p-4 text-center">
|
||||
<div class="row g-4 g-lg-5 g-ios">
|
||||
<!-- LEFT -->
|
||||
<div class="col-md-4">
|
||||
<article class="ios-card">
|
||||
<div class="p-4 text-center">
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="ava-wrap mb-3">
|
||||
{% if emp.avatar %}
|
||||
<img src="{{ emp.avatar }}"
|
||||
alt="Аватар {{ emp.last_name }} {{ emp.first_name }}"
|
||||
class="ios-ava" style="object-fit:cover;">
|
||||
{% else %}
|
||||
<div class="ios-ava--ph">
|
||||
{% firstof emp.first_name emp.last_name "?" as initial %}
|
||||
{{ initial|slice:":1"|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Name + edit button -->
|
||||
<div class="d-flex align-items-center justify-content-center gap-2 mb-1">
|
||||
<div class="ios-name">
|
||||
{{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
|
||||
<!-- Avatar -->
|
||||
<div class="ava-wrap mb-3">
|
||||
{% if emp.avatar %}
|
||||
<img src="{{ emp.avatar }}"
|
||||
alt="Аватар {{ emp.last_name }} {{ emp.first_name }}"
|
||||
class="ios-ava" style="object-fit:cover;">
|
||||
{% else %}
|
||||
<div class="ios-ava--ph">
|
||||
{% firstof emp.first_name emp.last_name "?" as initial %}
|
||||
{{ initial|slice:":1"|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<a class="btn-icon" href="{% url 'employees:employee_edit' emp.id %}" title="Редактировать профиль" aria-label="Редактировать профиль">
|
||||
<i class="bi-pencil-square"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="ios-role">
|
||||
{% if emp.position.name %}
|
||||
{{ emp.position.name }}
|
||||
{% elif emp.position_label %}
|
||||
{{ emp.position_label }}
|
||||
{% elif emp.position %}
|
||||
Должность #{{ emp.position }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Name + edit button -->
|
||||
<div class="d-flex align-items-center justify-content-center gap-2 mb-1">
|
||||
<div class="ios-name">
|
||||
{{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<a class="btn-icon" href="{% url 'employees:employee_edit' emp.id %}" title="Редактировать профиль" aria-label="Редактировать профиль">
|
||||
<i class="bi-pencil-square"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# === НАВЫКИ (из DRF: [{id,name}], без итерации по строкам) === #}
|
||||
{% if emp.skills and emp.skills.0.name %}
|
||||
<div class="ios-role">
|
||||
{% if emp.position.name %}
|
||||
{{ emp.position.name }}
|
||||
{% elif emp.position_label %}
|
||||
{{ emp.position_label }}
|
||||
{% elif emp.position %}
|
||||
Должность #{{ emp.position }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div class="mt-4 text-start">
|
||||
<div class="ios-acc">
|
||||
<button class="ios-collapse-trigger"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#contactsCollapse"
|
||||
aria-expanded="false"
|
||||
aria-controls="contactsCollapse">
|
||||
<span class="title"><i class="bi-person-lines-fill me-1"></i>Контакты</span>
|
||||
<i class="bi-chevron-down ios-collapse-caret"></i>
|
||||
</button>
|
||||
|
||||
<div id="contactsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% if emp.email %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-envelope"></i>
|
||||
<a href="mailto:{{ emp.email }}">{{ emp.email }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.phone_number %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-telephone"></i>
|
||||
<a href="tel:{{ emp.phone_number }}">{{ emp.phone_number }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.telegram %}<div class="ios-cell"><i class="bi-telegram"></i><span>Telegram: {{ emp.telegram }}</span></div>{% endif %}
|
||||
{% if emp.whatsapp %}<div class="ios-cell"><i class="bi-whatsapp"></i><span>WhatsApp: {{ emp.whatsapp }}</span></div>{% endif %}
|
||||
{% if emp.wechat %}<div class="ios-cell"><i class="bi-wechat"></i><span>WeChat: {{ emp.wechat }}</span></div>{% endif %}
|
||||
{% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %}
|
||||
<div class="ios-cell"><span class="text-secondary">Контакты не указаны</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === ОТДЕЛЫ (без картинок; роль через тире; руководитель — визуально выделен) === #}
|
||||
<div class="mt-3 text-start">
|
||||
<div class="ios-acc">
|
||||
<button class="ios-collapse-trigger"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#skillsCollapse"
|
||||
data-bs-target="#departmentsCollapse"
|
||||
aria-expanded="false"
|
||||
aria-controls="skillsCollapse">
|
||||
<span class="title"><i class="bi-stars me-1"></i>Навыки</span>
|
||||
aria-controls="departmentsCollapse">
|
||||
<span class="title"><i class="bi-diagram-3 me-1"></i>Отделы</span>
|
||||
<i class="bi-chevron-down ios-collapse-caret"></i>
|
||||
</button>
|
||||
<div id="skillsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="chips justify-content-center">
|
||||
{% for s in emp.skills %}{% if s.name %}<span class="chip">{{ s.name }}</span>{% endif %}{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="departmentsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% if emp.departments and emp.departments|length %}
|
||||
{% for d in emp.departments %}
|
||||
{% if d.name %}
|
||||
<div class="ios-cell dept-item">
|
||||
<div class="dept-line">
|
||||
{% if d.id %}
|
||||
<a class="text-decoration-none dept-name" href="{% url 'employees:department_detail' d.id %}">{{ d.name }}</a>
|
||||
{% else %}
|
||||
<span class="dept-name">{{ d.name }}</span>
|
||||
{% endif %}
|
||||
{% if d.role_name %}<span class="dept-role">— {{ d.role_name }}</span>{% endif %}
|
||||
</div>
|
||||
{% if d.is_head %}
|
||||
<span class="badge-head" data-bs-toggle="tooltip" data-bs-title="Руководитель отдела">
|
||||
<i class="bi-award-fill" aria-hidden="true"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="ios-cell"><span class="text-secondary">Отделы не указаны</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{# === КАДРОВЫЕ СОБЫТИЯ (для аутентифицированных) === #}
|
||||
{% if request.user.is_authenticated %}
|
||||
{% with acts_src=employee_actions|default:emp.actions %}
|
||||
{% with acts=acts_src.results|default:acts_src %}
|
||||
{% if acts %}
|
||||
<div class="mt-3 text-start">
|
||||
<div class="ios-acc">
|
||||
<button class="ios-collapse-trigger" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#actionsCollapse"
|
||||
aria-expanded="false" aria-controls="actionsCollapse">
|
||||
<span class="title"><i class="bi-journal-text me-1"></i>Кадровые события</span>
|
||||
<i class="bi-chevron-down ios-collapse-caret"></i>
|
||||
</button>
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
<!-- Contacts -->
|
||||
<div class="mt-4 text-start">
|
||||
<div class="ios-acc">
|
||||
<button class="ios-collapse-trigger"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#contactsCollapse"
|
||||
aria-expanded="false"
|
||||
aria-controls="contactsCollapse">
|
||||
<span class="title"><i class="bi-person-lines-fill me-1"></i>Контакты</span>
|
||||
<i class="bi-chevron-down ios-collapse-caret"></i>
|
||||
</button>
|
||||
|
||||
<div id="contactsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% if emp.email %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-envelope"></i>
|
||||
<a href="mailto:{{ emp.email }}">{{ emp.email }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.phone_number %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-telephone"></i>
|
||||
<a href="tel:{{ emp.phone_number }}">{{ emp.phone_number }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.telegram %}<div class="ios-cell"><i class="bi-telegram"></i><span>Telegram: {{ emp.telegram }}</span></div>{% endif %}
|
||||
{% if emp.whatsapp %}<div class="ios-cell"><i class="bi-whatsapp"></i><span>WhatsApp: {{ emp.whatsapp }}</span></div>{% endif %}
|
||||
{% if emp.wechat %}<div class="ios-cell"><i class="bi-wechat"></i><span>WeChat: {{ emp.wechat }}</span></div>{% endif %}
|
||||
{% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %}
|
||||
<div class="ios-cell"><span class="text-secondary">Контакты не указаны</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === ОТДЕЛЫ (без картинок; роль через тире; руководитель — визуально выделен) === #}
|
||||
<div class="mt-3 text-start">
|
||||
<div class="ios-acc">
|
||||
<button class="ios-collapse-trigger"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#departmentsCollapse"
|
||||
aria-expanded="false"
|
||||
aria-controls="departmentsCollapse">
|
||||
<span class="title"><i class="bi-diagram-3 me-1"></i>Отделы</span>
|
||||
<i class="bi-chevron-down ios-collapse-caret"></i>
|
||||
</button>
|
||||
|
||||
<div id="departmentsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% if emp.departments and emp.departments|length %}
|
||||
{% for d in emp.departments %}
|
||||
{% if d.name %}
|
||||
<div class="ios-cell dept-item">
|
||||
<div class="dept-line">
|
||||
{% if d.id %}
|
||||
<a class="text-decoration-none dept-name" href="{% url 'employees:department_detail' d.id %}">{{ d.name }}</a>
|
||||
{% else %}
|
||||
<span class="dept-name">{{ d.name }}</span>
|
||||
{% endif %}
|
||||
{% if d.role_name %}<span class="dept-role">— {{ d.role_name }}</span>{% endif %}
|
||||
</div>
|
||||
{% if d.is_head %}
|
||||
<span class="badge-head" data-bs-toggle="tooltip" data-bs-title="Руководитель отдела">
|
||||
<i class="bi-award-fill" aria-hidden="true"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="ios-cell"><span class="text-secondary">Отделы не указаны</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{# === КАДРОВЫЕ СОБЫТИЯ (для аутентифицированных) === #}
|
||||
{% if request.user.is_authenticated %}
|
||||
{% with acts_src=employee_actions|default:emp.actions %}
|
||||
{% with acts=acts_src.results|default:acts_src %}
|
||||
{% if acts %}
|
||||
<div class="mt-3 text-start">
|
||||
<div class="ios-acc">
|
||||
<button class="ios-collapse-trigger" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#actionsCollapse"
|
||||
aria-expanded="false" aria-controls="actionsCollapse">
|
||||
<span class="title"><i class="bi-journal-text me-1"></i>Кадровые события</span>
|
||||
<i class="bi-chevron-down ios-collapse-caret"></i>
|
||||
</button>
|
||||
|
||||
<div id="actionsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% for a in acts %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-clipboard-check" style="width:1.25rem;text-align:center;opacity:.9;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{% firstof a.action_display a.action %}</div>
|
||||
{% if a.comment %}<div class="text-secondary">{{ a.comment }}</div>{% endif %}
|
||||
<div class="small text-secondary">
|
||||
<time>{% firstof a.date_display a.date|date:"d.m.Y H:i" %}</time>
|
||||
</div>
|
||||
{% if a.extra %}
|
||||
<div class="small text-secondary mt-1">
|
||||
{% for k, v in a.extra.items %}
|
||||
<div>{{ k }}: {{ v }}</div>
|
||||
{% endfor %}
|
||||
<div id="actionsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% for a in acts %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-clipboard-check" style="width:1.25rem;text-align:center;opacity:.9;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{% firstof a.action_display a.action %}</div>
|
||||
{% if a.comment %}<div class="text-secondary">{{ a.comment }}</div>{% endif %}
|
||||
<div class="small text-secondary">
|
||||
<time>{% firstof a.date_display a.date|date:"d.m.Y H:i" %}</time>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if a.extra %}
|
||||
<div class="small text-secondary mt-1">
|
||||
{% for k, v in a.extra.items %}
|
||||
<div>{{ k }}: {{ v }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# История этого события: без "создано/изменено", только название события и новые значения #}
|
||||
{% if a.history and a.history|length %}
|
||||
<details class="mt-2 small">
|
||||
<summary class="text-secondary">История</summary>
|
||||
<div class="mt-2">
|
||||
{% for h in a.history %}
|
||||
{% with new_action=h.changes.action.new|default:a.action %}
|
||||
<div class="py-2 border-top">
|
||||
<div class="d-flex align-items-baseline gap-2">
|
||||
<span class="fw-semibold">
|
||||
{# Переводим код события в человекочитаемый #}
|
||||
{% if new_action == 'hired' %}Принят
|
||||
{% elif new_action == 'dismissed' %}Уволен
|
||||
{% elif new_action == 'on_leave' %}В отпуске
|
||||
{% elif new_action == 'returned_from_leave' %}Вернулся из отпуска
|
||||
{% elif new_action == 'on_maternity' %}В декрете
|
||||
{% elif new_action == 'returned_from_maternity' %}Вернулся из декрета
|
||||
{% elif new_action == 'transferred' %}Переведен
|
||||
{% elif new_action == 'rehired' %}Восстановлен
|
||||
{% else %}{% firstof a.action_display a.action %}{% endif %}
|
||||
</span>
|
||||
<span class="text-secondary">
|
||||
{% firstof h.history_date_display h.history_date %}{% if h.history_user %} • {{ h.history_user }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{# История этого события: без "создано/изменено", только название события и новые значения #}
|
||||
{% if a.history and a.history|length %}
|
||||
<details class="mt-2 small">
|
||||
<summary class="text-secondary">История</summary>
|
||||
<div class="mt-2">
|
||||
{% for h in a.history %}
|
||||
{% with new_action=h.changes.action.new|default:a.action %}
|
||||
<div class="py-2 border-top">
|
||||
<div class="d-flex align-items-baseline gap-2">
|
||||
<span class="fw-semibold">
|
||||
{# Переводим код события в человекочитаемый #}
|
||||
{% if new_action == 'hired' %}Принят
|
||||
{% elif new_action == 'dismissed' %}Уволен
|
||||
{% elif new_action == 'on_leave' %}В отпуске
|
||||
{% elif new_action == 'returned_from_leave' %}Вернулся из отпуска
|
||||
{% elif new_action == 'on_maternity' %}В декрете
|
||||
{% elif new_action == 'returned_from_maternity' %}Вернулся из декрета
|
||||
{% elif new_action == 'transferred' %}Переведен
|
||||
{% elif new_action == 'rehired' %}Восстановлен
|
||||
{% else %}{% firstof a.action_display a.action %}{% endif %}
|
||||
</span>
|
||||
<span class="text-secondary">
|
||||
{% firstof h.history_date_display h.history_date %}{% if h.history_user %} • {{ h.history_user }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if h.changes %}
|
||||
<div class="ms-3 mt-1">
|
||||
{# Показываем только НОВЫЕ значения (без зачёркнутых старых) #}
|
||||
{% for field, diff in h.changes.items %}
|
||||
{% if field != 'action' %}
|
||||
<div>
|
||||
<span class="text-secondary">
|
||||
{% if field == 'date' %}Дата события
|
||||
{% elif field == 'comment' %}Комментарий
|
||||
{% elif field == 'extra' %}Дополнительно
|
||||
{% elif field == 'employee_id' %}Сотрудник
|
||||
{% else %}{{ field }}{% endif %}
|
||||
:
|
||||
</span>
|
||||
{% if field == 'extra' and diff.new %}
|
||||
{% if diff.new.items %}
|
||||
{% for ek, ev in diff.new.items %}
|
||||
{% if not forloop.first %}, {% endif %}{{ ek }}: {{ ev }}
|
||||
{% endfor %}
|
||||
{% if h.changes %}
|
||||
<div class="ms-3 mt-1">
|
||||
{# Показываем только НОВЫЕ значения (без зачёркнутых старых) #}
|
||||
{% for field, diff in h.changes.items %}
|
||||
{% if field != 'action' %}
|
||||
<div>
|
||||
<span class="text-secondary">
|
||||
{% if field == 'date' %}Дата события
|
||||
{% elif field == 'comment' %}Комментарий
|
||||
{% elif field == 'extra' %}Дополнительно
|
||||
{% elif field == 'employee_id' %}Сотрудник
|
||||
{% else %}{{ field }}{% endif %}
|
||||
:
|
||||
</span>
|
||||
{% if field == 'extra' and diff.new %}
|
||||
{% if diff.new.items %}
|
||||
{% for ek, ev in diff.new.items %}
|
||||
{% if not forloop.first %}, {% endif %}{{ ek }}: {{ ev }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{ diff.new }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ diff.new }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ diff.new }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="ios-cell"><span class="text-secondary">Событий нет.</span></div>
|
||||
{% endfor %}
|
||||
{% empty %}
|
||||
<div class="ios-cell"><span class="text-secondary">Событий нет.</span></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{# ====== НАВЫКИ ====== #}
|
||||
<div class="mt-3 text-start" id="skillsBlock"
|
||||
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
|
||||
data-editable="1"
|
||||
{% endif %}>
|
||||
<div class="ios-section-title mb-2"><i class="bi bi-stars me-1"></i>Навыки</div>
|
||||
|
||||
<div class="chips align-items-center">
|
||||
{% with skills=emp.skills %}
|
||||
{% if skills %}
|
||||
{% for s in skills %}
|
||||
{% firstof s.name s as sk_name %}
|
||||
{% if sk_name %}
|
||||
<span class="chip chip-skill"
|
||||
data-skill-id="{{ s.id|default:'' }}"
|
||||
data-skill-name="{{ sk_name }}">
|
||||
<span class="lbl">{{ sk_name }}</span>
|
||||
|
||||
{# кнопка «минус» только если есть право #}
|
||||
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
|
||||
<button type="button" class="chip-del-btn" title="Убрать навык" aria-label="Убрать навык">
|
||||
<i class="bi bi-dash-lg"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="skills-empty"></span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{# плюс только если есть право #}
|
||||
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
|
||||
<button type="button" class="chip chip-add"
|
||||
data-bs-toggle="collapse" data-bs-target="#skillsForm"
|
||||
aria-expanded="false" aria-controls="skillsForm" title="Добавить навык">
|
||||
<i class="bi bi-plus-lg"></i><span class="d-none d-sm-inline"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
|
||||
<div id="skillsForm" class="collapse mt-2">
|
||||
<form id="skillAddForm" class="skill-form"
|
||||
action="{% url 'employees:skill_add' emp.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row g-2">
|
||||
<div class="col-12">
|
||||
<input class="form-control" name="skill_name" list="skillsAll"
|
||||
placeholder="Выберите или введите новый навык" required>
|
||||
<datalist id="skillsAll">
|
||||
{% for sk in all_skills %}
|
||||
{% if sk.name %}<option value="{{ sk.name }}"></option>{% endif %}
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" data-bs-toggle="collapse" data-bs-target="#skillsForm">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- RIGHT -->
|
||||
<div class="col-md-8">
|
||||
<article class="ios-card">
|
||||
<div class="p-4">
|
||||
<div class="ios-section-title mb-3"><i class="bi-activity me-1"></i>Активность</div>
|
||||
{% if posts %}
|
||||
{% include "feed/_feed_cards.html" with items=posts only %}
|
||||
{% else %}
|
||||
<div class="text-secondary small">Публикаций пока нет.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT -->
|
||||
<div class="col-md-8">
|
||||
<article class="ios-card" id="activity">
|
||||
<div class="p-4">
|
||||
<div class="ios-section-title mb-3"><i class="bi-activity me-1"></i>Активность</div>
|
||||
{% if posts %}
|
||||
<div class="activity-feed">
|
||||
{% include "feed/_feed_cards.html" with items=posts only %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-secondary small">Публикаций пока нет.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const block = document.getElementById('skillsBlock');
|
||||
if (!block || block.dataset.editable !== '1') return;
|
||||
|
||||
const form = document.getElementById('skillAddForm');
|
||||
const chipsWrap = block.querySelector('.chips');
|
||||
const addBtn = block.querySelector('.chip-add');
|
||||
const collapseEl = document.getElementById('skillsForm');
|
||||
const removeUrl = "{% url 'employees:skill_remove' emp.id %}";
|
||||
const csrf = form?.querySelector('input[name="csrfmiddlewaretoken"]')?.value || '';
|
||||
|
||||
let bsCollapse = null;
|
||||
if (window.bootstrap && collapseEl) {
|
||||
bsCollapse = new bootstrap.Collapse(collapseEl, { toggle:false });
|
||||
}
|
||||
|
||||
if (form){
|
||||
const input = form.querySelector('input[name="skill_name"]');
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = (input.value || '').trim();
|
||||
if (!name) { input.focus(); return; }
|
||||
submitBtn?.setAttribute('disabled','');
|
||||
|
||||
try{
|
||||
const resp = await fetch(form.action, {
|
||||
method:'POST', credentials:'same-origin',
|
||||
headers:{
|
||||
'X-CSRFToken': csrf,
|
||||
'X-Requested-With':'XMLHttpRequest',
|
||||
'Accept':'application/json',
|
||||
'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
},
|
||||
body:new URLSearchParams({ skill_name:name })
|
||||
});
|
||||
if(!resp.ok) throw new Error('HTTP '+resp.status);
|
||||
let data=null; try{ data=await resp.json(); }catch(_){}
|
||||
const displayName=(data && (data.name || data?.skill?.name || data?.title)) || name;
|
||||
const skillId=(data && (data.id || data?.skill?.id)) || '';
|
||||
|
||||
chipsWrap.querySelector('.skills-empty')?.remove();
|
||||
const exists=[...chipsWrap.querySelectorAll('.chip-skill')].some(
|
||||
el => (el.dataset.skillName || el.textContent).trim().toLowerCase() === displayName.toLowerCase()
|
||||
);
|
||||
if(!exists){
|
||||
const chip=document.createElement('span');
|
||||
chip.className='chip chip-skill';
|
||||
chip.dataset.skillId=String(skillId || '');
|
||||
chip.dataset.skillName=displayName;
|
||||
chip.innerHTML=`
|
||||
<span class="lbl">${displayName}</span>
|
||||
<button type="button" class="chip-del-btn" title="Убрать навык" aria-label="Убрать навык">
|
||||
<i class="bi bi-dash-lg"></i>
|
||||
</button>`;
|
||||
chipsWrap.insertBefore(chip, addBtn);
|
||||
}
|
||||
|
||||
input.value='';
|
||||
bsCollapse ? bsCollapse.hide() : collapseEl?.classList.remove('show');
|
||||
addBtn?.classList.add('pulse'); setTimeout(()=>addBtn?.classList.remove('pulse'),180);
|
||||
}catch(e){
|
||||
console.error(e); location.reload();
|
||||
}finally{
|
||||
submitBtn?.removeAttribute('disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
chipsWrap.addEventListener('click', async (e) => {
|
||||
const del = e.target.closest('.chip-del-btn');
|
||||
if (!del) return;
|
||||
const chip = del.closest('.chip-skill');
|
||||
if (!chip) return;
|
||||
|
||||
const skillId = chip.dataset.skillId || '';
|
||||
const skillName = chip.dataset.skillName || chip.textContent.trim();
|
||||
|
||||
del.disabled = true;
|
||||
try{
|
||||
const resp = await fetch(removeUrl, {
|
||||
method:'POST', credentials:'same-origin',
|
||||
headers:{
|
||||
'X-CSRFToken': csrf,
|
||||
'X-Requested-With':'XMLHttpRequest',
|
||||
'Accept':'application/json',
|
||||
'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
},
|
||||
body:new URLSearchParams({ skill_id:skillId, skill_name:skillName })
|
||||
});
|
||||
if(!resp.ok) throw new Error('HTTP '+resp.status);
|
||||
|
||||
chip.remove();
|
||||
if (!chipsWrap.querySelector('.chip-skill')){
|
||||
const note=document.createElement('span');
|
||||
note.className='skills-empty';
|
||||
note.textContent='';
|
||||
chipsWrap.insertBefore(note, addBtn);
|
||||
}
|
||||
}catch(e){
|
||||
console.error(e); location.reload();
|
||||
}finally{
|
||||
del.disabled=false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
</div>
|
||||
|
||||
{% if post.pinned %}
|
||||
<span class="feed-pin"><i class="bi-pin-angle"></i> закреплено</span>
|
||||
<span class="feed-pin"><i class="bi-pin-angle"></i></span>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
|
||||
@ -56,18 +56,18 @@ def register_payload(**overrides):
|
||||
|
||||
|
||||
def register(api: APIClient, **overrides):
|
||||
url = reverse("api:api_v1:register")
|
||||
url = reverse("api:v1:register")
|
||||
return api.post(url, register_payload(**overrides), format="json")
|
||||
|
||||
|
||||
def verify(api: APIClient, *, email: str, code: str | None):
|
||||
url = reverse("api:api_v1:verify-email")
|
||||
url = reverse("api:v1:verify-email")
|
||||
# интерфейс верификации: email + code
|
||||
return api.post(url, {"email": email, "code": code}, format="json")
|
||||
|
||||
|
||||
def resend(api: APIClient, *, email: str):
|
||||
url = reverse("api:api_v1:resend-email")
|
||||
url = reverse("api:v1:resend-email")
|
||||
return api.post(url, {"email": email}, format="json")
|
||||
|
||||
|
||||
@ -109,13 +109,13 @@ def test_register_success_sends_email_and_user_inactive(api):
|
||||
def test_register_missing_required_field(api, missing_field):
|
||||
payload = register_payload()
|
||||
payload.pop(missing_field)
|
||||
resp = api.post(reverse("api:api_v1:register"), payload, format="json")
|
||||
resp = api.post(reverse("api:v1:register"), payload, format="json")
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_register_requires_at_least_one_contact(api):
|
||||
payload = register_payload(telegram="", whatsapp="", wechat="")
|
||||
resp = api.post(reverse("api:api_v1:register"), payload, format="json")
|
||||
resp = api.post(reverse("api:v1:register"), payload, format="json")
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "WhatsApp" in resp.json().get("detail", "") or "WeChat" in resp.json().get("detail", "") or "Telegram" in resp.json().get("detail", "")
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ def _make_user(staff=False, superuser=False) -> Employee:
|
||||
return u
|
||||
|
||||
def _dept_set_head(api_client, dept_id, head_id):
|
||||
url = reverse("api:api_v1:departments-set-head", args=[dept_id])
|
||||
url = reverse("api:v1:departments-set-head", args=[dept_id])
|
||||
payload = {"head_id": head_id} if head_id is not None else {"head_id": None}
|
||||
return api_client.post(url, payload, format="json")
|
||||
|
||||
@ -47,7 +47,7 @@ def test_old_head_loses_rights_after_change(api_client):
|
||||
dept = Department.objects.create(name="Alpha", head=old_head)
|
||||
|
||||
# Как глава — может править отдел (partial_update требует manage_department)
|
||||
url_detail = reverse("api:api_v1:departments-detail", args=[dept.id])
|
||||
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
|
||||
resp = api_client.patch(url_detail, {"description": "init"}, format="json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@ -79,7 +79,7 @@ def test_new_head_gets_rights_and_membership_created(api_client):
|
||||
|
||||
# У нового главы есть права управления отделом
|
||||
api_client.force_authenticate(user=new_head)
|
||||
url_detail = reverse("api:api_v1:departments-detail", args=[dept.id])
|
||||
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
|
||||
resp = api_client.patch(url_detail, {"description": "ok"}, format="json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@ -97,7 +97,7 @@ def test_remove_head_deactivates_membership_and_revokes_rights(api_client):
|
||||
|
||||
# как глава он может управлять
|
||||
api_client.force_authenticate(user=head)
|
||||
url_detail = reverse("api:api_v1:departments-detail", args=[dept.id])
|
||||
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
|
||||
assert api_client.patch(url_detail, {"description": "ok"}, format="json").status_code == 200
|
||||
|
||||
# staff снимает главу (head -> null)
|
||||
@ -138,7 +138,7 @@ def test_old_head_keeps_rights_if_role_grants_after_change(api_client):
|
||||
|
||||
# старый глава уже не head, но он обладает ролью с manage_department -> должен иметь доступ
|
||||
api_client.force_authenticate(user=old_head)
|
||||
url_detail = reverse("api:api_v1:departments-detail", args=[dept.id])
|
||||
url_detail = reverse("api:v1:departments-detail", args=[dept.id])
|
||||
resp = api_client.patch(url_detail, {"description": "still ok"}, format="json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ def _grant_perm(user: Employee, perm_code: str):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_and_filter_requires_auth(api_client):
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
|
||||
# unauth -> 401/403
|
||||
resp = api_client.get(url)
|
||||
@ -77,7 +77,7 @@ def test_head_can_crud_in_own_department(api_client):
|
||||
# гарантируем активную связь головы с отделом
|
||||
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
|
||||
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
|
||||
# create в своём отделе
|
||||
resp = api_client.post(url, {"department": dept.id, "name": "Lead"}, format="json")
|
||||
@ -85,7 +85,7 @@ def test_head_can_crud_in_own_department(api_client):
|
||||
role_id = resp.data["id"]
|
||||
|
||||
# update
|
||||
url_detail = reverse("api:api_v1:department-roles-detail", args=[role_id])
|
||||
url_detail = reverse("api:v1:department-roles-detail", args=[role_id])
|
||||
resp = api_client.patch(url_detail, {"name": "Lead++"}, format="json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@ -102,7 +102,7 @@ def test_head_cannot_crud_in_foreign_department(api_client):
|
||||
EmployeeDepartment.objects.get_or_create(employee=head, department=own, defaults={"is_active": True})
|
||||
|
||||
# попытка создать роль в чужом отделе -> 403
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
resp = api_client.post(url, {"department": foreign.id, "name": "X"}, format="json")
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@ -112,7 +112,7 @@ def test_non_head_needs_manage_for_crud_and_assign_for_perms(api_client):
|
||||
api_client.force_authenticate(user=user)
|
||||
dept = Department.objects.create(name="Ops")
|
||||
# нет ни head, ни ролей — CRUD запрещён
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
resp = api_client.post(url, {"department": dept.id, "name": "Role"}, format="json")
|
||||
assert resp.status_code == 403
|
||||
|
||||
@ -122,12 +122,12 @@ def test_non_head_needs_manage_for_crud_and_assign_for_perms(api_client):
|
||||
assert resp.status_code == 201
|
||||
role_id = resp.data["id"]
|
||||
|
||||
url_detail = reverse("api:api_v1:department-roles-detail", args=[role_id])
|
||||
url_detail = reverse("api:v1:department-roles-detail", args=[role_id])
|
||||
resp = api_client.patch(url_detail, {"name": "Role2"}, format="json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# но set_perms всё ещё нельзя: нужен assign_department_role
|
||||
url_set = reverse("api:api_v1:department-roles-set-perms", args=[role_id])
|
||||
url_set = reverse("api:v1:department-roles-set-perms", args=[role_id])
|
||||
resp = api_client.post(url_set, {"permission_ids": []}, format="json")
|
||||
assert resp.status_code == 403
|
||||
|
||||
@ -152,25 +152,25 @@ def test_perms_listing_and_add_remove(api_client):
|
||||
p_manage = Permission.objects.get(content_type__app_label="employees", codename="manage_department")
|
||||
|
||||
# add_perms (как head)
|
||||
url_add = reverse("api:api_v1:department-roles-add-perms", args=[role.id])
|
||||
url_add = reverse("api:v1:department-roles-add-perms", args=[role.id])
|
||||
resp = api_client.post(url_add, {"permission_ids": [p_view.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# perms (GET)
|
||||
url_perms = reverse("api:api_v1:department-roles-perms", args=[role.id])
|
||||
url_perms = reverse("api:v1:department-roles-perms", args=[role.id])
|
||||
resp = api_client.get(url_perms)
|
||||
assert resp.status_code == 200
|
||||
codes = {row["codename"] for row in resp.data["results"]}
|
||||
assert "employees.view_employee" in codes
|
||||
|
||||
# remove_perms
|
||||
url_rm = reverse("api:api_v1:department-roles-remove-perms", args=[role.id])
|
||||
url_rm = reverse("api:v1:department-roles-remove-perms", args=[role.id])
|
||||
resp = api_client.post(url_rm, {"permission_ids": [p_view.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
assert set(role.permissions.values_list("id", flat=True)) == set()
|
||||
|
||||
# set_perms
|
||||
url_set = reverse("api:api_v1:department-roles-set-perms", args=[role.id])
|
||||
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
|
||||
resp = api_client.post(url_set, {"permission_ids": [p_manage.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
assert set(role.permissions.values_list("id", flat=True)) == {p_manage.id}
|
||||
|
||||
@ -58,7 +58,7 @@ def test_perms_endpoint_requires_auth(api_client):
|
||||
d = Department.objects.create(name="A")
|
||||
role = DepartmentRole.objects.create(department=d, name="R")
|
||||
|
||||
url = reverse("api:api_v1:department-roles-perms", args=[role.id])
|
||||
url = reverse("api:v1:department-roles-perms", args=[role.id])
|
||||
|
||||
# unauth -> 401/403
|
||||
resp = api_client.get(url)
|
||||
@ -81,7 +81,7 @@ def test_filter_invalid_department_param(api_client):
|
||||
d = Department.objects.create(name="D")
|
||||
DepartmentRole.objects.create(department=d, name="R1")
|
||||
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
|
||||
# нечисловой фильтр department -> просто пустой список (ошибки быть не должно)
|
||||
resp = api_client.get(url, {"department": "oops"})
|
||||
@ -105,7 +105,7 @@ def test_member_role_with_manage_can_crud_in_own_department(api_client):
|
||||
# делаем пользователя членом отдела с этой ролью
|
||||
EmployeeDepartment.objects.create(employee=user, department=dept, role=role_mgr, is_active=True)
|
||||
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
|
||||
# create в своём отделе — должен пройти
|
||||
resp = api_client.post(url, {"department": dept.id, "name": "NewRole"}, format="json")
|
||||
@ -113,7 +113,7 @@ def test_member_role_with_manage_can_crud_in_own_department(api_client):
|
||||
role_id = resp.data["id"]
|
||||
|
||||
# update — тоже должен пройти
|
||||
url_detail = reverse("api:api_v1:department-roles-detail", args=[role_id])
|
||||
url_detail = reverse("api:v1:department-roles-detail", args=[role_id])
|
||||
resp = api_client.patch(url_detail, {"name": "NewRole++"}, format="json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@ -140,7 +140,7 @@ def test_member_role_assign_can_set_perms_without_manage(api_client):
|
||||
p_view_emp = Permission.objects.get(content_type__app_label="employees", codename="view_employee")
|
||||
|
||||
# set_perms должен позволяться с одним правом assign_department_role
|
||||
url_set = reverse("api:api_v1:department-roles-set-perms", args=[target_role.id])
|
||||
url_set = reverse("api:v1:department-roles-set-perms", args=[target_role.id])
|
||||
resp = api_client.post(url_set, {"permission_ids": [p_view_emp.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
assert set(target_role.permissions.values_list("id", flat=True)) == {p_view_emp.id}
|
||||
@ -155,12 +155,12 @@ def test_global_manage_grants_crud_in_any_department(api_client):
|
||||
|
||||
foreign = Department.objects.create(name="Foreign-Dept")
|
||||
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
resp = api_client.post(url, {"department": foreign.id, "name": "GloballyAllowed"}, format="json")
|
||||
assert resp.status_code == 201
|
||||
|
||||
role_id = resp.data["id"]
|
||||
url_detail = reverse("api:api_v1:department-roles-detail", args=[role_id])
|
||||
url_detail = reverse("api:v1:department-roles-detail", args=[role_id])
|
||||
resp = api_client.patch(url_detail, {"name": "EditOK"}, format="json")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@ -182,7 +182,7 @@ def test_role_in_other_dept_does_not_grant_cross_access(api_client):
|
||||
EmployeeDepartment.objects.create(employee=user, department=dept_a, role=role_mgr_a, is_active=True)
|
||||
|
||||
# create в отделе B -> 403
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
resp = api_client.post(url, {"department": dept_b.id, "name": "NoAccess"}, format="json")
|
||||
assert resp.status_code == 403
|
||||
|
||||
@ -201,7 +201,7 @@ def test_set_perms_replaces_completely_and_dedup(api_client):
|
||||
# сначала добавим одно право
|
||||
role.permissions.add(p_view)
|
||||
|
||||
url_set = reverse("api:api_v1:department-roles-set-perms", args=[role.id])
|
||||
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
|
||||
# передаём список с дублями -> в итоге только p_manage
|
||||
resp = api_client.post(url_set, {"permission_ids": [p_manage.id, p_manage.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
@ -220,7 +220,7 @@ def test_add_and_remove_idempotent(api_client):
|
||||
p2 = Permission.objects.get(content_type__app_label="employees", codename="assign_department_role")
|
||||
|
||||
# add_perms с дублями
|
||||
url_add = reverse("api:api_v1:department-roles-add-perms", args=[role.id])
|
||||
url_add = reverse("api:v1:department-roles-add-perms", args=[role.id])
|
||||
resp = api_client.post(url_add, {"permission_ids": [p1.id, p1.id, p2.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
assert set(role.permissions.values_list("id", flat=True)) == {p1.id, p2.id}
|
||||
@ -231,7 +231,7 @@ def test_add_and_remove_idempotent(api_client):
|
||||
assert set(role.permissions.values_list("id", flat=True)) == {p1.id, p2.id}
|
||||
|
||||
# remove_perms частичный и повторный (идемпотентность)
|
||||
url_rm = reverse("api:api_v1:department-roles-remove-perms", args=[role.id])
|
||||
url_rm = reverse("api:v1:department-roles-remove-perms", args=[role.id])
|
||||
resp = api_client.post(url_rm, {"permission_ids": [p1.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
assert set(role.permissions.values_list("id", flat=True)) == {p2.id}
|
||||
@ -249,7 +249,7 @@ def test_missing_department_on_create_returns_400(api_client):
|
||||
dept = Department.objects.create(name="Doc", head=head)
|
||||
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
|
||||
|
||||
url = reverse("api:api_v1:department-roles-list")
|
||||
url = reverse("api:v1:department-roles-list")
|
||||
# забыли передать department -> 400 (валидация сериализатора)
|
||||
resp = api_client.post(url, {"name": "RoleX"}, format="json")
|
||||
assert resp.status_code == 400
|
||||
@ -264,7 +264,7 @@ def test_permissions_payload_validation_errors(api_client):
|
||||
role = DepartmentRole.objects.create(department=dept, name="R")
|
||||
|
||||
# не список -> 400
|
||||
url_set = reverse("api:api_v1:department-roles-set-perms", args=[role.id])
|
||||
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
|
||||
resp = api_client.post(url_set, {"permission_ids": "oops"}, format="json")
|
||||
assert resp.status_code == 400
|
||||
|
||||
@ -280,6 +280,6 @@ def test_delete_nonexistent_returns_404(api_client):
|
||||
dept = Department.objects.create(name="Del", head=head)
|
||||
EmployeeDepartment.objects.get_or_create(employee=head, department=dept, defaults={"is_active": True})
|
||||
|
||||
url_detail = reverse("api:api_v1:department-roles-detail", args=[999999])
|
||||
url_detail = reverse("api:v1:department-roles-detail", args=[999999])
|
||||
resp = api_client.delete(url_detail)
|
||||
assert resp.status_code == 404
|
||||
|
||||
@ -130,7 +130,7 @@ def extract_results(data):
|
||||
|
||||
|
||||
def test_list_requires_auth(api_client: APIClient):
|
||||
url = reverse("api:api_v1:departments-list")
|
||||
url = reverse("api:v1:departments-list")
|
||||
resp = api_client.get(url)
|
||||
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@ -142,7 +142,7 @@ def test_list_ok_for_authenticated(api_client: APIClient):
|
||||
Department.objects.create(name="A")
|
||||
Department.objects.create(name="B")
|
||||
|
||||
url = reverse("api:api_v1:departments-list")
|
||||
url = reverse("api:v1:departments-list")
|
||||
resp = api_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
items = extract_results(resp.json())
|
||||
@ -158,7 +158,7 @@ def test_search_and_ordering(api_client: APIClient):
|
||||
Department.objects.create(name="Beta", description="second")
|
||||
Department.objects.create(name="Gamma", description="third")
|
||||
|
||||
base = reverse("api:api_v1:departments-list")
|
||||
base = reverse("api:v1:departments-list")
|
||||
|
||||
# поиск
|
||||
resp = api_client.get(base, {"search": "be"})
|
||||
@ -193,7 +193,7 @@ def test_employees_count_adds_head_if_not_in_links(api_client: APIClient):
|
||||
EmployeeDepartment.objects.create(employee=e1, department=d, is_active=True)
|
||||
EmployeeDepartment.objects.create(employee=e2, department=d, is_active=True)
|
||||
|
||||
url = reverse("api:api_v1:departments-detail", args=[d.pk])
|
||||
url = reverse("api:v1:departments-detail", args=[d.pk])
|
||||
resp = api_client.get(url)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@ -212,7 +212,7 @@ def test_employees_count_not_double_count_head_if_in_links(api_client: APIClient
|
||||
EmployeeDepartment.objects.create(employee=e1, department=d, is_active=True)
|
||||
EmployeeDepartment.objects.create(employee=e2, department=d, is_active=True)
|
||||
|
||||
url = reverse("api:api_v1:departments-detail", args=[d.pk])
|
||||
url = reverse("api:v1:departments-detail", args=[d.pk])
|
||||
resp = api_client.get(url)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@ -226,7 +226,7 @@ def test_create_requires_staff(api_client: APIClient):
|
||||
user = make_user("u@example.com")
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("api:api_v1:departments-list")
|
||||
url = reverse("api:v1:departments-list")
|
||||
|
||||
# не staff
|
||||
resp = api_client.post(url, {"name": "New"}, format="json")
|
||||
@ -242,7 +242,7 @@ def test_create_requires_staff(api_client: APIClient):
|
||||
|
||||
def test_destroy_requires_staff(api_client: APIClient):
|
||||
d = Department.objects.create(name="X")
|
||||
url = reverse("api:api_v1:departments-detail", args=[d.pk])
|
||||
url = reverse("api:v1:departments-detail", args=[d.pk])
|
||||
|
||||
user = make_user("u@example.com")
|
||||
api_client.force_authenticate(user=user)
|
||||
@ -267,7 +267,7 @@ def test_partial_update_name_requires_manage_perm(api_client: APIClient):
|
||||
api_client.force_authenticate(user=emp)
|
||||
EmployeeDepartment.objects.create(employee=emp, department=d, is_active=True)
|
||||
|
||||
url = reverse("api:api_v1:departments-detail", args=[d.pk])
|
||||
url = reverse("api:v1:departments-detail", args=[d.pk])
|
||||
# без прав
|
||||
assert (
|
||||
api_client.patch(url, {"name": "New Name"}, format="json").status_code
|
||||
@ -298,7 +298,7 @@ def test_partial_update_head_requires_change_head_perm(api_client: APIClient):
|
||||
employee=emp, department=d, is_active=True, role=r_manage
|
||||
)
|
||||
|
||||
url = reverse("api:api_v1:departments-detail", args=[d.pk])
|
||||
url = reverse("api:v1:departments-detail", args=[d.pk])
|
||||
# только manage → 403
|
||||
assert (
|
||||
api_client.patch(url, {"head_id": b.id}, format="json").status_code
|
||||
@ -332,7 +332,7 @@ def test_partial_update_head_same_value_does_not_require_extra_perm(
|
||||
employee=emp, department=d, is_active=True, role=r_manage
|
||||
)
|
||||
|
||||
url = reverse("api:api_v1:departments-detail", args=[d.pk])
|
||||
url = reverse("api:v1:departments-detail", args=[d.pk])
|
||||
assert (
|
||||
api_client.patch(url, {"head_id": a.id}, format="json").status_code
|
||||
== status.HTTP_200_OK
|
||||
@ -356,7 +356,7 @@ def test_set_head_by_role_with_perm(api_client: APIClient):
|
||||
employee=manager, department=d, is_active=True, role=r
|
||||
)
|
||||
|
||||
url = reverse("api:api_v1:departments-set-head", args=[d.pk])
|
||||
url = reverse("api:v1:departments-set-head", args=[d.pk])
|
||||
resp = api_client.post(url, {"head_id": candidate.id}, format="json")
|
||||
assert resp.status_code == 200
|
||||
d.refresh_from_db()
|
||||
@ -377,7 +377,7 @@ def test_set_head_validation_inactive_employee(api_client: APIClient):
|
||||
employee=manager, department=d, is_active=True, role=r
|
||||
)
|
||||
|
||||
url = reverse("api:api_v1:departments-set-head", args=[d.pk])
|
||||
url = reverse("api:v1:departments-set-head", args=[d.pk])
|
||||
assert (
|
||||
api_client.post(url, {"head_id": inactive.id}, format="json").status_code
|
||||
== status.HTTP_400_BAD_REQUEST
|
||||
@ -395,7 +395,7 @@ def test_set_head_remove_with_null(api_client: APIClient):
|
||||
employee=manager, department=d, is_active=True, role=r
|
||||
)
|
||||
|
||||
url = reverse("api:api_v1:departments-set-head", args=[d.pk])
|
||||
url = reverse("api:v1:departments-set-head", args=[d.pk])
|
||||
assert (
|
||||
api_client.post(url, {"head_id": None}, format="json").status_code
|
||||
== status.HTTP_204_NO_CONTENT
|
||||
@ -415,7 +415,7 @@ def test_set_head_requires_perm(api_client: APIClient):
|
||||
employee=emp, department=d, is_active=True
|
||||
) # без роли
|
||||
|
||||
url = reverse("api:api_v1:departments-set-head", args=[d.pk])
|
||||
url = reverse("api:v1:departments-set-head", args=[d.pk])
|
||||
assert (
|
||||
api_client.post(url, {"head_id": candidate.id}, format="json").status_code
|
||||
== status.HTTP_403_FORBIDDEN
|
||||
@ -440,7 +440,7 @@ def test_set_member_role_happy_path(api_client: APIClient):
|
||||
|
||||
r_worker = make_role(d, "worker", [])
|
||||
|
||||
url = reverse("api:api_v1:departments-set-member-role", args=[d.pk])
|
||||
url = reverse("api:v1:departments-set-member-role", args=[d.pk])
|
||||
payload = {"employee_id": emp.id, "role_id": r_worker.id, "is_active": True}
|
||||
resp = api_client.post(url, payload, format="json")
|
||||
assert resp.status_code == 200
|
||||
@ -466,7 +466,7 @@ def test_set_member_role_reject_foreign_role(api_client: APIClient):
|
||||
|
||||
foreign_role = make_role(d2, "other", [])
|
||||
|
||||
url = reverse("api:api_v1:departments-set-member-role", args=[d1.pk])
|
||||
url = reverse("api:v1:departments-set-member-role", args=[d1.pk])
|
||||
resp = api_client.post(
|
||||
url, {"employee_id": emp.id, "role_id": foreign_role.id}, format="json"
|
||||
)
|
||||
@ -485,7 +485,7 @@ def test_set_member_role_requires_perm(api_client: APIClient):
|
||||
EmployeeDepartment.objects.create(employee=emp, department=d, is_active=True)
|
||||
role = make_role(d, "worker", [])
|
||||
|
||||
url = reverse("api:api_v1:departments-set-member-role", args=[d.pk])
|
||||
url = reverse("api:v1:departments-set-member-role", args=[d.pk])
|
||||
resp = api_client.post(
|
||||
url, {"employee_id": emp.id, "role_id": role.id}, format="json"
|
||||
)
|
||||
@ -501,6 +501,6 @@ def test_set_member_role_missing_employee_id(api_client: APIClient):
|
||||
employee=manager, department=d, is_active=True, role=r_assign
|
||||
)
|
||||
|
||||
url = reverse("api:api_v1:departments-set-member-role", args=[d.pk])
|
||||
url = reverse("api:v1:departments-set-member-role", args=[d.pk])
|
||||
resp = api_client.post(url, {"role_id": None}, format="json")
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@ -63,7 +63,7 @@ def _any_non_dismissed():
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_requires_auth_and_filtering(api_client):
|
||||
url = reverse("api:api_v1:employee-actions-list")
|
||||
url = reverse("api:v1:employee-actions-list")
|
||||
# unauth
|
||||
assert api_client.get(url).status_code in (401, 403)
|
||||
|
||||
@ -100,7 +100,7 @@ def test_create_requires_perm_or_staff(api_client):
|
||||
actor = _user()
|
||||
target = _user()
|
||||
api_client.force_authenticate(user=actor)
|
||||
url = reverse("api:api_v1:employee-actions-list")
|
||||
url = reverse("api:v1:employee-actions-list")
|
||||
payload = {
|
||||
"employee": target.id,
|
||||
"action": _any_non_dismissed(),
|
||||
@ -137,7 +137,7 @@ def test_dismissal_deactivates_employee_and_links(api_client):
|
||||
dept = Department.objects.create(name="D")
|
||||
EmployeeDepartment.objects.create(employee=emp, department=dept, is_active=True)
|
||||
|
||||
url = reverse("api:api_v1:employee-actions-list")
|
||||
url = reverse("api:v1:employee-actions-list")
|
||||
resp = api_client.post(
|
||||
url,
|
||||
{
|
||||
@ -163,7 +163,7 @@ def test_non_dismissal_activates_employee(api_client):
|
||||
emp.is_active = False
|
||||
emp.save(update_fields=["is_active"])
|
||||
|
||||
url = reverse("api:api_v1:employee-actions-list")
|
||||
url = reverse("api:v1:employee-actions-list")
|
||||
resp = api_client.post(
|
||||
url,
|
||||
{
|
||||
@ -190,7 +190,7 @@ def test_update_to_dismissed_applies_effects(api_client):
|
||||
employee=emp, action=_any_non_dismissed(), date=timezone.now()
|
||||
)
|
||||
|
||||
url_det = reverse("api:api_v1:employee-actions-detail", args=[act.id])
|
||||
url_det = reverse("api:v1:employee-actions-detail", args=[act.id])
|
||||
resp = api_client.patch(url_det, {"action": ACTION_DISMISSED}, format="json")
|
||||
assert resp.status_code == 200
|
||||
emp.refresh_from_db()
|
||||
@ -205,7 +205,7 @@ def test_delete_requires_perm_or_staff(api_client):
|
||||
act = EmployeeAction.objects.create(
|
||||
employee=emp, action=_any_non_dismissed(), date=timezone.now()
|
||||
)
|
||||
url_det = reverse("api:api_v1:employee-actions-detail", args=[act.id])
|
||||
url_det = reverse("api:v1:employee-actions-detail", args=[act.id])
|
||||
|
||||
# no perm -> 403
|
||||
assert api_client.delete(url_det).status_code == 403
|
||||
|
||||
@ -75,7 +75,7 @@ def extract_results(data):
|
||||
# ---------- basic auth / list / retrieve ----------
|
||||
|
||||
def test_list_requires_auth(api_client: APIClient):
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
resp = api_client.get(url)
|
||||
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@ -86,7 +86,7 @@ def test_list_ok_for_authenticated(api_client: APIClient):
|
||||
e2 = make_user("b@example.com")
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
resp = api_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
|
||||
@ -97,7 +97,7 @@ def test_list_ok_for_authenticated(api_client: APIClient):
|
||||
|
||||
def test_retrieve_requires_auth(api_client: APIClient):
|
||||
e = make_user("x@example.com")
|
||||
url = reverse("api:api_v1:employees-detail", args=[e.pk])
|
||||
url = reverse("api:v1:employees-detail", args=[e.pk])
|
||||
assert api_client.get(url).status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@ -106,7 +106,7 @@ def test_retrieve_ok(api_client: APIClient):
|
||||
e = make_user("x@example.com")
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("api:api_v1:employees-detail", args=[e.pk])
|
||||
url = reverse("api:v1:employees-detail", args=[e.pk])
|
||||
resp = api_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
data = resp.json()
|
||||
@ -124,7 +124,7 @@ def test_search_by_email_and_last_name(api_client: APIClient):
|
||||
b = make_user("beta@example.com", first_name="Bob", last_name="Beta")
|
||||
_ = make_user("gamma@example.com", first_name="Garry", last_name="Gamma")
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
|
||||
# поиск по части email
|
||||
r = api_client.get(url, {"search": "bet"})
|
||||
@ -161,7 +161,7 @@ def test_filter_by_department_includes_members_and_head(api_client: APIClient):
|
||||
d = Department.objects.create(name="Dept", head=head)
|
||||
EmployeeDepartment.objects.create(employee=member, department=d, is_active=True)
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
r = api_client.get(url, {"department": d.id})
|
||||
assert r.status_code == 200
|
||||
ids = {it["id"] for it in extract_results(r.json())}
|
||||
@ -186,7 +186,7 @@ def test_filter_by_position(api_client: APIClient):
|
||||
|
||||
_ = make_user("q1@example.com", position=p2)
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
r = api_client.get(url, {"position": p1.id})
|
||||
assert r.status_code == 200
|
||||
ids = {it["id"] for it in extract_results(r.json())}
|
||||
@ -208,7 +208,7 @@ def test_filter_by_skills_any_of(api_client: APIClient):
|
||||
|
||||
_ = make_user("e3@example.com") # без навыков
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
r = api_client.get(url, [("skill", s1.id), ("skill", s2.id)])
|
||||
assert r.status_code == 200
|
||||
ids = {it["id"] for it in extract_results(r.json())}
|
||||
@ -223,7 +223,7 @@ def test_filter_by_email_verified_and_active(api_client: APIClient):
|
||||
v0 = make_user("v0@example.com", verified=False, active=True)
|
||||
a0 = make_user("a0@example.com", verified=True, active=False)
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
|
||||
r = api_client.get(url, {"email_verified": "true"})
|
||||
ids = {it["id"] for it in extract_results(r.json())}
|
||||
@ -250,7 +250,7 @@ def test_filter_actually_active_logic(api_client: APIClient):
|
||||
# e_unver: email_verified=False, даже при active=True — не включается при actually_active=true
|
||||
e_unver = make_user("unver@example.com", verified=False, active=True)
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
|
||||
r = api_client.get(url, {"actually_active": "true"})
|
||||
ids = {it["id"] for it in extract_results(r.json())}
|
||||
@ -270,7 +270,7 @@ def test_create_requires_staff(api_client: APIClient):
|
||||
user = make_user("u@example.com")
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
payload = {
|
||||
"email": "new@example.com",
|
||||
"phone_number": _unique_phone(),
|
||||
@ -301,7 +301,7 @@ def test_create_requires_staff(api_client: APIClient):
|
||||
def test_partial_update_self_allowed(api_client: APIClient):
|
||||
e = make_user("self@example.com")
|
||||
api_client.force_authenticate(user=e)
|
||||
url = reverse("api:api_v1:employees-detail", args=[e.pk])
|
||||
url = reverse("api:v1:employees-detail", args=[e.pk])
|
||||
|
||||
resp = api_client.patch(url, {"first_name": "Me"}, format="json")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
@ -313,7 +313,7 @@ def test_partial_update_other_forbidden_for_regular_user(api_client: APIClient):
|
||||
owner = make_user("owner@example.com")
|
||||
other = make_user("other@example.com")
|
||||
api_client.force_authenticate(user=other)
|
||||
url = reverse("api:api_v1:employees-detail", args=[owner.pk])
|
||||
url = reverse("api:v1:employees-detail", args=[owner.pk])
|
||||
|
||||
resp = api_client.patch(url, {"first_name": "Hack"}, format="json")
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
@ -323,7 +323,7 @@ def test_partial_update_other_allowed_for_staff(api_client: APIClient):
|
||||
target = make_user("target@example.com")
|
||||
staff = make_user("admin@example.com", staff=True)
|
||||
api_client.force_authenticate(user=staff)
|
||||
url = reverse("api:api_v1:employees-detail", args=[target.pk])
|
||||
url = reverse("api:v1:employees-detail", args=[target.pk])
|
||||
|
||||
resp = api_client.patch(url, {"first_name": "Ok"}, format="json")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
@ -336,7 +336,7 @@ def test_destroy_requires_admin(api_client: APIClient):
|
||||
regular = make_user("regular@example.com")
|
||||
staff = make_user("staff@example.com", staff=True)
|
||||
|
||||
url = reverse("api:api_v1:employees-detail", args=[victim.pk])
|
||||
url = reverse("api:v1:employees-detail", args=[victim.pk])
|
||||
|
||||
api_client.force_authenticate(user=regular)
|
||||
assert api_client.delete(url).status_code in (
|
||||
@ -356,7 +356,7 @@ def test_me_get_returns_current_user(api_client: APIClient):
|
||||
me = make_user("me@example.com")
|
||||
api_client.force_authenticate(user=me)
|
||||
|
||||
url = reverse("api:api_v1:employees-me")
|
||||
url = reverse("api:v1:employees-me")
|
||||
r = api_client.get(url)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == me.id
|
||||
@ -366,7 +366,7 @@ def test_me_patch_updates_profile(api_client: APIClient):
|
||||
me = make_user("me@example.com", telegram="@me") # уже есть контакт
|
||||
api_client.force_authenticate(user=me)
|
||||
|
||||
url = reverse("api:api_v1:employees-me")
|
||||
url = reverse("api:v1:employees-me")
|
||||
r = api_client.patch(url, {"first_name": "Updated"}, format="json")
|
||||
assert r.status_code == 200
|
||||
me.refresh_from_db()
|
||||
@ -382,6 +382,6 @@ def test_me_patch_rejects_when_all_contacts_empty(api_client: APIClient):
|
||||
me = make_user("nocc@example.com", telegram="", whatsapp="", wechat="")
|
||||
api_client.force_authenticate(user=me)
|
||||
|
||||
url = reverse("api:api_v1:employees-me")
|
||||
url = reverse("api:v1:employees-me")
|
||||
r = api_client.patch(url, {"first_name": "Try"}, format="json")
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@ -83,7 +83,7 @@ def test_list_includes_basic_fields(api):
|
||||
"emp@example.com", first_name="Ivan", last_name="Ivanov", position=pos
|
||||
)
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
r = api.get(url)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
items = extract_results(r.json())
|
||||
@ -127,7 +127,7 @@ def test_list_departments_include_role(api):
|
||||
employee=emp, department=dept2, is_active=True
|
||||
) # без роли
|
||||
|
||||
url = reverse("api:api_v1:employees-list")
|
||||
url = reverse("api:v1:employees-list")
|
||||
r = api.get(url)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
items = extract_results(r.json())
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
from itertools import count
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.urls import reverse
|
||||
from employees.models import Employee, Position
|
||||
@ -16,18 +17,19 @@ def _unique_phone() -> str:
|
||||
return f"+7999{next(_phone_seq):07d}"
|
||||
|
||||
|
||||
def _grant_perm(user: Employee, perm_code: str):
|
||||
"""
|
||||
perm_code формат: 'app_label.codename', например 'employees.add_position'
|
||||
"""
|
||||
try:
|
||||
app_label, codename = perm_code.split(".", 1)
|
||||
except ValueError:
|
||||
raise AssertionError("perm_code must be 'app_label.codename'")
|
||||
def _grant_perm(user, code: str):
|
||||
app_label, codename = code.split(".")
|
||||
perm = Permission.objects.get(content_type__app_label=app_label, codename=codename)
|
||||
user.user_permissions.add(perm)
|
||||
user.save()
|
||||
return perm
|
||||
|
||||
# Сброс кэшей пермишенов у этого инстанса
|
||||
for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
|
||||
if hasattr(user, attr):
|
||||
delattr(user, attr)
|
||||
|
||||
# Перечитать из БД (на всякий случай)
|
||||
User = get_user_model()
|
||||
return User.objects.get(pk=user.pk)
|
||||
|
||||
|
||||
def _make_user(email: str, staff: bool = False, superuser: bool = False) -> Employee:
|
||||
@ -53,7 +55,7 @@ def _make_user(email: str, staff: bool = False, superuser: bool = False) -> Empl
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_requires_auth(api_client):
|
||||
url = reverse("api:api_v1:positions-list")
|
||||
url = reverse("api:v1:positions-list")
|
||||
|
||||
# unauth -> 401
|
||||
resp = api_client.get(url)
|
||||
@ -74,7 +76,7 @@ def test_list_requires_auth(api_client):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_permissions(api_client):
|
||||
url = reverse("api:api_v1:positions-list")
|
||||
url = reverse("api:v1:positions-list")
|
||||
|
||||
# обычный пользователь без прав -> 403
|
||||
user = _make_user("user@example.com")
|
||||
@ -104,7 +106,7 @@ def test_update_permissions_and_search_ordering(api_client):
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
# без прав change_position -> 403
|
||||
url_detail = reverse("api:api_v1:positions-detail", args=[p1.id])
|
||||
url_detail = reverse("api:v1:positions-detail", args=[p1.id])
|
||||
resp = api_client.patch(url_detail, {"description": "updated"}, format="json")
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@ -116,7 +118,7 @@ def test_update_permissions_and_search_ordering(api_client):
|
||||
assert p1.description == "updated"
|
||||
|
||||
# проверим search & ordering
|
||||
url_list = reverse("api:api_v1:positions-list")
|
||||
url_list = reverse("api:v1:positions-list")
|
||||
resp = api_client.get(url_list, {"search": "alp"})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.data) == 1
|
||||
@ -133,7 +135,7 @@ def test_delete_permissions(api_client):
|
||||
pos = Position.objects.create(name="ToDelete")
|
||||
user = _make_user("user@example.com")
|
||||
api_client.force_authenticate(user=user)
|
||||
url_detail = reverse("api:api_v1:positions-detail", args=[pos.id])
|
||||
url_detail = reverse("api:v1:positions-detail", args=[pos.id])
|
||||
|
||||
# без delete_position -> 403
|
||||
resp = api_client.delete(url_detail)
|
||||
@ -155,7 +157,7 @@ def test_set_groups_requires_special_perm(api_client):
|
||||
user = _make_user("user@example.com")
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
url_set = reverse("api:api_v1:positions-set-groups", args=[pos.id])
|
||||
url_set = reverse("api:v1:positions-set-groups", args=[pos.id])
|
||||
|
||||
# даже с change_position НЕЛЬЗЯ менять группы — нужен assign_position_groups
|
||||
_grant_perm(user, "employees.change_position")
|
||||
@ -192,7 +194,7 @@ def test_add_and_remove_groups_and_permissions_endpoint(api_client):
|
||||
_grant_perm(user, "employees.assign_position_groups")
|
||||
|
||||
# add_groups
|
||||
url_add = reverse("api:api_v1:positions-add-groups", args=[pos.id])
|
||||
url_add = reverse("api:v1:positions-add-groups", args=[pos.id])
|
||||
resp = api_client.post(url_add, {"groups": [gA.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
assert set(pos.groups.values_list("id", flat=True)) == {gA.id}
|
||||
@ -203,7 +205,7 @@ def test_add_and_remove_groups_and_permissions_endpoint(api_client):
|
||||
assert set(pos.groups.values_list("id", flat=True)) == {gA.id, gB.id}
|
||||
|
||||
# permissions endpoint
|
||||
url_perms = reverse("api:api_v1:positions-permissions", args=[pos.id])
|
||||
url_perms = reverse("api:v1:positions-permissions", args=[pos.id])
|
||||
resp = api_client.get(url_perms)
|
||||
assert resp.status_code == 200
|
||||
codes = {row["codename"] for row in resp.data["results"]}
|
||||
@ -211,13 +213,13 @@ def test_add_and_remove_groups_and_permissions_endpoint(api_client):
|
||||
assert "auth.view_group" in codes
|
||||
|
||||
# remove_groups (уберём A)
|
||||
url_rm = reverse("api:api_v1:positions-remove-groups", args=[pos.id])
|
||||
url_rm = reverse("api:v1:positions-remove-groups", args=[pos.id])
|
||||
resp = api_client.post(url_rm, {"groups": [gA.id]}, format="json")
|
||||
assert resp.status_code == 200
|
||||
assert set(pos.groups.values_list("id", flat=True)) == {gB.id}
|
||||
|
||||
# retrieve содержит groups_verbose
|
||||
url_detail = reverse("api:api_v1:positions-detail", args=[pos.id])
|
||||
url_detail = reverse("api:v1:positions-detail", args=[pos.id])
|
||||
resp = api_client.get(url_detail)
|
||||
assert resp.status_code == 200
|
||||
assert "groups_verbose" in resp.data
|
||||
@ -231,7 +233,7 @@ def test_groups_payload_validation(api_client):
|
||||
api_client.force_authenticate(user=user)
|
||||
_grant_perm(user, "employees.assign_position_groups")
|
||||
|
||||
url_set = reverse("api:api_v1:positions-set-groups", args=[pos.id])
|
||||
url_set = reverse("api:v1:positions-set-groups", args=[pos.id])
|
||||
|
||||
# groups не список -> 400
|
||||
resp = api_client.post(url_set, {"groups": "not-a-list"}, format="json")
|
||||
|
||||
@ -57,7 +57,7 @@ def _grant_perm(user: Employee, perm_code: str):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_search_ordering_requires_auth(api_client):
|
||||
url = reverse("api:api_v1:skills-list")
|
||||
url = reverse("api:v1:skills-list")
|
||||
# unauth
|
||||
resp = api_client.get(url)
|
||||
assert resp.status_code in (401, 403)
|
||||
@ -89,7 +89,7 @@ def test_list_search_ordering_requires_auth(api_client):
|
||||
def test_create_allowed_for_regular_user(api_client):
|
||||
user = _mk_user()
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse("api:api_v1:skills-list")
|
||||
url = reverse("api:v1:skills-list")
|
||||
resp = api_client.post(url, {"name": "Go"}, format="json")
|
||||
assert resp.status_code == 201
|
||||
assert Skill.objects.filter(name="Go").exists()
|
||||
@ -102,7 +102,7 @@ def test_update_delete_permissions(api_client):
|
||||
s = Skill.objects.create(name="Docker")
|
||||
|
||||
# update -> 403
|
||||
url_det = reverse("api:api_v1:skills-detail", args=[s.id])
|
||||
url_det = reverse("api:v1:skills-detail", args=[s.id])
|
||||
resp = api_client.patch(url_det, {"name": "Docker+"}, format="json")
|
||||
assert resp.status_code == 403
|
||||
|
||||
@ -129,7 +129,7 @@ def test_staff_bypasses_permissions(api_client):
|
||||
staff = _mk_user(staff=True)
|
||||
api_client.force_authenticate(user=staff)
|
||||
s = Skill.objects.create(name="K8s")
|
||||
url_det = reverse("api:api_v1:skills-detail", args=[s.id])
|
||||
url_det = reverse("api:v1:skills-detail", args=[s.id])
|
||||
assert (
|
||||
api_client.patch(url_det, {"name": "Kubernetes"}, format="json").status_code
|
||||
== 200
|
||||
@ -141,7 +141,7 @@ def test_staff_bypasses_permissions(api_client):
|
||||
def test_bulk_create_by_regular_user(api_client):
|
||||
user = _mk_user()
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse("api:api_v1:skills-bulk-create")
|
||||
url = reverse("api:v1:skills-bulk-create")
|
||||
|
||||
resp = api_client.post(
|
||||
url, {"names": [" Python ", "python", "SQL", ""]}, format="json"
|
||||
@ -166,7 +166,7 @@ def test_merge_requires_perm_and_reassigns(api_client):
|
||||
e1.skills.add(src)
|
||||
e2.skills.add(src)
|
||||
|
||||
url = reverse("api:api_v1:skills-merge")
|
||||
url = reverse("api:v1:skills-merge")
|
||||
|
||||
# без прав -> 403
|
||||
resp = api_client.post(
|
||||
@ -195,7 +195,7 @@ def test_merge_same_id_and_invalid_ids(api_client):
|
||||
_grant_perm(user, "employees.change_skill")
|
||||
s = Skill.objects.create(name="A")
|
||||
|
||||
url = reverse("api:api_v1:skills-merge")
|
||||
url = reverse("api:v1:skills-merge")
|
||||
# same id
|
||||
resp = api_client.post(url, {"source_id": s.id, "target_id": s.id}, format="json")
|
||||
assert resp.status_code == 400
|
||||
@ -216,7 +216,7 @@ def test_merge_without_reassign_or_delete(api_client):
|
||||
e = _mk_user()
|
||||
e.skills.add(src)
|
||||
|
||||
url = reverse("api:api_v1:skills-merge")
|
||||
url = reverse("api:v1:skills-merge")
|
||||
resp = api_client.post(
|
||||
url,
|
||||
{
|
||||
@ -248,7 +248,7 @@ def test_merge_idempotent_if_employee_already_has_target(api_client):
|
||||
e = _mk_user()
|
||||
e.skills.add(src, dst)
|
||||
|
||||
url = reverse("api:api_v1:skills-merge")
|
||||
url = reverse("api:v1:skills-merge")
|
||||
resp = api_client.post(
|
||||
url, {"source_id": src.id, "target_id": dst.id}, format="json"
|
||||
)
|
||||
|
||||
@ -53,7 +53,7 @@ def _items(resp):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_requires_auth_and_filters_ordering(api_client):
|
||||
url = reverse("api:api_v1:comments-list")
|
||||
url = reverse("api:v1:comments-list")
|
||||
assert api_client.get(url).status_code in (401, 403)
|
||||
|
||||
author = _user()
|
||||
@ -88,7 +88,7 @@ def test_create_text_only_and_files(api_client):
|
||||
api_client.force_authenticate(user=user)
|
||||
p = Post.objects.create(author=user, type=TYPE_COMPANY, title="news", body="b")
|
||||
|
||||
url = reverse("api:api_v1:comments-list")
|
||||
url = reverse("api:v1:comments-list")
|
||||
|
||||
# text only
|
||||
resp = api_client.post(url, {"post": p.id, "text": "hi"}, format="multipart")
|
||||
@ -112,7 +112,7 @@ def test_create_text_only_and_files(api_client):
|
||||
def test_create_empty_payload_is_400(api_client):
|
||||
user = _user(); api_client.force_authenticate(user=user)
|
||||
p = Post.objects.create(author=user, type=TYPE_COMPANY, title="n", body="b")
|
||||
url = reverse("api:api_v1:comments-list")
|
||||
url = reverse("api:v1:comments-list")
|
||||
|
||||
resp = api_client.post(url, {"post": p.id, "text": ""}, format="multipart")
|
||||
assert resp.status_code == 400
|
||||
@ -128,7 +128,7 @@ def test_update_permissions_and_validation(api_client):
|
||||
p = Post.objects.create(author=author, type=TYPE_COMPANY, title="n", body="b")
|
||||
c = Comment.objects.create(post=p, author=author, text="orig")
|
||||
|
||||
url_detail = reverse("api:api_v1:comments-detail", args=[c.id])
|
||||
url_detail = reverse("api:v1:comments-detail", args=[c.id])
|
||||
|
||||
# не автор -> 403
|
||||
api_client.force_authenticate(user=other)
|
||||
@ -156,7 +156,7 @@ def test_delete_permissions(api_client):
|
||||
author = _user(); staff = _user(staff=True); other = _user()
|
||||
p = Post.objects.create(author=author, type=TYPE_COMPANY, title="n", body="b")
|
||||
c = Comment.objects.create(post=p, author=author, text="delme")
|
||||
url_detail = reverse("api:api_v1:comments-detail", args=[c.id])
|
||||
url_detail = reverse("api:v1:comments-detail", args=[c.id])
|
||||
|
||||
# другой пользователь -> 403
|
||||
api_client.force_authenticate(user=other)
|
||||
@ -169,4 +169,4 @@ def test_delete_permissions(api_client):
|
||||
# staff тоже может (создадим новый)
|
||||
c2 = Comment.objects.create(post=p, author=author, text="del2")
|
||||
api_client.force_authenticate(user=staff)
|
||||
assert api_client.delete(reverse("api:api_v1:comments-detail", args=[c2.id])).status_code == 204
|
||||
assert api_client.delete(reverse("api:v1:comments-detail", args=[c2.id])).status_code == 204
|
||||
|
||||
@ -77,7 +77,7 @@ def _items(resp):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_requires_auth(api_client):
|
||||
url = reverse("api:api_v1:posts-list")
|
||||
url = reverse("api:v1:posts-list")
|
||||
assert api_client.get(url).status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
user = _user()
|
||||
@ -87,7 +87,7 @@ def test_list_requires_auth(api_client):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_company_requires_staff_or_perm(api_client):
|
||||
url = reverse("api:api_v1:posts-list")
|
||||
url = reverse("api:v1:posts-list")
|
||||
author = _user()
|
||||
api_client.force_authenticate(user=author)
|
||||
|
||||
@ -118,7 +118,7 @@ def test_create_company_requires_staff_or_perm(api_client):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_department_requires_head_or_role_or_staff(api_client):
|
||||
url = reverse("api:api_v1:posts-list")
|
||||
url = reverse("api:v1:posts-list")
|
||||
dept = Department.objects.create(name="R&D")
|
||||
|
||||
u = _user()
|
||||
@ -159,7 +159,7 @@ def test_create_department_requires_head_or_role_or_staff(api_client):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_employee_type_is_forbidden_and_department_field_validation(api_client):
|
||||
url = reverse("api:api_v1:posts-list")
|
||||
url = reverse("api:v1:posts-list")
|
||||
staff = _user(staff=True)
|
||||
api_client.force_authenticate(user=staff)
|
||||
dept = Department.objects.create(name="Ops")
|
||||
@ -190,7 +190,7 @@ def test_update_and_delete_permissions_match_create_rules(api_client):
|
||||
|
||||
# создаём пост отдела от лица главы
|
||||
api_client.force_authenticate(user=head)
|
||||
url = reverse("api:api_v1:posts-list")
|
||||
url = reverse("api:v1:posts-list")
|
||||
resp = api_client.post(
|
||||
url,
|
||||
{"type": TYPE_DEPARTMENT, "title": "t1", "body": "b1", "department": dept.id},
|
||||
@ -199,7 +199,7 @@ def test_update_and_delete_permissions_match_create_rules(api_client):
|
||||
assert resp.status_code == status.HTTP_201_CREATED
|
||||
post_id = resp.data["id"]
|
||||
|
||||
url_detail = reverse("api:api_v1:posts-detail", args=[post_id])
|
||||
url_detail = reverse("api:v1:posts-detail", args=[post_id])
|
||||
|
||||
# другой обычный пользователь не может править/удалять
|
||||
stranger = _user()
|
||||
@ -224,7 +224,7 @@ def test_list_by_author_shows_all_authors_posts(api_client):
|
||||
_grant_dept_role_perm(author, dept, "feed.publish_department_post")
|
||||
|
||||
api_client.force_authenticate(user=author)
|
||||
url = reverse("api:api_v1:posts-list")
|
||||
url = reverse("api:v1:posts-list")
|
||||
|
||||
# company пост
|
||||
r1 = api_client.post(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user