diff --git a/backend/api/v1/employees/serializers/employee_serializers/detail.py b/backend/api/v1/employees/serializers/employee_serializers/detail.py index 315e33db..a12673eb 100644 --- a/backend/api/v1/employees/serializers/employee_serializers/detail.py +++ b/backend/api/v1/employees/serializers/employee_serializers/detail.py @@ -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 diff --git a/backend/api/v1/employees/serializers/employee_serializers/listing.py b/backend/api/v1/employees/serializers/employee_serializers/listing.py index 826914e5..d1d6bdc2 100644 --- a/backend/api/v1/employees/serializers/employee_serializers/listing.py +++ b/backend/api/v1/employees/serializers/employee_serializers/listing.py @@ -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) diff --git a/backend/api/v1/employees/views/employee_mixins/query.py b/backend/api/v1/employees/views/employee_mixins/query.py index f55cad56..286dc972 100644 --- a/backend/api/v1/employees/views/employee_mixins/query.py +++ b/backend/api/v1/employees/views/employee_mixins/query.py @@ -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: diff --git a/backend/documents/rules.py b/backend/documents/rules.py index 0d0337a1..f90f27b0 100644 --- a/backend/documents/rules.py +++ b/backend/documents/rules.py @@ -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 [ diff --git a/backend/employees/admin.py b/backend/employees/admin.py index 23b9e061..07eff79b 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -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")}, + ), ( "Права и статусы", { diff --git a/backend/employees/migrations/0062_employee_additional_positions.py b/backend/employees/migrations/0062_employee_additional_positions.py new file mode 100644 index 00000000..b41dad10 --- /dev/null +++ b/backend/employees/migrations/0062_employee_additional_positions.py @@ -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="Дополнительные должности", + ), + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index f648fb97..08b9a469 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -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 ( diff --git a/backend/employees/positions.py b/backend/employees/positions.py new file mode 100644 index 00000000..b1e5629d --- /dev/null +++ b/backend/employees/positions.py @@ -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() diff --git a/backend/employees/rules.py b/backend/employees/rules.py index 0c107a1a..fb6ccbd1 100644 --- a/backend/employees/rules.py +++ b/backend/employees/rules.py @@ -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 [ diff --git a/backend/employees/signals/ldap/employee.py b/backend/employees/signals/ldap/employee.py index 12792569..3d577277 100644 --- a/backend/employees/signals/ldap/employee.py +++ b/backend/employees/signals/ldap/employee.py @@ -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 пользователем. diff --git a/backend/eusrr_backend/auth_backends.py b/backend/eusrr_backend/auth_backends.py index 908fb79b..e8707dd0 100644 --- a/backend/eusrr_backend/auth_backends.py +++ b/backend/eusrr_backend/auth_backends.py @@ -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() ) diff --git a/backend/feed/rules.py b/backend/feed/rules.py index 5ab6fda0..3a8217fc 100644 --- a/backend/feed/rules.py +++ b/backend/feed/rules.py @@ -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( diff --git a/backend/procurement/rules.py b/backend/procurement/rules.py index 638415fc..ab528821 100644 --- a/backend/procurement/rules.py +++ b/backend/procurement/rules.py @@ -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 ["руководитель", "начальник", "директор", "финансов"] diff --git a/backend/requests_app/rules.py b/backend/requests_app/rules.py index bcaad167..5abcb123 100644 --- a/backend/requests_app/rules.py +++ b/backend/requests_app/rules.py @@ -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 [ diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index 8a23fe82..61751b87 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -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) diff --git a/backend/tests/employees/test_position_permissions.py b/backend/tests/employees/test_position_permissions.py index 26044640..bdfe7202 100644 --- a/backend/tests/employees/test_position_permissions.py +++ b/backend/tests/employees/test_position_permissions.py @@ -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") diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index e18a5a47..58217c88 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -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 ? ( position.name) + .join(" · ") || "Должность не указана" + } departmentBadge={ departmentSummary ? ( diff --git a/frontend/src/components/users/ChangeUserPositionModal.tsx b/frontend/src/components/users/ChangeUserPositionModal.tsx index a9713174..0b98c491 100644 --- a/frontend/src/components/users/ChangeUserPositionModal.tsx +++ b/frontend/src/components/users/ChangeUserPositionModal.tsx @@ -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; + onCreatePosition: (name: string) => Promise; 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({
-
-
-

- {person?.last_name} {person?.first_name} {person?.patronymic || ""} -

-

- Текущая должность: {person?.position?.name || "Не указана"} -

-
+

+ {person?.last_name} {person?.first_name} {person?.patronymic || ""} +

- - - {positionsLoading ? ( -

Загрузка должностей...

- ) : null} - - {error ? ( -

{error}

- ) : null} -
- -
+ +
- {isCreateOpen ? ( -
-