Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-27 10:41:55 +03:00
commit 485ff2424d
21 changed files with 515 additions and 261 deletions

View File

@ -49,6 +49,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
)
position = PositionBriefSerializer(read_only=True)
positions = serializers.SerializerMethodField()
position_id = serializers.PrimaryKeyRelatedField(
source="position",
queryset=Position.objects.all(),
@ -56,6 +57,12 @@ class EmployeeSerializer(serializers.ModelSerializer):
required=False,
allow_null=True,
)
position_ids = serializers.PrimaryKeyRelatedField(
queryset=Position.objects.all(),
many=True,
write_only=True,
required=False,
)
departments = serializers.SerializerMethodField()
auth = serializers.SerializerMethodField(read_only=True)
@ -80,7 +87,9 @@ class EmployeeSerializer(serializers.ModelSerializer):
"phone_number",
"birth_date",
"position",
"positions",
"position_id",
"position_ids",
"telegram",
"whatsapp",
"wechat",
@ -129,6 +138,10 @@ class EmployeeSerializer(serializers.ModelSerializer):
def get_skills(self, obj):
return serialize_employee_skills(obj, context=self.context)
@extend_schema_field(PositionBriefSerializer(many=True))
def get_positions(self, obj):
return PositionBriefSerializer(obj.get_positions(), many=True).data
def validate_attendance_aliases(self, value):
result = []
for alias in value or []:
@ -139,16 +152,44 @@ class EmployeeSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
skills = validated_data.pop("skills_ids", None)
positions = validated_data.pop("position_ids", None)
if positions is not None:
unique_positions = list(dict.fromkeys(positions))
validated_data["position"] = (
unique_positions[0] if unique_positions else None
)
else:
unique_positions = None
if "avatar" in validated_data and validated_data["avatar"] is None:
validated_data.pop("avatar")
instance = super().update(instance, validated_data)
if unique_positions is not None:
instance.additional_positions.set(unique_positions[1:])
elif "position" in validated_data:
instance.additional_positions.clear()
if skills is not None:
secret_skills = list(instance.skills.filter(is_secret=True))
instance.skills.set([*skills, *secret_skills])
return instance
def create(self, validated_data):
positions = validated_data.pop("position_ids", None)
if positions is not None:
unique_positions = list(dict.fromkeys(positions))
validated_data["position"] = (
unique_positions[0] if unique_positions else None
)
else:
unique_positions = None
instance = super().create(validated_data)
if unique_positions is not None:
instance.additional_positions.set(unique_positions[1:])
return instance
@extend_schema_field(EmployeeDepartmentSerializer(many=True))
def get_departments(self, obj):
from employees.models import RoleAssignment

View File

@ -31,6 +31,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
phone_number = serializers.CharField(read_only=True)
avatar = serializers.ImageField(read_only=True)
position = PositionBriefSerializer(read_only=True)
positions = serializers.SerializerMethodField()
departments = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
skills = serializers.SerializerMethodField()
@ -51,6 +52,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
"full_name",
"avatar",
"position",
"positions",
"departments",
"skills",
"created_at",
@ -68,6 +70,10 @@ class EmployeeListSerializer(serializers.ModelSerializer):
def get_skills(self, obj):
return serialize_employee_skills(obj, context=self.context)
@extend_schema_field(PositionBriefSerializer(many=True))
def get_positions(self, obj):
return PositionBriefSerializer(obj.get_positions(), many=True).data
@extend_schema_field(EmployeeAttendanceStatusSerializer(allow_null=True))
def get_attendance_status(self, obj):
return _employee_attendance_status_payload(obj)

View File

@ -72,6 +72,7 @@ class EmployeeQueryMixin:
return (
Employee.objects.select_related("position")
.prefetch_related(
"additional_positions",
Prefetch(
"skills",
queryset=skill_queryset_with_rarity().order_by("name"),
@ -162,7 +163,9 @@ class EmployeeQueryMixin:
position = query_params.get("position")
if position:
queryset = queryset.filter(position_id=position)
queryset = queryset.filter(
Q(position_id=position) | Q(additional_positions__id=position)
).distinct()
skill_ids = query_params.getlist("skill")
if skill_ids:

View File

@ -8,6 +8,8 @@ https://github.com/dfunckt/django-rules
import rules
from employees.positions import employee_position_search_text
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
@ -72,10 +74,7 @@ def can_manage_documents(user):
Пользователь может управлять документами.
Адаптируйте под вашу логику (по должности, группе и т.д.)
"""
if not hasattr(user, "position"):
return False
position_name = getattr(user.position, "name", "").lower()
position_name = employee_position_search_text(user)
return any(
keyword in position_name
for keyword in [

View File

@ -302,6 +302,7 @@ class EmployeeChangeForm(forms.ModelForm):
"whatsapp",
"wechat",
"position",
"additional_positions",
"skills",
"is_active",
"is_hidden_in_ui",
@ -400,7 +401,12 @@ class EmployeeAdmin(DjangoUserAdmin):
ordering = ("last_name", "first_name")
list_select_related = ("position",)
filter_horizontal = ("groups", "user_permissions", "skills")
filter_horizontal = (
"groups",
"user_permissions",
"skills",
"additional_positions",
)
readonly_fields = (
"last_login",
@ -551,7 +557,10 @@ class EmployeeAdmin(DjangoUserAdmin):
"Контакты",
{"fields": ("phone_number", "telegram", "whatsapp", "wechat")},
),
("Работа", {"fields": ("position", "skills")}),
(
"Работа",
{"fields": ("position", "additional_positions", "skills")},
),
(
"Права и статусы",
{

View File

@ -0,0 +1,32 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("employees", "0061_secretskillsettings"),
]
operations = [
migrations.AlterField(
model_name="employee",
name="position",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="employees",
to="employees.position",
verbose_name="Основная должность",
),
),
migrations.AddField(
model_name="employee",
name="additional_positions",
field=models.ManyToManyField(
blank=True,
related_name="additional_employees",
to="employees.position",
verbose_name="Дополнительные должности",
),
),
]

View File

@ -216,7 +216,13 @@ class Employee(AbstractUser):
null=True,
blank=True,
related_name="employees",
verbose_name="Должность",
verbose_name="Основная должность",
)
additional_positions = models.ManyToManyField(
Position,
blank=True,
related_name="additional_employees",
verbose_name="Дополнительные должности",
)
email = models.EmailField("email address", unique=True)
telegram = models.CharField(
@ -297,6 +303,19 @@ class Employee(AbstractUser):
def __str__(self):
return f"{self.last_name} {self.first_name}"
def get_positions(self):
"""Все должности сотрудника, начиная с основной."""
positions = []
seen_ids = set()
if self.position_id:
positions.append(self.position)
seen_ids.add(self.position_id)
for position in self.additional_positions.all():
if position.id not in seen_ids:
positions.append(position)
seen_ids.add(position.id)
return positions
@property
def employment_status(self):
from employees.services.personnel_state import (

View File

@ -0,0 +1,9 @@
def employee_position_search_text(employee) -> str:
"""Нормализованные названия всех должностей сотрудника для правил доступа."""
names = []
primary = getattr(employee, "position", None)
if primary is not None and primary.name:
names.append(primary.name)
if getattr(employee, "pk", None):
names.extend(employee.additional_positions.values_list("name", flat=True))
return " ".join(names).lower()

View File

@ -7,6 +7,8 @@ https://github.com/dfunckt/django-rules
import rules
from employees.positions import employee_position_search_text
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
@ -31,9 +33,7 @@ def is_hr_staff(user):
HR сотрудник - определяется по должности или группе.
Адаптируйте под вашу логику: по position, department, группе и т.д.
"""
if not hasattr(user, "position"):
return False
position_name = getattr(user.position, "name", "").lower()
position_name = employee_position_search_text(user)
return "hr" in position_name or "кадр" in position_name
@ -43,9 +43,7 @@ def is_department_head(user):
Руководитель отдела - определяется по должности.
Адаптируйте под вашу логику.
"""
if not hasattr(user, "position"):
return False
position_name = getattr(user.position, "name", "").lower()
position_name = employee_position_search_text(user)
return any(
keyword in position_name
for keyword in [

View File

@ -6,7 +6,7 @@
import logging
from django.conf import settings
from django.db.models.signals import post_delete, post_save
from django.db.models.signals import m2m_changed, post_delete, post_save
from django.dispatch import receiver
from employees.ldap import UserService
@ -15,17 +15,81 @@ from employees.ldap.errors import (
DirectoryLdapError,
DirectoryServiceError,
)
from employees.models import LdapSyncState
from employees.models import Employee, LdapSyncState, Position
from employees.signals.ldap._queue import _enqueue
logger = logging.getLogger(__name__)
def _additional_position_ids_for_action(instance, action, pk_set):
if action == "pre_clear":
instance._cleared_additional_position_ids = set(
instance.additional_positions.values_list("pk", flat=True)
)
return None
if action not in {"post_add", "post_remove", "post_clear"}:
return None
if action == "post_clear":
return getattr(instance, "_cleared_additional_position_ids", set())
return set(pk_set or ())
def _sync_additional_position_groups(instance, action, position_ids):
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.position_service import PositionService
service = PositionService(
group_service=GroupService(),
user_service=UserService(),
)
for position in Position.objects.filter(pk__in=position_ids):
if action == "post_add":
service.assign_position(instance, position)
elif position.pk != instance.position_id:
service.unassign_position(instance, position)
def _is_ldap_enabled():
"""Проверяет, включена ли интеграция с LDAP."""
return getattr(settings, "LDAP_ENABLED", False)
@receiver(m2m_changed, sender=Employee.additional_positions.through)
def sync_additional_positions_to_ldap(
sender,
instance,
action,
reverse,
model,
pk_set,
**kwargs,
):
"""Синхронизирует дополнительные POS-группы сотрудника с LDAP."""
if reverse or not instance.is_ldap_managed or not _is_ldap_enabled():
return
affected_ids = _additional_position_ids_for_action(instance, action, pk_set)
if not affected_ids:
return
try:
_sync_additional_position_groups(instance, action, affected_ids)
except (DirectoryLdapError, DirectoryServiceError, DirectoryDbError) as error:
logger.warning(
"LDAP additional position group sync failed",
exc_info=True,
extra={
"event": "ldap.employee.additional_positions_sync_failed",
"employee_id": instance.pk,
"position_ids": sorted(affected_ids),
"action": action,
"error_type": type(error).__name__,
},
)
finally:
if hasattr(instance, "_cleared_additional_position_ids"):
delattr(instance, "_cleared_additional_position_ids")
@receiver(post_save, sender="employees.Employee")
def sync_employee_to_ldap_on_save(sender, instance, created, **kwargs):
"""Синхронизирует изменения Employee с существующим LDAP пользователем.

View File

@ -793,10 +793,17 @@ class PositionRoleBackend(ModelBackend):
return perms
# 1) Глобальные права от должности — оставить
position_ids = set()
pos = getattr(user_obj, "position", None)
if pos:
position_ids.add(pos.pk)
if getattr(user_obj, "pk", None):
position_ids.update(
user_obj.additional_positions.values_list("pk", flat=True)
)
if position_ids:
pos_perms = (
Permission.objects.filter(group__positions=pos)
Permission.objects.filter(group__positions__id__in=position_ids)
.select_related("content_type")
.distinct()
)

View File

@ -7,6 +7,8 @@ https://github.com/dfunckt/django-rules
import rules
from employees.positions import employee_position_search_text
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
@ -34,10 +36,7 @@ def can_publish_posts(user):
Пользователь может публиковать посты в ленте.
Адаптируйте под вашу логику (по должности, группе и т.д.)
"""
if not hasattr(user, "position"):
return False
position_name = getattr(user.position, "name", "").lower()
position_name = employee_position_search_text(user)
# Руководители, PR-менеджеры, секретари могут публиковать
return any(

View File

@ -7,6 +7,8 @@ https://github.com/dfunckt/django-rules
import rules
from employees.positions import employee_position_search_text
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
@ -25,10 +27,7 @@ def is_procurement_manager(user):
Пользователь является менеджером по закупкам.
Адаптируйте под вашу логику (по должности, группе и т.д.)
"""
if not hasattr(user, "position"):
return False
position_name = getattr(user.position, "name", "").lower()
position_name = employee_position_search_text(user)
return any(
keyword in position_name
for keyword in ["закупк", "снабжен", "procurement", "supply"]
@ -88,10 +87,7 @@ def can_approve_purchases(user):
Пользователь может согласовывать закупки.
Адаптируйте под вашу логику (по должности, бюджетным лимитам и т.д.)
"""
if not hasattr(user, "position"):
return False
position_name = getattr(user.position, "name", "").lower()
position_name = employee_position_search_text(user)
return any(
keyword in position_name
for keyword in ["руководитель", "начальник", "директор", "финансов"]

View File

@ -7,6 +7,8 @@ https://github.com/dfunckt/django-rules
import rules
from employees.positions import employee_position_search_text
# -----------------------------------------------------------------------------
# ПРЕДИКАТЫ (predicates)
@ -67,10 +69,7 @@ def can_manage_requests(user):
Пользователь может управлять заявками (по должности или роли).
Адаптируйте под вашу логику.
"""
if not hasattr(user, "position"):
return False
position_name = getattr(user.position, "name", "").lower()
position_name = employee_position_search_text(user)
return any(
keyword in position_name
for keyword in [

View File

@ -34,16 +34,20 @@ User = get_user_model()
# ---------- fixtures / helpers ----------
@pytest.fixture
def api_client():
return APIClient()
_phone_seq = itertools.count(2000)
def _unique_phone() -> str:
# валидный уникальный E.164
return f"+7999000{next(_phone_seq):03d}"
def make_user(
email: str,
*,
@ -68,18 +72,22 @@ def make_user(
u.save()
return u
def extract_results(data):
if isinstance(data, dict) and "results" in data:
return data["results"]
return data
# ---------- basic auth / list / retrieve ----------
def test_list_requires_auth(api_client: APIClient):
url = reverse("api:v1:employees-list")
resp = api_client.get(url)
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_ok_for_authenticated(api_client: APIClient):
user = make_user("u@example.com")
e1 = make_user("a@example.com")
@ -108,9 +116,7 @@ def test_hidden_employee_is_excluded_from_directory_but_keeps_own_profile(
response = api_client.get(reverse("api:v1:employees-list"))
assert response.status_code == status.HTTP_200_OK
assert hidden.id not in {
item["id"] for item in extract_results(response.json())
}
assert hidden.id not in {item["id"] for item in extract_results(response.json())}
api_client.force_authenticate(user=hidden)
profile_response = api_client.get(reverse("api:v1:employees-me"))
@ -191,9 +197,7 @@ def test_stats_returns_active_employee_demographics(api_client: APIClient, monke
birth_date=dt.date(1985, 1, 1),
)
experienced.date_joined = dt.datetime(
2026, 1, 1, tzinfo=dt.timezone.utc
)
experienced.date_joined = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)
experienced.save(update_fields=["date_joined"])
def action_date(year: int, month: int, day: int) -> dt.datetime:
@ -349,6 +353,7 @@ def test_retrieve_requires_auth(api_client: APIClient):
url = reverse("api:v1:employees-detail", args=[e.pk])
assert api_client.get(url).status_code == status.HTTP_401_UNAUTHORIZED
def test_retrieve_ok(api_client: APIClient):
user = make_user("u@example.com")
e = make_user("x@example.com")
@ -482,8 +487,10 @@ def test_update_attendance_aliases_normalizes_values(api_client: APIClient):
employee.refresh_from_db()
assert employee.attendance_aliases == ["200", "300"]
# ---------- search & ordering ----------
def test_search_by_email_and_last_name(api_client: APIClient):
me = make_user("me@example.com")
api_client.force_authenticate(user=me)
@ -515,8 +522,10 @@ def test_search_by_email_and_last_name(api_client: APIClient):
ids = [x["id"] for x in extract_results(r.json())]
assert ids == sorted(ids)
# ---------- filters ----------
def test_filter_by_department_includes_members_and_head(api_client: APIClient):
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
@ -535,6 +544,7 @@ def test_filter_by_department_includes_members_and_head(api_client: APIClient):
assert ids == {head.id, member.id}
assert outsider.id not in ids
def test_filter_by_position(api_client: APIClient):
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
@ -558,6 +568,7 @@ def test_filter_by_position(api_client: APIClient):
ids = {it["id"] for it in extract_results(r.json())}
assert ids == {e1.id, e2.id}
def test_filter_by_skills_any_of(api_client: APIClient):
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
@ -579,6 +590,7 @@ def test_filter_by_skills_any_of(api_client: APIClient):
ids = {it["id"] for it in extract_results(r.json())}
assert ids == {e1.id, e2.id}
def test_filter_by_email_verified_and_active(api_client: APIClient):
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
@ -597,6 +609,7 @@ def test_filter_by_email_verified_and_active(api_client: APIClient):
ids = {it["id"] for it in extract_results(r.json())}
assert v1.id in ids and v0.id in ids and a0.id not in ids
def test_filter_actually_active_logic(api_client: APIClient):
u = make_user("u@example.com")
api_client.force_authenticate(user=u)
@ -626,8 +639,10 @@ def test_filter_actually_active_logic(api_client: APIClient):
# среди "не реально активных" должны быть уволенный и/или не верифицированный
assert e_d.id in ids or e_unver.id in ids
# ---------- create (staff only) + validation + skills ----------
def test_create_requires_staff(api_client: APIClient):
user = make_user("u@example.com")
api_client.force_authenticate(user=user)
@ -651,8 +666,10 @@ def test_create_requires_staff(api_client: APIClient):
assert data["email"] == "new@example.com"
assert Employee.objects.filter(email="new@example.com").exists()
# ---------- update / partial_update (IsSelfOrStaff) ----------
def test_partial_update_self_allowed(api_client: APIClient):
e = make_user("self@example.com")
api_client.force_authenticate(user=e)
@ -663,6 +680,7 @@ def test_partial_update_self_allowed(api_client: APIClient):
e.refresh_from_db()
assert e.first_name == "Me"
def test_partial_update_other_forbidden_for_regular_user(api_client: APIClient):
owner = make_user("owner@example.com")
other = make_user("other@example.com")
@ -672,6 +690,7 @@ def test_partial_update_other_forbidden_for_regular_user(api_client: APIClient):
resp = api_client.patch(url, {"first_name": "Hack"}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
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)
@ -683,6 +702,49 @@ def test_partial_update_other_allowed_for_staff(api_client: APIClient):
target.refresh_from_db()
assert target.first_name == "Ok"
def test_staff_can_assign_multiple_positions(api_client: APIClient):
target = make_user("multiple-positions@example.com")
staff = make_user("position-admin@example.com", staff=True)
primary = Position.objects.create(name="Разработчик")
secondary = Position.objects.create(name="Аналитик")
api_client.force_authenticate(user=staff)
response = api_client.patch(
reverse("api:v1:employees-detail", args=[target.pk]),
{"position_ids": [primary.pk, secondary.pk]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["position"] == {"id": primary.pk, "name": primary.name}
assert response.json()["positions"] == [
{"id": primary.pk, "name": primary.name},
{"id": secondary.pk, "name": secondary.name},
]
target.refresh_from_db()
assert target.position_id == primary.pk
assert list(target.additional_positions.values_list("pk", flat=True)) == [
secondary.pk
]
def test_position_filter_includes_additional_position(api_client: APIClient):
viewer = make_user("position-filter-viewer@example.com")
employee = make_user("position-filter-target@example.com")
secondary = Position.objects.create(name="Дополнительная должность")
employee.additional_positions.add(secondary)
api_client.force_authenticate(user=viewer)
response = api_client.get(
reverse("api:v1:employees-list"),
{"position": secondary.pk},
)
assert response.status_code == status.HTTP_200_OK
assert employee.pk in {row["id"] for row in extract_results(response.json())}
def test_destroy_requires_admin(api_client: APIClient):
victim = make_user("victim@example.com")
regular = make_user("regular@example.com")
@ -714,8 +776,10 @@ def test_destroy_self_forbidden_for_regular_user(api_client: APIClient):
)
assert Employee.objects.filter(pk=me.pk).exists()
# ---------- action: me (GET/PATCH) ----------
def test_me_get_returns_current_user(api_client: APIClient):
me = make_user("me@example.com")
api_client.force_authenticate(user=me)
@ -725,6 +789,7 @@ def test_me_get_returns_current_user(api_client: APIClient):
assert r.status_code == 200
assert r.json()["id"] == me.id
def test_me_patch_updates_profile(api_client: APIClient):
me = make_user("me@example.com", telegram="@me") # уже есть контакт
api_client.force_authenticate(user=me)

View File

@ -7,6 +7,7 @@
3. has_perm работает для прав из должностей
4. Права наследуются при назначении/изменении должности
"""
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
@ -23,21 +24,18 @@ pytestmark = pytest.mark.django_db
# Фикстуры (на уровне модуля, доступны всем тестам)
# ============================================================================
@pytest.fixture
def feed_content_type():
"""ContentType для приложения feed (для прав публикаций)."""
ct, _ = ContentType.objects.get_or_create(
app_label="feed", model="post"
)
ct, _ = ContentType.objects.get_or_create(app_label="feed", model="post")
return ct
@pytest.fixture
def documents_content_type():
"""ContentType для приложения documents."""
ct, _ = ContentType.objects.get_or_create(
app_label="documents", model="document"
)
ct, _ = ContentType.objects.get_or_create(app_label="documents", model="document")
return ct
@ -45,20 +43,20 @@ def documents_content_type():
def group_with_publish_perm(feed_content_type):
"""Группа с правом публикации в feed."""
group = Group.objects.create(name="publishers")
# Ищем существующее право или создаём новое
perm = Permission.objects.filter(
codename="publish_company_post",
content_type=feed_content_type,
).first()
if not perm:
perm = Permission.objects.create(
codename="publish_company_post",
name="Can publish company post",
content_type=feed_content_type,
)
group.permissions.add(perm)
return group
@ -67,20 +65,20 @@ def group_with_publish_perm(feed_content_type):
def group_with_document_perm(documents_content_type):
"""Группа с правом добавления документов."""
group = Group.objects.create(name="doc_creators")
# Ищем существующее право или используем defaults
perm = Permission.objects.filter(
codename="add_document",
content_type=documents_content_type,
).first()
if not perm:
perm = Permission.objects.create(
codename="add_document",
name="Can add document",
content_type=documents_content_type,
)
group.permissions.add(perm)
return group
@ -92,9 +90,7 @@ def engineer_position(group_with_publish_perm, group_with_document_perm):
name="Инженер",
description="Технический специалист",
)
position.groups.add(
group_with_publish_perm, group_with_document_perm
)
position.groups.add(group_with_publish_perm, group_with_document_perm)
return position
@ -125,6 +121,7 @@ def basic_user(user_factory):
# Тесты
# ============================================================================
class TestPositionPermissions:
"""Тесты прав доступа через должности."""
@ -132,35 +129,48 @@ class TestPositionPermissions:
"""Пользователь без должности не имеет прав."""
assert not basic_user.has_perm("feed.publish_company_post")
assert not basic_user.has_perm("documents.add_document")
# Проверяем через get_all_permissions
all_perms = basic_user.get_all_permissions()
assert "feed.publish_company_post" not in all_perms
assert "documents.add_document" not in all_perms
def test_user_with_position_gets_permissions(
self, basic_user, engineer_position
):
def test_user_with_position_gets_permissions(self, basic_user, engineer_position):
"""Пользователь с должностью получает права из групп должности."""
# Назначаем должность
basic_user.position = engineer_position
basic_user.save()
# Обновляем пользователя из БД (чтобы подтянуть связи)
basic_user.refresh_from_db()
# Проверяем has_perm
assert basic_user.has_perm("feed.publish_company_post"), \
assert basic_user.has_perm("feed.publish_company_post"), (
"Должен быть доступ к публикации (из группы publishers)"
assert basic_user.has_perm("documents.add_document"), \
)
assert basic_user.has_perm("documents.add_document"), (
"Должен быть доступ к созданию документов (из группы doc_creators)"
)
# Проверяем через get_all_permissions
all_perms = basic_user.get_all_permissions()
assert "feed.publish_company_post" in all_perms, \
assert "feed.publish_company_post" in all_perms, (
f"Право должно быть в all_perms. Текущие права: {all_perms}"
assert "documents.add_document" in all_perms, \
)
assert "documents.add_document" in all_perms, (
f"Право должно быть в all_perms. Текущие права: {all_perms}"
)
def test_additional_position_also_grants_permissions(
self,
basic_user,
engineer_position,
):
basic_user.additional_positions.add(engineer_position)
basic_user = User.objects.get(pk=basic_user.pk)
assert basic_user.has_perm("feed.publish_company_post")
assert basic_user.has_perm("documents.add_document")
def test_position_change_updates_permissions(
self, basic_user, engineer_position, manager_position
@ -169,91 +179,89 @@ class TestPositionPermissions:
# Назначаем инженера (2 права)
basic_user.position = engineer_position
basic_user.save()
# Получаем fresh instance чтобы сбросить кэш прав
user_id = basic_user.id
basic_user = User.objects.get(id=user_id)
assert basic_user.has_perm("feed.publish_company_post")
assert basic_user.has_perm("documents.add_document")
# Меняем на менеджера (только 1 право)
basic_user.position = manager_position
basic_user.save()
# Снова получаем fresh instance
basic_user = User.objects.get(id=user_id)
assert basic_user.has_perm("feed.publish_company_post"), \
"Право на публикацию должно остаться"
assert not basic_user.has_perm("documents.add_document"), \
"Право на документы должно исчезнуть"
def test_remove_position_removes_permissions(
self, basic_user, engineer_position
):
assert basic_user.has_perm("feed.publish_company_post"), (
"Право на публикацию должно остаться"
)
assert not basic_user.has_perm("documents.add_document"), (
"Право на документы должно исчезнуть"
)
def test_remove_position_removes_permissions(self, basic_user, engineer_position):
"""При снятии должности права удаляются."""
# Назначаем должность
basic_user.position = engineer_position
basic_user.save()
# Получаем fresh instance
user_id = basic_user.id
basic_user = User.objects.get(id=user_id)
assert basic_user.has_perm("feed.publish_company_post")
assert basic_user.has_perm("documents.add_document")
# Снимаем должность
basic_user.position = None
basic_user.save()
# Снова fresh instance для сброса кэша
basic_user = User.objects.get(id=user_id)
assert not basic_user.has_perm("feed.publish_company_post"), \
"Права должны исчезнуть после снятия должности"
assert not basic_user.has_perm("documents.add_document"), \
"Права должны исчезнуть после снятия должности"
def test_multiple_users_with_same_position(
self, user_factory, engineer_position
):
assert not basic_user.has_perm("feed.publish_company_post"), (
"Права должны исчезнуть после снятия должности"
)
assert not basic_user.has_perm("documents.add_document"), (
"Права должны исчезнуть после снятия должности"
)
def test_multiple_users_with_same_position(self, user_factory, engineer_position):
"""Несколько пользователей с одной должностью получают
одинаковые права.
"""
user1 = user_factory(email="user1@example.com")
user2 = user_factory(email="user2@example.com")
user1.position = engineer_position
user2.position = engineer_position
user1.save()
user2.save()
user1.refresh_from_db()
user2.refresh_from_db()
# Оба должны иметь одинаковые права
for user in [user1, user2]:
assert user.has_perm("feed.publish_company_post")
assert user.has_perm("documents.add_document")
def test_superuser_has_all_permissions(
self, user_factory, engineer_position
):
def test_superuser_has_all_permissions(self, user_factory, engineer_position):
"""is_superuser имеет все права независимо от должности."""
superuser = user_factory(email="admin@example.com", superuser=True)
# Без должности superuser имеет все права
assert superuser.has_perm("feed.publish_company_post")
assert superuser.has_perm("documents.add_document")
assert superuser.has_perm("any.random_permission")
# С должностью тоже все права
superuser.position = engineer_position
superuser.save()
superuser = User.objects.get(id=superuser.id)
assert superuser.has_perm("feed.publish_company_post")
assert superuser.has_perm("documents.add_document")
assert superuser.has_perm("any.random_permission")
@ -275,7 +283,7 @@ class TestPositionPermissions:
content_type=feed_content_type,
)
group1.permissions.add(perm1)
group2 = Group.objects.create(name="group2")
perm2 = Permission.objects.filter(
codename="perm2",
@ -288,15 +296,15 @@ class TestPositionPermissions:
content_type=feed_content_type,
)
group2.permissions.add(perm2)
# Создаём должность с обеими группами
position = Position.objects.create(name="Multi-group Position")
position.groups.add(group1, group2)
basic_user.position = position
basic_user.save()
basic_user.refresh_from_db()
# Пользователь должен иметь права из обеих групп
assert basic_user.has_perm("feed.perm1")
assert basic_user.has_perm("feed.perm2")
@ -308,16 +316,16 @@ class TestPositionPermissions:
basic_user.position = engineer_position
basic_user.save()
basic_user.refresh_from_db()
all_perms = basic_user.get_all_permissions()
# Проверяем, что это set
assert isinstance(all_perms, set)
# Проверяем содержимое
assert "feed.publish_company_post" in all_perms
assert "documents.add_document" in all_perms
# Проверяем, что нет дубликатов (set гарантирует это)
perms_list = list(all_perms)
assert len(perms_list) == len(set(perms_list))
@ -342,21 +350,19 @@ class TestPositionPermissionsEdgeCases:
user.position = empty_position
user.save()
user.refresh_from_db()
all_perms = user.get_all_permissions()
assert len(all_perms) == 0, "Должность без групп не должна давать прав"
def test_inactive_user_has_no_permissions(
self, user_factory, engineer_position
):
def test_inactive_user_has_no_permissions(self, user_factory, engineer_position):
"""Неактивный пользователь не имеет прав даже с должностью."""
user = user_factory(email="inactive@example.com", active=False)
user.position = engineer_position
user.save()
# Получаем fresh instance
user = User.objects.get(id=user.id)
# is_active=False блокирует все права
assert not user.has_perm("feed.publish_company_post")
assert not user.has_perm("documents.add_document")
@ -366,7 +372,7 @@ class TestPositionPermissionsEdgeCases:
):
"""Права из должности + прямые права пользователя объединяются."""
user = user_factory(email="user@example.com")
# Даём пользователю прямое право
direct_perm, _ = Permission.objects.get_or_create(
codename="direct_perm",
@ -374,31 +380,30 @@ class TestPositionPermissionsEdgeCases:
content_type=feed_content_type,
)
user.user_permissions.add(direct_perm)
# Назначаем должность
user.position = engineer_position
user.save()
user.refresh_from_db()
# Должен иметь и прямые права, и права из должности
assert user.has_perm("feed.direct_perm"), \
"Прямое право должно работать"
assert user.has_perm("feed.publish_company_post"), \
assert user.has_perm("feed.direct_perm"), "Прямое право должно работать"
assert user.has_perm("feed.publish_company_post"), (
"Право из должности должно работать"
assert user.has_perm("documents.add_document"), \
)
assert user.has_perm("documents.add_document"), (
"Право из должности должно работать"
)
class TestPositionPermissionsIntegration:
"""Интеграционные тесты с реальными моделями приложения."""
def test_feed_permissions_through_position(
self, user_factory, feed_content_type
):
def test_feed_permissions_through_position(self, user_factory, feed_content_type):
"""Проверка прав на публикацию постов через должность."""
# Создаём группу с правами публикации
publishers = Group.objects.create(name="publishers")
for codename in ["publish_company_post", "publish_department_post"]:
perm, _ = Permission.objects.get_or_create(
codename=codename,
@ -406,17 +411,17 @@ class TestPositionPermissionsIntegration:
content_type=feed_content_type,
)
publishers.permissions.add(perm)
# Создаём должность редактора
editor_position = Position.objects.create(name="Редактор")
editor_position.groups.add(publishers)
# Назначаем пользователю
user = user_factory(email="editor@example.com")
user.position = editor_position
user.save()
user.refresh_from_db()
# Проверяем права
assert user.has_perm("feed.publish_company_post")
assert user.has_perm("feed.publish_department_post")
@ -427,36 +432,38 @@ class TestPositionPermissionsIntegration:
"""Проверка прав на документы через должность."""
# Создаём группу с правами на документы
doc_managers = Group.objects.create(name="doc_managers")
for codename in [
"add_document", "change_document",
"delete_document", "view_document"
"add_document",
"change_document",
"delete_document",
"view_document",
]:
# Используем filter().first() вместо get_or_create
perm = Permission.objects.filter(
codename=codename,
content_type=documents_content_type,
).first()
if not perm:
perm = Permission.objects.create(
codename=codename,
name=f"Can {codename.replace('_', ' ')}",
content_type=documents_content_type,
)
doc_managers.permissions.add(perm)
# Создаём должность документоведа
doc_position = Position.objects.create(name="Документовед")
doc_position.groups.add(doc_managers)
# Назначаем пользователю
user = user_factory(email="docs@example.com")
user.position = doc_position
user.save()
user.refresh_from_db()
# Проверяем все CRUD права
assert user.has_perm("documents.add_document")
assert user.has_perm("documents.change_document")

View File

@ -147,7 +147,11 @@ export default function ProfilePage() {
}
fullName={fullName}
secondaryLine={formatBirthdayWithYear(user.birth_date) || "Не указана"}
roleText={user.position?.name || "Должность не указана"}
roleText={
(user.positions?.length ? user.positions : user.position ? [user.position] : [])
.map((position) => position.name)
.join(" · ") || "Должность не указана"
}
departmentBadge={
departmentSummary ? (
<ProfileDepartmentBadge

View File

@ -93,7 +93,7 @@ export default function UserDetailPage() {
latestAction,
loading,
person,
positionValue,
positionIds,
positions,
positionsError,
positionsLoading,
@ -101,7 +101,7 @@ export default function UserDetailPage() {
setActionField,
setAvatarFailed,
setEditField,
setPositionValue,
setPositionIds,
sortedActions,
} = useUserDetailPage(userId, currentUser);
@ -475,7 +475,11 @@ export default function UserDetailPage() {
}
fullName={fullName}
secondaryLine={formatBirthdayWithYear(person.birth_date) || "Не указана"}
roleText={person.position?.name || "Должность не указана"}
roleText={
(person.positions?.length ? person.positions : person.position ? [person.position] : [])
.map((position) => position.name)
.join(" · ") || "Должность не указана"
}
departmentBadge={
departmentSummary ? (
<ProfileDepartmentBadge
@ -623,10 +627,10 @@ export default function UserDetailPage() {
isOpen={isPositionModalOpen}
onClose={handleClosePositionModal}
onCreatePosition={handleCreatePosition}
onPositionChange={setPositionValue}
onPositionIdsChange={setPositionIds}
onSave={handleSavePosition}
person={person}
positionValue={positionValue}
positionIds={positionIds}
positions={positions}
positionsLoading={positionsLoading}
/>

View File

@ -1,19 +1,19 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { BriefcaseBusiness, Plus, X } from "lucide-react";
import { Modal } from "@/components/ui";
import type { Position, User } from "@/types/api";
type ChangeUserPositionModalProps = {
isOpen: boolean;
onClose: () => void;
onCreatePosition: (name: string, description: string) => Promise<boolean>;
onCreatePosition: (name: string) => Promise<Position | null>;
onSave: () => void;
onPositionChange: (value: string) => void;
onPositionIdsChange: (value: number[]) => void;
positions: Position[];
positionsLoading: boolean;
positionValue: string;
positionIds: number[];
actionLoading: string | null;
person: User | null;
error?: string | null;
@ -24,10 +24,10 @@ export default function ChangeUserPositionModal({
onClose,
onCreatePosition,
onSave,
onPositionChange,
onPositionIdsChange,
positions,
positionsLoading,
positionValue,
positionIds,
actionLoading,
person,
error,
@ -35,20 +35,34 @@ export default function ChangeUserPositionModal({
const isSaving = actionLoading === "position";
const isCreating = actionLoading === "position-create";
const isBusy = isSaving || isCreating;
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newPositionName, setNewPositionName] = useState("");
const [newPositionDescription, setNewPositionDescription] = useState("");
const [inputValue, setInputValue] = useState("");
const listId = "employee-position-options";
const handleCreatePosition = async () => {
const created = await onCreatePosition(
newPositionName,
newPositionDescription,
const selectedPositions = useMemo(
() => positionIds
.map((id) => positions.find((position) => position.id === id))
.filter((position): position is Position => Boolean(position)),
[positionIds, positions],
);
const handleAddPosition = async () => {
const name = inputValue.trim();
if (!name || isBusy) return;
const existing = positions.find(
(position) => position.name.trim().toLocaleLowerCase("ru") === name.toLocaleLowerCase("ru"),
);
if (!created) return;
const position = existing || await onCreatePosition(name);
if (!position) return;
setNewPositionName("");
setNewPositionDescription("");
setIsCreateOpen(false);
if (!positionIds.includes(position.id)) {
onPositionIdsChange([...positionIds, position.id]);
}
setInputValue("");
};
const removePosition = (positionId: number) => {
onPositionIdsChange(positionIds.filter((id) => id !== positionId));
};
const footer = (
@ -76,110 +90,86 @@ export default function ChangeUserPositionModal({
<Modal
isOpen={isOpen && !!person}
onClose={onClose}
title="Изменить должность"
title="Должности"
size="sm"
closeOnEsc={!isBusy}
footer={footer}
>
<div className="space-y-4">
<section className="app-surface-muted rounded-xl p-4">
<div className="mb-3">
<p className="text-sm font-semibold text-[var(--foreground)]">
{person?.last_name} {person?.first_name} {person?.patronymic || ""}
</p>
<p className="app-text-muted mt-1 text-sm">
Текущая должность: {person?.position?.name || "Не указана"}
</p>
</div>
<p className="app-text-muted text-sm">
{person?.last_name} {person?.first_name} {person?.patronymic || ""}
</p>
<label htmlFor="employee-position" className="block">
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Новая должность
</span>
<select
id="employee-position"
value={positionValue}
onChange={(event) => onPositionChange(event.target.value)}
className="app-select w-full rounded-lg px-4 py-2.5 text-sm"
disabled={positionsLoading || isBusy}
>
<option value="">Без должности</option>
{positions.map((position) => (
<option key={position.id} value={String(position.id)}>
{position.name}
</option>
<div className="flex min-w-0 gap-2">
<input
list={listId}
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
placeholder="Название должности"
className="app-input min-w-0 flex-1 rounded-xl px-3 py-2.5 text-sm"
disabled={isBusy || positionsLoading}
onKeyDown={(event) => {
if (event.key === "Enter" && inputValue.trim()) {
event.preventDefault();
void handleAddPosition();
}
}}
/>
<datalist id={listId}>
{positions
.filter((position) => !positionIds.includes(position.id))
.map((position) => (
<option key={position.id} value={position.name} />
))}
</select>
</label>
{positionsLoading ? (
<p className="app-text-muted mt-3 text-sm">Загрузка должностей...</p>
) : null}
{error ? (
<p className="mt-3 text-sm text-[var(--danger-foreground)]">{error}</p>
) : null}
</section>
<section className="app-surface-muted rounded-xl p-4">
</datalist>
<button
type="button"
onClick={() => setIsCreateOpen((current) => !current)}
disabled={isBusy}
className="flex w-full items-center justify-between gap-3 text-left text-sm font-medium text-[var(--foreground)] disabled:opacity-50"
aria-expanded={isCreateOpen}
onClick={() => void handleAddPosition()}
disabled={isBusy || positionsLoading || !inputValue.trim()}
className="app-action-primary flex h-[42px] w-[42px] shrink-0 items-center justify-center rounded-xl disabled:opacity-50"
title="Добавить должность"
aria-label="Добавить должность"
>
<span>Добавить новую должность</span>
<Plus
size={18}
className={`app-text-muted shrink-0 transition-transform ${isCreateOpen ? "rotate-45" : ""}`}
/>
<Plus size={17} />
</button>
</div>
{isCreateOpen ? (
<div className="mt-4 space-y-3 border-t border-[var(--border)] pt-4">
<label className="block">
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Название
</span>
<input
type="text"
value={newPositionName}
onChange={(event) => setNewPositionName(event.target.value)}
placeholder="Например, Системный аналитик"
maxLength={255}
autoFocus
{positionsLoading ? (
<p className="app-text-muted text-sm">Загрузка должностей...</p>
) : null}
{isCreating ? (
<p className="app-text-muted text-sm">Создаём новую должность...</p>
) : null}
{error ? (
<p className="text-sm text-[var(--danger-foreground)]">{error}</p>
) : null}
<div>
<p className="app-card-caption mb-2">Назначенные должности</p>
{selectedPositions.length ? (
<div className="flex flex-wrap gap-2">
{selectedPositions.map((position, index) => (
<button
key={position.id}
type="button"
onClick={() => removePosition(position.id)}
disabled={isBusy}
className="app-input w-full rounded-lg px-4 py-2.5 text-sm"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Описание
<span className="app-text-muted ml-1 font-normal">(необязательно)</span>
</span>
<textarea
value={newPositionDescription}
onChange={(event) => setNewPositionDescription(event.target.value)}
placeholder="Кратко опишите обязанности"
rows={3}
disabled={isBusy}
className="app-input w-full resize-y rounded-lg px-4 py-2.5 text-sm"
/>
</label>
<button
type="button"
onClick={() => void handleCreatePosition()}
disabled={isBusy || !newPositionName.trim()}
className="app-action-secondary w-full rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
{isCreating ? "Создание..." : "Создать и выбрать"}
</button>
className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition hover:border-red-400/50 hover:text-red-300 disabled:opacity-50"
title="Убрать должность"
>
<BriefcaseBusiness size={13} />
<span>{position.name}</span>
{index === 0 ? (
<span className="app-text-muted text-[10px]">Основная</span>
) : null}
<X size={14} />
</button>
))}
</div>
) : null}
</section>
) : (
<p className="app-text-muted text-sm">Должности не назначены</p>
)}
</div>
</div>
</Modal>
);

View File

@ -44,7 +44,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
const [positions, setPositions] = useState<Position[]>([]);
const [positionsLoading, setPositionsLoading] = useState(false);
const [positionsError, setPositionsError] = useState<string | null>(null);
const [positionValue, setPositionValue] = useState("");
const [positionIds, setPositionIds] = useState<number[]>([]);
const refreshPerson = useCallback(async () => {
const data = await apiClient.getEmployee(userId) as User;
@ -227,7 +227,12 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
const handleOpenPositionModal = useCallback(async () => {
if (!person) return;
setPositionValue(person.position?.id ? String(person.position.id) : "");
const currentPositions = person.positions?.length
? person.positions
: person.position
? [person.position]
: [];
setPositionIds(currentPositions.map((position) => position.id));
setPositionsError(null);
setIsPositionModalOpen(true);
@ -324,7 +329,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
setPositionsError(null);
try {
await apiClient.updateEmployee(userId, {
position_id: positionValue ? Number(positionValue) : null,
position_ids: positionIds,
});
await refreshPerson();
handleClosePositionModal();
@ -335,16 +340,15 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
} finally {
setActionLoading(null);
}
}, [handleClosePositionModal, person, positionValue, refreshPerson, userId]);
}, [handleClosePositionModal, person, positionIds, refreshPerson, userId]);
const handleCreatePosition = useCallback(async (
name: string,
description: string,
): Promise<boolean> => {
): Promise<Position | null> => {
const trimmedName = name.trim();
if (!trimmedName) {
setPositionsError("Укажите название должности");
return false;
return null;
}
setActionLoading("position-create");
@ -352,20 +356,18 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
try {
const createdPosition = await apiClient.createPosition({
name: trimmedName,
description: description.trim() || undefined,
}) as Position;
setPositions((current) => (
[...current.filter((position) => position.id !== createdPosition.id), createdPosition]
.sort((left, right) => left.name.localeCompare(right.name, "ru"))
));
setPositionValue(String(createdPosition.id));
return true;
return createdPosition;
} catch (createError) {
setPositionsError(
getErrorMessage(createError, "Не удалось создать должность"),
);
return false;
return null;
} finally {
setActionLoading(null);
}
@ -424,7 +426,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
latestAction,
loading,
person,
positionValue,
positionIds,
positions,
positionsError,
positionsLoading,
@ -432,7 +434,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
setActionField,
setAvatarFailed,
setEditField,
setPositionValue,
setPositionIds,
sortedActions,
};
}

View File

@ -168,6 +168,7 @@ export interface User {
last_name: string;
patronymic?: string; // отчество (было middle_name)
position?: Position;
positions?: Position[];
departments?: EmployeeDepartment[]; // массив отделов с ролями (было department)
avatar?: string;
is_active: boolean; // ВАЖНО: активный сотрудник (не уволен), НЕ онлайн статус!