мелкие доработки тестов и шаблонов

This commit is contained in:
admin i 2025-08-29 17:03:18 +03:00
parent c78d1e5fea
commit da183c8fbd
32 changed files with 1143 additions and 704 deletions

View File

@ -6,3 +6,5 @@ from .serializers import PhoneOrEmailTokenObtainPairSerializer
class PhoneOrEmailTokenObtainPairView(TokenObtainPairView):
serializer_class = PhoneOrEmailTokenObtainPairSerializer

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 = [
('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': 'События отделов'},
),
]

View File

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

View File

@ -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': 'Должности'},
),
]

View 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': 'Сотрудники'},
),
]

View File

@ -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 = "Роль в отделе"

View File

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

View File

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

View File

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

View File

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

View File

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

View 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': 'Публикации'},
),
]

View 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 = [
('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': 'Заявления'},
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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