Merge branch 'develop'
This commit is contained in:
commit
485ff2424d
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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")},
|
||||
),
|
||||
(
|
||||
"Права и статусы",
|
||||
{
|
||||
|
||||
@ -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="Дополнительные должности",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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 (
|
||||
|
||||
9
backend/employees/positions.py
Normal file
9
backend/employees/positions.py
Normal 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()
|
||||
@ -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 [
|
||||
|
||||
@ -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 пользователем.
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ["руководитель", "начальник", "директор", "финансов"]
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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; // ВАЖНО: активный сотрудник (не уволен), НЕ онлайн статус!
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user