From 07d5209f4371afc224121ebcf2930aa8e8f9e7dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Thu, 4 Sep 2025 17:10:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(employees):=20=D1=83=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D0=BC=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=20+=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2;=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82-=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F/=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F;=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D1=8B=20UI/=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend / API - DepartmentViewSet: - добавлены экшены POST /departments/{id}/add_member/ и /remove_member/ • включают/выключают членство (is_active) без изменения роли • идемпотентны: add_member активирует существующую связь или создаёт, remove_member не трогает роль - set_member_role: не создаёт членство, 404 если сотрудник не состоит в отделе; роль проверяется на принадлежность отделу; возвращает {employee_id, role_id|null, is_active} - get_permissions: исправлено пересечение прав • add_member/remove_member → только ManagePerm (DeptPerm.MANAGE) • set_member_role → только AssignRolePerm (DeptPerm.ASSIGN_ROLE) • set_head → ChangeHeadPerm • update/partial_update/destroy → ManagePerm - queryset/поиск/сортировка: аннотации для employees_count, поиск по имени/описанию/ФИО руководителя, сортировка по name/id - Исправления валидации head в partial_update/set_head: «ослабление» требования email_verified, если меняет текущий руководитель Frontend / Views & Templates - employees/views_front.py: - department_detail: берёт агрегат /api/v1/departments/{id}/ui-context/; формирует members_exclude_ids (участники+руководитель) - добавлены прокси-вьюхи department_add_member / department_remove_member (POST) — ходят в API, показывают messages и редиректят обратно - employees_list: поддержан формат ?format=json для фронт-поиска (без прямого запроса в DRF → нет 401) - set_member_role: прокси, защита от изменения собственной роли - templates/employees/department_detail.html: - «голая» шестерёнка в правом нижнем углу инфоблока; запоминает состояние (localStorage), аккуратные отступы - описание отдела: white-space: pre-line + overflow-wrap:anywhere (учёт переносов и длинных слов), уменьшен верхний «воздух» - секция «Команда отдела» переделана в стилях блока «Профиль» (как в employee_detail.html): • строки-элементы с аватаром, ФИО, текущей ролью • форма смены роли (если есть права) • две отдельные кнопки: «Пригласить» (добавить) и «Удалить» - два модальных окна: • Пригласить (add): поиск по всем сотрудникам, исключая уже состоящих/руководителя (members_exclude_ids) • Удалить (remove): поиск только по сотрудникам текущего отдела; исключаем самого себя • список с аватарками, выбор подсвечивается; сабмит не активен до выбора; клавиши Enter/Escape - JS для модалок: поиск через фронтовую employees_list (?format=json), дебаунс, «портал» меню в body, обработка ошибок - templates/employees/_department_controls.html: - «Новая роль»: фикс разворота/скрытия формы, сброс состояний - выпадающее меню ролей рендерится в body (не клипается родителями) - кнопка «Отмена» теперь полноценная кнопка (btn btn-link), работает - чипсы прав + скрытые permission_ids синхронизируются корректно Тесты - tests/api/v1/employees/test_department_membership_separation.py (новые/обновлённые): - test_add_member_requires_manage_and_does_not_assign_role - test_remove_member_requires_manage_and_does_not_touch_role - test_set_member_role_requires_assign_not_manage - test_set_member_role_does_not_create_membership_and_does_not_toggle_active - test_all_three_actions_require_auth_and_permissions - фиксы фабрик/тест-данных: гарантированно уникальные email/phone_number (устранён IntegrityError: UNIQUE constraint failed) - поправлены ожидания статусов (401 для неаутентифицированных, 403 без прав, 200 при корректных правах); для set_member_role — 404, если членства нет Bugfixes / прочее - VariableDoesNotExist (list) в department_detail: использован безопасный контекст и корректный default - 401 при поиске сотрудников из шаблона: переведено на фронтовую прокси-вьюху (?format=json), вместо прямого запроса в DRF - мелкие правки стилей и отступов, соответствие стилю «Профиль» Как проверить 1) API-права: - без аутентификации: все 3 экшена → 401 - с аутентификацией, но без прав: add/remove → 403; set_member_role → 403 - с DeptPerm.MANAGE: add/remove → 200; set_member_role → 403 - с DeptPerm.ASSIGN_ROLE: set_member_role → 200; add/remove → 403 2) set_member_role не создаёт членство и не меняет is_active; add/remove не трогают роль 3) UI: - шестерёнка показывает/скрывает настройки; состояние помнится - модалки «Пригласить»/«Удалить»: поиск, выбор, сабмит; аватарки; корректные фильтры (исключения и текущий отдел) - «Отмена» работает, кнопки и выпадающие списки не клипаются BREAKING CHANGES: нет Migrations: нет --- backend/api/v1/employees/serializers.py | 43 +- backend/api/v1/employees/views.py | 158 ++- backend/employees/forms.py | 260 ---- backend/employees/urls_front.py | 38 +- backend/employees/views_auth.py | 2 +- backend/employees/views_front.py | 346 +++-- .../employees/_department_controls.html | 1185 ++++++++++------- .../employees/department_detail.html | 615 +++++++-- .../test_department_membership_separation.py | 378 ++++++ 9 files changed, 2031 insertions(+), 994 deletions(-) delete mode 100644 backend/employees/forms.py create mode 100644 backend/tests/api/v1/employees/test_department_membership_separation.py diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 82ea0df9..bbabfbce 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -420,8 +420,8 @@ class DepartmentSerializer(serializers.ModelSerializer): "id", "name", "description", - "head", # nested read - "head_id", # write + "head", + "head_id", "employees_count", ) @@ -660,5 +660,42 @@ class SetMemberRoleInput(serializers.Serializer): return attrs +class AddMemberInput(serializers.Serializer): + """ + Входные данные для добавления сотрудника в отдел. + Роль НЕ назначается здесь (для этого есть set_member_role). + """ + + employee_id = serializers.IntegerField(required=False) + employee = serializers.IntegerField(required=False) + + def validate(self, attrs): + emp = ( + attrs.get("employee_id") + if "employee_id" in attrs + else attrs.get("employee") + ) + if emp is None: + raise serializers.ValidationError( + {"employee_id": "This field is required."} + ) + attrs["employee_id"] = emp + return attrs + + class RemoveMemberInput(serializers.Serializer): - employee_id = serializers.IntegerField() + """ + Входные данные для удаления (деактивации) сотрудника из отдела. + """ + + employee_id = serializers.IntegerField(required=False) + employee = serializers.IntegerField(required=False) + + def validate(self, attrs): + emp = attrs.get("employee_id", attrs.get("employee")) + if emp is None: + raise serializers.ValidationError( + {"employee_id": "This field is required."} + ) + attrs["employee_id"] = emp + return attrs diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 3519b567..39bfbdb5 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -59,6 +59,7 @@ from ..permissions import ( has_dept_perm, ) from .serializers import ( + AddMemberInput, DepartmentRoleSerializer, DepartmentSerializer, EmailSerializer, @@ -203,7 +204,14 @@ def _build_links_for_dept(dept: Department) -> list[dict]: def _head_choices_for_dept(dept: Department) -> list[dict]: """ - Возвращает [{ "id": , "name": }] для селекта руководителя. + Вернёт список кандидатов для назначения руководителя отдела. + + Формат элемента: + { + "id": int, # ID сотрудника + "name": str, # display_name из EmployeeBriefSerializer + "email": str # email сотрудника (может быть пустой строкой) + } """ choices, seen = [], set() qs = ( @@ -219,12 +227,25 @@ def _head_choices_for_dept(dept: Department) -> list[dict]: for link in qs: data = EmployeeBriefSerializer(link.employee).data if data["id"] not in seen: - choices.append({"id": data["id"], "name": data["display_name"]}) + choices.append( + { + "id": data["id"], + "name": data["display_name"], + "email": data.get("email", "") or "", + } + ) seen.add(data["id"]) if dept.head_id and dept.head_id not in seen: head_data = EmployeeBriefSerializer(dept.head).data - choices.insert(0, {"id": head_data["id"], "name": head_data["display_name"]}) + choices.insert( + 0, + { + "id": head_data["id"], + "name": head_data["display_name"], + "email": head_data.get("email", "") or "", + }, + ) return choices @@ -480,13 +501,15 @@ class DepartmentViewSet(viewsets.ModelViewSet): CRUD отделов + действия: - POST /departments/{id}/set_head - POST /departments/{id}/set_member_role + - POST /departments/{id}/add_member - POST /departments/{id}/remove_member Права: - update/partial_update/destroy → manage_department - - set_head → change_department_head - - set_member_role/remove_member → assign_department_role - - create → staff/superuser - - чтение → аутентифицированным + - set_head → change_department_head + - set_member_role → assign_department_role (назначение/снятие роли) + - add_member/remove_member → manage_department (управление участниками) + - create → staff/superuser + - чтение → аутентифицированным """ queryset = Department.objects.select_related("head").prefetch_related("roles").all() @@ -513,13 +536,15 @@ class DepartmentViewSet(viewsets.ModelViewSet): return [self.ManagePerm()] if self.action == "set_head": return [self.ChangeHeadPerm()] - if self.action in {"set_member_role", "remove_member"}: + if self.action in {"set_member_role"}: return [self.AssignRolePerm()] + if self.action in {"add_member", "remove_member"}: + return [self.ManagePerm()] if self.action == "create": return [AdminOrActionOrModelPerms()] if self.action in {"members", "user_perms", "ui_context"}: return [IsAuthenticated()] - return [IsAuthenticated()] + return [AdminOrActionOrModelPerms()] # --- поиск и сортировка, как в старой реализации/тестах --- ordering = ["name"] @@ -676,6 +701,19 @@ class DepartmentViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["post"]) @transaction.atomic def set_member_role(self, request, pk=None): + """ + Назначает/снимает РОЛЬ существующему участнику отдела. + Не создаёт членство и не меняет is_active. + + Тело: + { "employee_id": , "role_id": } + Права: + AssignRolePerm (DeptPerm.ASSIGN_ROLE) + Ответ: + 200 {"employee_id":..., "role_id": , "is_active": } + 404 если сотрудник не состоит в отделе + 400 если роль не принадлежит отделу + """ dept = self.get_object() payload = SetMemberRoleInput(data=request.data) if not payload.is_valid(): @@ -683,11 +721,6 @@ class DepartmentViewSet(viewsets.ModelViewSet): emp_id = payload.validated_data["employee_id"] role_id = payload.validated_data.get("role_id") - make_active = payload.validated_data.get("is_active", True) - - # модель сотрудника берём из FK Department.head (без жёсткого User) - employee_model = Department._meta.get_field("head").remote_field.model - get_object_or_404(employee_model, id=emp_id) # проверяем роль, если указана, что она принадлежит этому отделу role = None @@ -699,15 +732,19 @@ class DepartmentViewSet(viewsets.ModelViewSet): status=400, ) - # создаём/обновляем линк - link, _ = EmployeeDepartment.objects.get_or_create( - employee_id=emp_id, - department_id=dept.id, - defaults={"is_active": make_active, "role": role}, - ) - link.is_active = bool(make_active) - link.role = role # None => снять роль - link.save(update_fields=["is_active", "role"]) + # членство ДОЛЖНО существовать — не создаём автоматически + try: + link = EmployeeDepartment.objects.get( + employee_id=emp_id, department_id=dept.id + ) + except EmployeeDepartment.DoesNotExist: + return Response( + {"detail": "Employee is not a member of this department."}, status=404 + ) + + # только меняем роль + link.role = role + link.save(update_fields=["role"]) return Response( { @@ -799,6 +836,81 @@ class DepartmentViewSet(viewsets.ModelViewSet): } return Response(payload, status=200) + @action(detail=True, methods=["post"], url_path="add_member") + @transaction.atomic + def add_member(self, request, pk: int | None = None): + """ + Активирует/добавляет сотрудника в отдел. + Роль НЕ назначается здесь. + + Тело: + { "employee_id": } + Права: + ManagePerm (DeptPerm.MANAGE) + + Идемпотентно: + если линк есть — делаем is_active=True, роль не трогаем. + """ + dept = self.get_object() + payload = AddMemberInput(data=request.data) + payload.is_valid(raise_exception=True) + + emp_id = payload.validated_data["employee_id"] + + # проверим, что сотрудник существует (модель берём из FK на head) + employee_model = Department._meta.get_field("head").remote_field.model + get_object_or_404(employee_model, id=emp_id) + + link, _created = EmployeeDepartment.objects.get_or_create( + employee_id=emp_id, department_id=dept.id, defaults={"is_active": True} + ) + if not link.is_active: + link.is_active = True + link.save(update_fields=["is_active"]) + + return Response( + { + "employee_id": emp_id, + "is_active": True, + "role_id": (link.role_id if getattr(link, "role_id", None) else None), + }, + status=status.HTTP_200_OK, + ) + + @action(detail=True, methods=["post"], url_path="remove_member") + @transaction.atomic + def remove_member(self, request, pk: int | None = None): + """ + Деактивирует (удаляет) сотрудника из отдела. + Роль НЕ изменяется. + + Тело: + { "employee_id": } + Права: + ManagePerm (DeptPerm.MANAGE) + """ + dept = self.get_object() + payload = RemoveMemberInput(data=request.data) + payload.is_valid(raise_exception=True) + + emp_id = payload.validated_data["employee_id"] + + link = get_object_or_404( + EmployeeDepartment, employee_id=emp_id, department_id=dept.id + ) + if link.is_active: + link.is_active = False + link.save(update_fields=["is_active"]) + + return Response( + { + "employee_id": emp_id, + "is_active": False, + "role_id": (link.role_id if getattr(link, "role_id", None) else None), + }, + status=status.HTTP_200_OK, + ) + class EmployeeViewSet(viewsets.ModelViewSet): """ diff --git a/backend/employees/forms.py b/backend/employees/forms.py deleted file mode 100644 index d904c4f9..00000000 --- a/backend/employees/forms.py +++ /dev/null @@ -1,260 +0,0 @@ -# backend/employees/forms.py -import base64 -from mimetypes import guess_type - -from django import forms -from django.contrib.auth.forms import (AuthenticationForm, UserChangeForm, - UserCreationForm) -from django.core.exceptions import ValidationError -from employees.models import Employee -from phonenumber_field.formfields import PhoneNumberField - -from .models import Department, Employee, EmployeeDepartment, Skill - - -# ========================= -# Регистрация -# ========================= -def _file_to_data_uri(f) -> str: - """ - Конвертирует загруженный файл (InMemoryUploadedFile) в data URI: data:;base64,<...> - Совместимо с твоим Base64ImageField на бэкенде. - """ - try: - content = f.read() - if hasattr(f, "seek"): - f.seek(0) - except Exception: - return "" - mime = ( - getattr(f, "content_type", None) - or guess_type(getattr(f, "name", ""))[0] - or "image/jpeg" - ) - b64 = base64.b64encode(content).decode("ascii") - return f"data:{mime};base64,{b64}" - - -class EmailOrPhoneAuthenticationForm(AuthenticationForm): - username = forms.CharField( - label="Email или телефон", - widget=forms.TextInput( - attrs={ - "autofocus": True, - "placeholder": "user@example.com или +7 999 123-45-67", - } - ), - ) - password = forms.CharField( - label="Пароль", - strip=False, - widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}), - ) - - -class RegistrationForm(forms.Form): - # Обязательные поля - first_name = forms.CharField(max_length=150, label="Имя") - last_name = forms.CharField(max_length=150, label="Фамилия") - phone_number = forms.CharField(max_length=100, label="Номер телефона") - email = forms.EmailField(label="Email") - birth_date = forms.DateField( - label="Дата рождения", widget=forms.DateInput(attrs={"type": "date"}) - ) - - # Пароли — как в шаблоне - password1 = forms.CharField(label="Пароль", widget=forms.PasswordInput) - password2 = forms.CharField(label="Повторите пароль", widget=forms.PasswordInput) - - # Контакты: нужен хотя бы один - telegram = forms.CharField(required=False, label="Telegram") - whatsapp = forms.CharField(required=False, label="WhatsApp") - wechat = forms.CharField(required=False, label="WeChat") - - # Аватар (опционально) - avatar = forms.ImageField(required=False, label="Аватар") - - # Опциональные поля модели - patronymic = forms.CharField(required=False, label="Отчество") - gender = forms.IntegerField(required=False, label="Пол (0/1/2)") - position = forms.IntegerField(required=False, label="ID должности") - # skills — список ID через запятую (для простоты) - skills = forms.CharField(required=False, label="Навыки (ID через запятую)") - - def clean(self): - data = super().clean() - - # хотя бы один контакт - if not (data.get("telegram") or data.get("whatsapp") or data.get("wechat")): - raise ValidationError( - "Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram" - ) - - # пароли совпадают - p1 = data.get("password1") - p2 = data.get("password2") - if p1 or p2: - if p1 != p2: - raise ValidationError("Пароли не совпадают.") - else: - raise ValidationError("Введите пароль и его подтверждение.") - - return data - - def skills_ids(self) -> list[int]: - raw = (self.cleaned_data.get("skills") or "").strip() - if not raw: - return [] - out: list[int] = [] - for chunk in raw.split(","): - chunk = chunk.strip() - if not chunk: - continue - try: - out.append(int(chunk)) - except ValueError: - pass - return out - - def to_api_payload(self) -> dict: - """ - Payload для POST /api/v1/auth/register/. - Включает avatar как data URI, если загружен. - """ - cd = self.cleaned_data - payload = { - "first_name": cd["first_name"], - "last_name": cd["last_name"], - "phone_number": cd["phone_number"], - "email": cd["email"], - "birth_date": cd["birth_date"].isoformat(), - "password": cd["password1"], - "telegram": cd.get("telegram") or "", - "whatsapp": cd.get("whatsapp") or "", - "wechat": cd.get("wechat") or "", - } - # доп. поля, если заданы - if cd.get("patronymic"): - payload["patronymic"] = cd["patronymic"] - if cd.get("gender") is not None: - payload["gender"] = cd["gender"] - if cd.get("position") is not None: - payload["position"] = cd["position"] - ids = self.skills_ids() - if ids: - payload["skills"] = ids - # avatar → base64 data URI - if cd.get("avatar"): - payload["avatar"] = _file_to_data_uri(cd["avatar"]) - return payload - - def save(self, commit=True): - """ - Прямое создание пользователя (если не ходим в API). - Создаём через менеджер, аватар передаём как файл. - """ - data = self.cleaned_data - user = Employee.objects.create_user( - email=data["email"].strip().lower(), - password=data["password1"], - phone_number=data["phone_number"], - first_name=data["first_name"], - last_name=data["last_name"], - birth_date=data.get("birth_date"), - telegram=data.get("telegram", ""), - whatsapp=data.get("whatsapp", ""), - wechat=data.get("wechat", ""), - avatar=data.get("avatar") or None, - ) - return user - - -class VerifyEmailForm(forms.Form): - email = forms.EmailField(label="Email") - code = forms.CharField(max_length=32, label="Код из письма") - - -# ========================= -# Отделы -# ========================= -class DepartmentEditForm(forms.Form): - name = forms.CharField(max_length=255, label="Название") - description = forms.CharField( - required=False, widget=forms.Textarea, label="Описание" - ) - - -class SetHeadForm(forms.Form): - # просто номер id; мы не тянем ORM - head_id = forms.IntegerField( - required=False, label="ID руководителя (пусто — снять)" - ) - - -class SetMemberRoleForm(forms.Form): - employee_id = forms.IntegerField(label="ID сотрудника") - role_id = forms.IntegerField(required=False, label="ID роли (пусто — снять)") - is_active = forms.BooleanField(required=False, label="Активен в отделе") - - -class DepartmentForm(forms.ModelForm): - class Meta: - model = Department - fields = ["name", "description", "head"] - - -class InviteToDepartmentForm(forms.Form): - employee = forms.ModelChoiceField( - queryset=Employee.objects.none(), - label="Сотрудник", - widget=forms.Select(attrs={"class": "form-select"}), - ) - - def __init__(self, department, *args, **kwargs): - super().__init__(*args, **kwargs) - - qs = ( - Employee.objects.all() - # НЕ показываем тех, кто уже активен в отделе - .exclude( - departments_links__department=department, - departments_links__is_active=True, - ) - # по желанию: не показывать руководителя, если он уже назначен - .exclude(id=department.head_id if department.head_id else None).order_by( - "last_name", "first_name" - ) - ) - - self.fields["employee"].queryset = qs - - -class DepartmentMemberRoleForm(forms.ModelForm): - class Meta: - model = EmployeeDepartment - fields = ["role", "is_active"] - labels = { - "role": "Роль в отделе", - "is_active": "Активен в отделе", - } - widgets = { - "role": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "Например: Аналитик, Тимлид, Наставник", - "autocomplete": "off", - } - ), - "is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}), - } - - -# ========================= -# CRUD формы -# ========================= - - -class SkillForm(forms.ModelForm): - class Meta: - model = Skill - fields = ["name"] diff --git a/backend/employees/urls_front.py b/backend/employees/urls_front.py index 675c2743..a34c0957 100644 --- a/backend/employees/urls_front.py +++ b/backend/employees/urls_front.py @@ -13,11 +13,6 @@ urlpatterns = [ path("/edit/", views_front.employee_edit, name="employee_edit"), path("create/", views_front.employee_create, name="employee_create"), # --- Отделы --- - path( - "departments/add/", - views_front.DepartmentCreateView.as_view(), - name="department_add", - ), path( "departments//roles/edit/", views_front.edit_department_role, @@ -42,13 +37,36 @@ urlpatterns = [ views_front.set_member_role, name="set_member_role", ), + path( + "departments//add-member/", + views_front.department_add_member, + name="department_add_member", + ), + path( + "departments//remove-member/", + views_front.department_remove_member, + name="department_remove_member", + ), # --- Навыки --- path("/skill/add/", views_front.skill_add, name="skill_add"), path("/skill/remove/", views_front.skill_remove, name="skill_remove"), - # --- Должности --- - path("positions/create/", views_front.position_create_front, name="position_create"), - path("positions//update/", views_front.position_update_front, name="position_update"), - path("/set-position/", views_front.employee_set_position_front, name="employee_set_position"), - path("me/set-position/", views_front.employee_set_position_me_front, name="employee_set_position_me"), + path( + "positions/create/", views_front.position_create_front, name="position_create" + ), + path( + "positions//update/", + views_front.position_update_front, + name="position_update", + ), + path( + "/set-position/", + views_front.employee_set_position_front, + name="employee_set_position", + ), + path( + "me/set-position/", + views_front.employee_set_position_me_front, + name="employee_set_position_me", + ), ] diff --git a/backend/employees/views_auth.py b/backend/employees/views_auth.py index 7a44d258..ab65806a 100644 --- a/backend/employees/views_auth.py +++ b/backend/employees/views_auth.py @@ -14,7 +14,7 @@ from django.contrib.auth.views import \ from django.contrib.auth.views import \ PasswordResetView as DjangoPasswordResetView from django.shortcuts import redirect, render -from django.urls import path, reverse +from django.urls import reverse from django.utils.http import urlencode from django.views import View from django.views.decorators.http import require_http_methods diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 558fe68f..9dc37e48 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -5,30 +5,21 @@ from __future__ import annotations import base64 import json import mimetypes -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Tuple from api.client import get_api_client from api.decorators import require_api_auth from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.http import HttpRequest, JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse, reverse_lazy -from django.utils import timezone +from django.shortcuts import redirect, render +from django.urls import reverse from django.views.decorators.csrf import csrf_protect from django.views.decorators.http import require_http_methods, require_POST -from django.views.generic import CreateView, DeleteView, ListView, UpdateView -from .forms import ( - DepartmentEditForm, - DepartmentForm, - DepartmentMemberRoleForm, - InviteToDepartmentForm, - SkillForm, -) -from .forms_front import DepartmentEditForm, SetHeadForm, SetMemberRoleForm -from .models import Department, EmployeeDepartment, Skill + +from .forms_front import DepartmentEditForm, SetHeadForm + # ========================= # Утилиты @@ -70,20 +61,6 @@ def _extract_items(payload: Any): return [], 0, None, None -def _cleanup_payload(data: dict, keep_empty: set[str] = frozenset()) -> dict: - """ - Убираем None и пустые строки, КРОМЕ ключей из keep_empty. - """ - cleaned = {} - for k, v in data.items(): - if v is None: - continue - if v == "" and k not in keep_empty: - continue - cleaned[k] = v - return cleaned - - def _extract_results(payload): if isinstance(payload, dict): return payload.get("results", payload.get("items", [])) or [] @@ -149,16 +126,15 @@ def _has_role(link): def employee_list(request): """ Список сотрудников. - Ходит в /api/v1/employees/ c поддержкой поиска/сортировки/пагинации. + Если ?format=json — вернуть JSON (для автокомплита): + /employees/list/?format=json&q=...&exclude=1,2,3&limit=12 """ api = get_api_client(request) page = request.GET.get("page") or 1 ordering = request.GET.get("o") or "last_name" q = (request.GET.get("q") or "").strip() - department = request.GET.get( - "department" - ) # опциональный фильтр по отделу (из detail отдела) + department = request.GET.get("department") params = {"page": page, "ordering": ordering} if q: @@ -168,6 +144,8 @@ def employee_list(request): resp = api.get("v1/employees/", params=params) if not resp.ok: + if request.GET.get("format") == "json": + return JsonResponse({"results": [], "count": 0}, status=resp.status or 500) messages.error(request, f"Ошибка API ({resp.status})") return render( request, @@ -177,6 +155,32 @@ def employee_list(request): payload = resp.json or {} items, count, next_url, prev_url = _extract_items(payload) + + # JSON-ветка для автокомплита + if request.GET.get("format") == "json": + # exclude: "1,2,3" + raw_ex = (request.GET.get("exclude") or "").strip() + try: + ex_ids = {int(x) for x in raw_ex.split(",") if x.strip().isdigit()} + except Exception: + ex_ids = set() + + try: + limit = int(request.GET.get("limit") or 12) + except Exception: + limit = 12 + + # отфильтруем и обрежем на стороне фронт-прокси + def _id(e): + try: + return int(e.get("id")) + except Exception: + return -1 + + items = [e for e in items if _id(e) not in ex_ids][:limit] + return JsonResponse({"results": items, "count": len(items)}) + + # HTML-ветка (как было) context = { "employees": items, "count": count, @@ -465,24 +469,87 @@ def department_list(request): @require_api_auth def department_detail(request, pk: int): """ - Тонкая вьюха: берёт агрегированный контекст у API и рендерит шаблон. + Страница отдела. + + Тонкая фронт-вьюха: забирает агрегированный контекст у API + /api/v1/departments/{id}/ui-context/, дополняет его минимальными + метаданными для шаблона и рендерит страницу. + + В контекст добавляются: + - members_exclude_ids: список id сотрудников, которых нельзя + предлагать в "Добавить участника" (те, кто уже в отделе + руководитель) + - can_manage / can_change_head / can_assign_roles: плоские флаги, + чтобы шаблонам не ходить в вложенный user_perms. """ api = get_api_client(request) r = api.get(f"v1/departments/{pk}/ui-context/") if not r.ok: - messages.error(request, "Отдел не найден" if r.status == 404 else f"Ошибка API ({r.status})") + messages.error( + request, + "Отдел не найден" if r.status == 404 else f"Ошибка API ({r.status})", + ) return redirect("employees:department_list") - ctx = r.json if isinstance(r.json, dict) else {} - # Для обратной совместимости с шаблоном продублируем флаги как плоские поля: - perms = ctx.get("user_perms") or {} - ctx.update({ - "can_manage": perms.get("can_manage", False), - "can_change_head": perms.get("can_change_head", False), - "can_assign_roles": perms.get("can_assign_roles", False), - }) + # r.json в нашем api-клиенте может быть полем или методом — поддержим оба варианта + try: + ctx = ( + r.json + if isinstance(r.json, dict) + else (r.json() if callable(r.json) else {}) + ) + except Exception: + ctx = {} + + if not isinstance(ctx, dict): + ctx = {} + + perms = ( + (ctx.get("user_perms") or {}) if isinstance(ctx.get("user_perms"), dict) else {} + ) + + # ——— метаданные для автодополнения «Добавить участника» ——— + links = ctx.get("links") or [] + exclude_ids: list[int] = [] + for li in links: + emp = li.get("employee") if isinstance(li, dict) else None + if emp is None: + continue + # в агрегате employee может быть int (id) или объект + eid = emp.get("id") if isinstance(emp, dict) else emp + if eid: + try: + exclude_ids.append(int(eid)) + except (TypeError, ValueError): + pass + + # head тоже исключаем + dept = ctx.get("dept") or {} + head_id = dept.get("head_id") or ( + (dept.get("head") or {}).get("id") + if isinstance(dept.get("head"), dict) + else None + ) + if head_id: + try: + exclude_ids.append(int(head_id)) + except (TypeError, ValueError): + pass + + # уберём дубликаты, сохраняя порядок + seen = set() + exclude_ids = [x for x in exclude_ids if not (x in seen or seen.add(x))] + + ctx.update( + { + "members_exclude_ids": exclude_ids, + "can_manage": bool(perms.get("can_manage")), + "can_change_head": bool(perms.get("can_change_head")), + "can_assign_roles": bool(perms.get("can_assign_roles")), + } + ) return render(request, "employees/department_detail.html", ctx) + # ---------- EDIT BASIC FIELDS ---------- @require_api_auth @require_http_methods(["POST"]) @@ -591,33 +658,85 @@ def set_member_role(request, dept_id: int, emp_id: int): ) -class StaffOnlyMixin(UserPassesTestMixin): - def test_func(self): - return self.request.user.is_staff +@require_api_auth +@require_http_methods(["POST"]) +def department_add_member(request, pk: int): + """ + Прокси-вьюха: добавляет (активирует) сотрудника в отделе через API. - def handle_no_permission(self): - messages.error(self.request, "Недостаточно прав.") - return redirect("employees:department_list") + Ожидает: + POST form-data: employee_id + + Поведение: + - Делает POST в /api/v1/departments/{id}/add_member/ c {"employee_id": }. + - Сообщения об успехе/ошибке через django.contrib.messages. + - Редирект обратно на страницу отдела. + """ + api = get_api_client(request) + + raw = (request.POST.get("employee_id") or "").strip() + try: + employee_id = int(raw) + except (TypeError, ValueError): + messages.error(request, "Не выбран сотрудник.") + return redirect("employees:department_detail", pk=pk) + + resp = api.post( + f"v1/departments/{pk}/add_member/", json={"employee_id": employee_id} + ) + if resp.ok: + messages.success(request, "Сотрудник добавлен в отдел.") + else: + # аккуратно вытащим текст ошибки + detail = "" + try: + data = resp.json or {} + detail = data.get("detail") or data.get("error") or "" + except Exception: + detail = resp.text or "" + messages.error(request, f"Не удалось добавить: {resp.status} {detail}".strip()) + + return redirect("employees:department_detail", pk=pk) -class DepartmentCreateView(LoginRequiredMixin, StaffOnlyMixin, CreateView): - model = Department - form_class = DepartmentForm - template_name = "employees/department_form.html" - success_url = reverse_lazy("employees:department_list") +@require_api_auth +@require_http_methods(["POST"]) +def department_remove_member(request, pk: int): + """ + Прокси-вьюха: деактивирует (удаляет) сотрудника из отдела через API. + Ожидает: + POST form-data: employee_id -class DepartmentUpdateView(LoginRequiredMixin, StaffOnlyMixin, UpdateView): - model = Department - form_class = DepartmentForm - template_name = "employees/department_form.html" - success_url = reverse_lazy("employees:department_list") + Поведение: + - Делает POST в /api/v1/departments/{id}/remove_member/ c {"employee_id": }. + - Не изменяет роль (эта логика на API и отделена). + - Сообщения об успехе/ошибке через django.contrib.messages. + - Редирект обратно на страницу отдела. + """ + api = get_api_client(request) + raw = (request.POST.get("employee_id") or "").strip() + try: + employee_id = int(raw) + except (TypeError, ValueError): + messages.error(request, "Не выбран сотрудник.") + return redirect("employees:department_detail", pk=pk) -class DepartmentDeleteView(LoginRequiredMixin, StaffOnlyMixin, DeleteView): - model = Department - template_name = "employees/department_confirm_delete.html" - success_url = reverse_lazy("employees:department_list") + resp = api.post( + f"v1/departments/{pk}/remove_member/", json={"employee_id": employee_id} + ) + if resp.ok: + messages.success(request, "Сотрудник удалён из отдела.") + else: + try: + data = resp.json or {} + detail = data.get("detail") or data.get("error") or "" + except Exception: + detail = resp.text or "" + messages.error(request, f"Не удалось удалить: {resp.status} {detail}".strip()) + + return redirect("employees:department_detail", pk=pk) @require_api_auth @@ -625,8 +744,8 @@ class DepartmentDeleteView(LoginRequiredMixin, StaffOnlyMixin, DeleteView): def edit_department_role(request, pk: int): """ Универсальный обработчик ролей отдела: - - op=create : POST v1/department-roles/ {department: pk, name} - - op=update : PATCH v1/department-roles// {name} + - op=create : POST v1/department-roles/ {department: pk, name} + сразу POST set_perms/ + - op=update : PATCH v1/department-roles// {name} + POST set_perms/ - op=delete : DELETE v1/department-roles// """ api = get_api_client(request) @@ -638,7 +757,9 @@ def edit_department_role(request, pk: int): messages.error(request, "Отдел не найден.") return redirect("employees:department_detail", pk=pk) dept = dept_resp.json - allowed = request.user.is_staff or (dept.get("head") and dept["head"].get("id") == request.user.id) + allowed = request.user.is_staff or ( + dept.get("head") and dept["head"].get("id") == request.user.id + ) if not allowed: messages.error(request, "Недостаточно прав для управления ролями отдела.") return redirect("employees:department_detail", pk=pk) @@ -650,29 +771,58 @@ def edit_department_role(request, pk: int): messages.error(request, "Название роли не может быть пустым.") return redirect("employees:department_detail", pk=pk) - resp = api.post("v1/department-roles/", json={"department": pk, "name": name}) + # 1) создаём роль + resp = api.post( + "v1/department-roles/", json={"department": pk, "name": name} + ) if resp.ok: + # 2) сразу выставляем права, если они пришли из формы + role_id = (resp.json or {}).get("id") + perm_ids = [ + int(x) + for x in request.POST.getlist("permission_ids") + if str(x).isdigit() + ] + if role_id is not None: + # ВАЖНО: у set_perms — хвостовой слэш! + api.post( + f"v1/department-roles/{role_id}/set_perms/", + json={"permission_ids": perm_ids}, + ) + messages.success(request, f"Роль «{name}» создана.") else: - messages.error(request, f"Ошибка создания роли: {getattr(resp, 'status', '')} {resp.text or ''}") + messages.error( + request, + f"Ошибка создания роли: {getattr(resp, 'status', '')} {resp.text or ''}", + ) elif op == "update": role_id = request.POST.get("role_id") name = (request.POST.get("name") or "").strip() - # 1) Сохраняем имя + # 1) имя if name: api.patch(f"v1/department-roles/{role_id}/", json={"name": name}) - # 2) Сохраняем права роли - perm_ids = request.POST.getlist("permission_ids") # из чекбоксов - perm_ids = [int(x) for x in perm_ids if str(x).isdigit()] - resp = api.post(f"v1/department-roles/{role_id}/set_perms", json={"permission_ids": perm_ids}) + # 2) права (с хвостовым слэшем!) + perm_ids = [ + int(x) + for x in request.POST.getlist("permission_ids") + if str(x).isdigit() + ] + resp = api.post( + f"v1/department-roles/{role_id}/set_perms/", + json={"permission_ids": perm_ids}, + ) if resp.ok: messages.success(request, f"Роль обновлена: «{name}».") else: - messages.error(request, f"Ошибка обновления роли: {getattr(resp, 'status', '')} {resp.text or ''}") + messages.error( + request, + f"Ошибка обновления роли: {getattr(resp, 'status', '')} {resp.text or ''}", + ) elif op == "delete": role_id = request.POST.get("role_id") @@ -684,7 +834,10 @@ def edit_department_role(request, pk: int): if resp.ok or getattr(resp, "status", None) == 204: messages.success(request, "Роль удалена.") else: - messages.error(request, f"Ошибка удаления роли: {getattr(resp, 'status', '')} {resp.text or ''}") + messages.error( + request, + f"Ошибка удаления роли: {getattr(resp, 'status', '')} {resp.text or ''}", + ) else: messages.error(request, "Неизвестная операция.") @@ -693,49 +846,6 @@ def edit_department_role(request, pk: int): return redirect("employees:department_detail", pk=pk) -# ---------- Приглашение в отдел ---------- - - -@login_required -def invite_to_department(request, pk): - department = get_object_or_404(Department, pk=pk) - - if not (request.user.is_staff or request.user == department.head): - messages.error(request, "Нет прав приглашать сотрудников.") - return redirect("employees:department_detail", pk=pk) - - if request.method == "POST": - form = InviteToDepartmentForm(department, request.POST) - if form.is_valid(): - employee = form.cleaned_data["employee"] - - emp_dep, created = EmployeeDepartment.objects.get_or_create( - employee=employee, - department=department, - defaults={"is_active": True, "date_from": timezone.now().date()}, - ) - if not created and not emp_dep.is_active: - emp_dep.is_active = True - emp_dep.date_from = timezone.now().date() - emp_dep.date_to = None - emp_dep.save() - - messages.success(request, f"{employee.get_full_name()} приглашён в отдел!") - return redirect("employees:department_detail", pk=pk) - else: - form = InviteToDepartmentForm(department) - if not form.fields["employee"].queryset.exists(): - messages.info( - request, - "Нет сотрудников, которых можно пригласить — похоже, все уже в отделе.", - ) - - return render( - request, - "employees/invite_to_department.html", - {"form": form, "department": department}, - ) - # ========================= # Навыки diff --git a/backend/templates/employees/_department_controls.html b/backend/templates/employees/_department_controls.html index d6fcd765..69f04a10 100644 --- a/backend/templates/employees/_department_controls.html +++ b/backend/templates/employees/_department_controls.html @@ -1,15 +1,9 @@ {# backend/templates/employees/_department_controls.html #} -{# Ожидаемый контекст: - dept: {id, name, description, head?} - head_choices: [{id, name}] # список кандидатов - roles: [{id, name, permissions: [ids]}] # для совместимости: ids выбранных прав - dept_perm_choices: [{id, code, name}] # справочник прав для ролей - can_manage: bool - can_assign_roles: bool - can_change_head: bool -#} +{# Контекст:#} + {# dept, head_choices, roles, dept_perm_choices,#} + {# can_manage, can_assign_roles, can_change_head#} + -{# ===== 1) Управление отделом ===== #} {% if can_manage %}
@@ -23,21 +17,94 @@
{% csrf_token %} -
-
- - +
+ +
+ +
-
- - + rows="3" + data-autogrow + data-max-height="240" + style="overflow:hidden;">{{ dept.description|default_if_none:'' }}
-
+ +
+ +
+ +
+ +
+
+{% endif %} + + + +{% if can_change_head %} +
+
+ +
+
Руководитель отдела
+
Назначение / снятие
+
+
+ +
+
+ {% csrf_token %} + +
+
+ + +
+ + + + + + + + + + +
Выберите сотрудника из списка, введите точный email или очистите поле.
+
+ +
Очистите поле и сохраните, чтобы снять руководителя.
+
+ +
@@ -48,484 +115,634 @@
{% endif %} -{# ===== 2) Роли отдела ===== #} + + {% if can_assign_roles %} -
-
- -
Роли отдела
-
- -
-
- - {# === РЕДАКТИРОВАТЬ РОЛЬ (одна кнопка "Сохранить изменения") === #} -
- - {% csrf_token %} - - -
-
- - -
-
- - -
- -
-
- -
-
- {# сюда JS при сохранении подложит скрытые permission_ids (если менялись) #} -
-
- -
- - -
-
- -
- - {# === СОЗДАТЬ РОЛЬ (панель показывается по кнопке) === #} -
- - - -
-
- {% csrf_token %} - -
-
- - -
- -
-
- -
-
- {# сюда JS перед сабмитом положит #} -
-
- -
- - -
-
-
-
-
- -
{# /row #} -
-
- - {# === МОДАЛ: выбор прав (редактирование) === #} - - - {# === МОДАЛ: выбор прав (создание) === #} - - - -{% endif %} - -{# ===== 3) Назначение руководителя ===== #} -{% if can_change_head %}
- +
-
Назначение руководителя
-
Выбор или снятие руководителя отдела
+
Роли отдела
+
Редактирование и создание
-
- {% csrf_token %} -
-
- - +
+ + + + {% csrf_token %} + + + +
+ +
+
+ + +
+ + + + + + + + + + +
Выберите роль из списка или введите новое название для создания.
+
+
+ +
+ +
+
+ +
+ + + +
-
- + + +
+ + +
+ {% csrf_token %} + + +
+ +
+
+ + +
+ +
+ +
+
+ +
+ + +
-
- + + +
{% endif %} -{# Если нет ни одного права — информируем #} + + {% if not can_manage and not can_assign_roles and not can_change_head %} -
- У вас нет прав для управления этим отделом. -
+
У вас нет прав для управления этим отделом.
{% endif %} + +{# ===== Модальные окна выбора прав ===== #} + + + + +{# ===== Форма удаления роли (постит на edit_department_role/) ===== #} +
+ {% csrf_token %} + + +
+ + + + + \ No newline at end of file diff --git a/backend/templates/employees/department_detail.html b/backend/templates/employees/department_detail.html index 37ed5325..57d48047 100644 --- a/backend/templates/employees/department_detail.html +++ b/backend/templates/employees/department_detail.html @@ -15,34 +15,37 @@

{{ dept.name }}

- {# Кнопки только для staff или текущего руководителя #} - {% if user.is_staff or dept.head and dept.head.id == user.id %} -
- {% url 'feed:post_create' as post_create_url %} - {% if post_create_url %} - - Создать публикацию - - {% endif %} - {% url 'employees:invite_to_department' dept_id as invite_url %} - {% if invite_url %} - - Пригласить - - {% endif %} -
- {% endif %} +
+ {% url 'feed:post_create' as post_create_url %} + {% if post_create_url %} + + Создать публикацию + + {% endif %} + {% url 'employees:invite_to_department' dept_id as invite_url %} + {% if user.is_staff or user.is_superuser or can_manage or dept.head and dept.head.id == user.id %} + + {% endif %} +
{# ===== Инфо-блок ===== #} {% if dept.description or dept.head %} -
+
{% if dept.description %} -
{{ dept.description }}
+
+ {{ dept.description }} +
{% endif %} + {% if dept.head %} -
+
Руководитель: {% url 'employees:employee_detail' dept.head.id as head_url %} {% if head_url %} @@ -54,94 +57,127 @@ {% endif %}
{% endif %} + + {# «голая» шестерёнка, немного отступлена от краёв #} +
{% endif %} {# ===== Управление отделом (для staff/главы) ===== #} {% if user.is_staff or dept.head and dept.head.id == user.id %} - {% include "employees/_department_controls.html" with dept=dept edit_form=edit_form head_choices=head_choices %} - +
+ {% include "employees/_department_controls.html" with dept=dept edit_form=edit_form head_choices=head_choices %} +
{% endif %} - {# ===== Команда отдела (только сотрудники с назначённой ролью) ===== #} -
-
-
Команда отдела
- {% url 'employees:employees_list' as employees_list_url %} - {% if employees_list_url %} - - Все сотрудники → - - {% endif %} -
+ {# ===== Команда отдела — как в "Профиль" + две кнопки ===== #} +
+
+ +
+
Команда отдела
+
Участники и их роли
+
- {# показываем только те связи, где есть role #} - {% if links %} - {% with any_has_role=0 %} - {% for li in links %}{% if li.role %}{% with any_has_role=1 %}{% endwith %}{% endif %}{% endfor %} - {% endwith %} - {% endif %} +
+ {% url 'employees:employees_list' as employees_list_url %} + {% if employees_list_url %} + + Все сотрудники → + + {% endif %} -
- {% for link in links %} - {% if link.role %} - {% with emp=link.employee cur_role_id=link.role.id|default:link.role %} -
-
-
- {% if emp.avatar %}{% else %}{% endif %} -
-
- -
- {% if link.role.name %}{{ link.role.name }}{% else %}Роль #{{ cur_role_id }}{% endif %} -
-
-
+ {% if user.is_staff or user.is_superuser or can_manage or dept.head and dept.head.id == user.id %} + - {# Право менять роль: staff или глава; и не себе #} - {% if user.is_staff or dept.head and dept.head.id == user.id %} - {% if emp.id != user.id %} - {% with eid=emp.id|default:emp %} -
- {% csrf_token %} - - - -
- {% endwith %} + + {% endif %} +
+
+ +
+
+ {% for link in links %} + {% if link.role %} + {% with emp=link.employee cur_role_id=link.role.id|default:link.role %} + {% with eid=emp.id|default:emp %} +
+ + +
+ + {% if link.role.name %}{{ link.role.name }}{% else %}Роль #{{ cur_role_id }}{% endif %} + + + {% if user.is_staff or user.is_superuser or dept.head and dept.head.id == user.id %} + {% if roles %} +
+ {% csrf_token %} + + + +
{% endif %} {% endif %}
- {% endwith %} - {% endif %} - {% empty %} -
Пока нет сотрудников с назначёнными ролями.
- {% endfor %} +
+ {% endwith %} + {% endwith %} + {% endif %} + {% empty %} +
+
—
+
Пока нет сотрудников с назначёнными ролями.
-
+ {% endfor %} +
+
+
+ + + {# ===== Лента отдела ===== #}
@@ -164,5 +200,394 @@
+{# ---------- МОДАЛ «Удалить участника» ---------- #} +{% if user.is_staff or user.is_superuser or can_manage or dept.head and dept.head.id == user.id %} + +{% endif %} + +{# ---------- МОДАЛ «Добавить участника» ---------- #} +{% if user.is_staff or user.is_superuser or dept.head and dept.head.id == user.id %} + {% with exclude_ids=members_exclude_ids %} + + {% endwith %} +{% endif %} {% endwith %} {% endblock %} + +{% block extra_js %} + + +{% if user.is_staff or user.is_superuser or dept.head and dept.head.id == user.id %} +{% url 'employees:employees_list' as employees_list_url %} + + + + +{% if user.is_staff or user.is_superuser or dept.head and dept.head.id == user.id %} +{% url 'employees:employees_list' as employees_list_url %} + + + +{% endif %} + + +{% endif %} + + +{% endblock %} diff --git a/backend/tests/api/v1/employees/test_department_membership_separation.py b/backend/tests/api/v1/employees/test_department_membership_separation.py new file mode 100644 index 00000000..4462b937 --- /dev/null +++ b/backend/tests/api/v1/employees/test_department_membership_separation.py @@ -0,0 +1,378 @@ +# backend/tests/api/v1/employees/test_department_membership_separation.py +import hashlib +import pytest +from rest_framework.test import APIClient +from rest_framework import status + +from employees.models import ( + Department, + DepartmentRole, + DepartmentPermission, + EmployeeDepartment, + Employee, +) + + +# ========================= +# Helpers +# ========================= + + +def _unique_phone_from_email(email: str) -> str: + """ + Возвращает детерминированный уникальный номер телефона в формате E.164 + на основе email. Гарантирует уникальность в пределах набора тестов. + """ + n = int(hashlib.sha256(email.encode("utf-8")).hexdigest(), 16) % 10**9 + # "+79" + 9 цифр = 11-значный номер РФ + return f"+79{n:09d}" + + +def make_user(email: str, *, staff: bool = False, superuser: bool = False) -> Employee: + """ + Фабрика пользователя для тестов. Учитывает ограничение уникальности phone_number. + """ + return Employee.objects.create( + email=email, + phone_number=_unique_phone_from_email(email), + first_name="T", + last_name="U", + is_staff=staff, + is_superuser=superuser, + email_verified=True, + is_active=True, + ) + + +def ensure_dept_perm(code: str, name: str | None = None) -> DepartmentPermission: + """ + Создаёт (или возвращает) департаментское право с данным кодом. + """ + return DepartmentPermission.objects.get_or_create( + code=code, defaults={"name": name or code} + )[0] + + +def make_role( + dept: Department, name: str, codes: list[str] | None = None +) -> DepartmentRole: + """ + Создаёт роль отдела и навешивает на неё (опционально) набор департаментских прав. + """ + role = DepartmentRole.objects.create(department=dept, name=name) + if codes: + perms = [ensure_dept_perm(c) for c in codes] + role.scoped_permissions.add(*perms) + return role + + +def grant_manage_in_dept(user: Employee, dept: Department) -> DepartmentRole: + """ + Выдаёт пользователю роль в отделе с правом manage_department. + """ + role = make_role(dept, "manager", ["manage_department"]) + EmployeeDepartment.objects.update_or_create( + employee=user, + department=dept, + defaults={"is_active": True, "role": role}, + ) + return role + + +def grant_assign_in_dept(user: Employee, dept: Department) -> DepartmentRole: + """ + Выдаёт пользователю роль в отделе с правом assign_department_role. + """ + role = make_role(dept, "assigner", ["assign_department_role"]) + EmployeeDepartment.objects.update_or_create( + employee=user, + department=dept, + defaults={"is_active": True, "role": role}, + ) + return role + + +# Утилиты URL с обязательным хвостовым слэшем (APPEND_SLASH=True) +def url_add_member(dept_id: int) -> str: + """POST /api/v1/departments/{id}/add_member/""" + return f"/api/v1/departments/{dept_id}/add_member/" + + +def url_remove_member(dept_id: int) -> str: + """POST /api/v1/departments/{id}/remove_member/""" + return f"/api/v1/departments/{dept_id}/remove_member/" + + +def url_set_member_role(dept_id: int) -> str: + """POST /api/v1/departments/{id}/set_member_role/""" + return f"/api/v1/departments/{dept_id}/set_member_role/" + + +# ========================= +# Tests: add_member / remove_member +# ========================= + + +@pytest.mark.django_db +def test_add_member_requires_manage_and_does_not_assign_role(api_client: APIClient): + """ + add_member: + - 401 без авторизации + - 403 с правом assign_department_role (но без manage_department) + - 200 с manage_department + - не назначает роль (role остаётся None) + """ + d = Department.objects.create(name="Dept") + target = make_user("target@example.com") + url = url_add_member(d.pk) + + # 401 + resp = api_client.post(url, {"employee_id": target.id}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + # 403: есть только право назначать роли + assigner = make_user("assigner@example.com") + api_client.force_authenticate(assigner) + grant_assign_in_dept(assigner, d) + resp = api_client.post(url, {"employee_id": target.id}, format="json") + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # 200: manage_department + manager = make_user("manager@example.com") + api_client.force_authenticate(manager) + grant_manage_in_dept(manager, d) + resp = api_client.post(url, {"employee_id": target.id}, format="json") + assert resp.status_code == status.HTTP_200_OK + + link = EmployeeDepartment.objects.get(employee_id=target.id, department_id=d.id) + assert link.is_active is True + assert link.role_id is None # add_member не должен трогать роль + + +@pytest.mark.django_db +def test_remove_member_requires_manage_and_does_not_touch_role(api_client: APIClient): + """ + remove_member: + - 403 при отсутствии manage_department (даже если есть assign_department_role) + - 200 с manage_department + - не трогает role (оставляет как есть) + """ + d = Department.objects.create(name="Dept") + target = make_user("target2@example.com") + some_role = make_role(d, "any") + EmployeeDepartment.objects.create( + employee=target, department=d, is_active=True, role=some_role + ) + + url = url_remove_member(d.pk) + + assigner = make_user("assigner2@example.com") + api_client.force_authenticate(assigner) + grant_assign_in_dept(assigner, d) + resp = api_client.post(url, {"employee_id": target.id}, format="json") + assert resp.status_code == status.HTTP_403_FORBIDDEN + + manager = make_user("manager2@example.com") + api_client.force_authenticate(manager) + grant_manage_in_dept(manager, d) + resp = api_client.post(url, {"employee_id": target.id}, format="json") + assert resp.status_code == status.HTTP_200_OK + + link = EmployeeDepartment.objects.get(employee=target, department=d) + assert link.is_active is False + assert link.role_id == some_role.id # роль не менялась + + +# ========================= +# Tests: set_member_role (строгое разделение) +# ========================= + + +@pytest.mark.django_db +def test_set_member_role_requires_assign_not_manage(api_client: APIClient): + """ + set_member_role: + - 403 с manage_department, если нет assign_department_role + - 200 с assign_department_role + """ + d = Department.objects.create(name="Dept") + worker = make_user("worker@example.com") + EmployeeDepartment.objects.create(employee=worker, department=d, is_active=True) + role = make_role(d, "Worker") + + # 403: только manage_department + manager = make_user("manager3@example.com") + api_client.force_authenticate(manager) + grant_manage_in_dept(manager, d) + resp = api_client.post( + url_set_member_role(d.pk), + {"employee_id": worker.id, "role_id": role.id}, + format="json", + ) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # 200: assign_department_role + assigner = make_user("assigner3@example.com") + api_client.force_authenticate(assigner) + grant_assign_in_dept(assigner, d) + resp = api_client.post( + url_set_member_role(d.pk), + {"employee_id": worker.id, "role_id": role.id}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + + link = EmployeeDepartment.objects.get(employee=worker, department=d) + assert link.role_id == role.id + + +@pytest.mark.django_db +def test_set_member_role_does_not_create_membership_and_does_not_toggle_active( + api_client: APIClient, +): + """ + Проверяем отсутствие пересечения функционала у set_member_role: + - не создаёт членство, если связи нет (ожидаем ошибку) + - не меняет is_active (игнорирует попытки передать is_active) + """ + d = Department.objects.create(name="Dept") + role = make_role(d, "Worker") + + outsider = make_user("outsider@example.com") + + assigner = make_user("assigner4@example.com") + api_client.force_authenticate(assigner) + grant_assign_in_dept(assigner, d) + + # 1) Нельзя создать членство через set_member_role + resp = api_client.post( + url_set_member_role(d.pk), + {"employee_id": outsider.id, "role_id": role.id}, + format="json", + ) + assert resp.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND) + assert not EmployeeDepartment.objects.filter( + employee=outsider, department=d + ).exists() + + # 2) is_active не должен переключаться через set_member_role + worker = make_user("worker2@example.com") + link = EmployeeDepartment.objects.create( + employee=worker, department=d, is_active=True + ) + resp = api_client.post( + url_set_member_role(d.pk), + {"employee_id": worker.id, "role_id": role.id, "is_active": False}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + link.refresh_from_db() + assert link.is_active is True, "set_member_role не должен менять is_active" + assert link.role_id == role.id + + +# ========================= +# Доп. тест: авторизация и отсутствие пересечений +# ========================= + + +@pytest.mark.django_db +def test_all_three_actions_require_auth_and_permissions(api_client: APIClient): + """ + Ещё одна сетка проверок: + - без авторизации все три эндпоинта возвращают 401 + - пользователь с manage_department не может set_member_role (403) + - пользователь с assign_department_role не может add/remove (403) + """ + d = Department.objects.create(name="Dept") + worker = make_user("worker3@example.com") + EmployeeDepartment.objects.create(employee=worker, department=d, is_active=True) + role = make_role(d, "WorkerX") + + # 401 без авторизации + for url, payload in [ + (url_add_member(d.pk), {"employee_id": worker.id}), + (url_remove_member(d.pk), {"employee_id": worker.id}), + (url_set_member_role(d.pk), {"employee_id": worker.id, "role_id": role.id}), + ]: + resp = api_client.post(url, payload, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + # Менеджер отдела + manager = make_user("manager4@example.com") + grant_manage_in_dept(manager, d) + api_client.force_authenticate(manager) + # может add/remove + assert ( + api_client.post( + url_add_member(d.pk), {"employee_id": worker.id}, format="json" + ).status_code + == 200 + ) + assert ( + api_client.post( + url_remove_member(d.pk), {"employee_id": worker.id}, format="json" + ).status_code + == 200 + ) + # но не может set_member_role + assert ( + api_client.post( + url_set_member_role(d.pk), + {"employee_id": worker.id, "role_id": role.id}, + format="json", + ).status_code + == 403 + ) + + # Назначающий роли + assigner = make_user("assigner5@example.com") + grant_assign_in_dept(assigner, d) + api_client.force_authenticate(assigner) + # может set_member_role + assert ( + api_client.post( + url_set_member_role(d.pk), + {"employee_id": worker.id, "role_id": role.id}, + format="json", + ).status_code + == 200 + ) + # но не может add/remove + assert ( + api_client.post( + url_add_member(d.pk), {"employee_id": worker.id}, format="json" + ).status_code + == 403 + ) + assert ( + api_client.post( + url_remove_member(d.pk), {"employee_id": worker.id}, format="json" + ).status_code + == 403 + ) + + +@pytest.mark.django_db +def test_add_member_ignores_role_parameter(api_client: APIClient): + """ + add_member не должен назначать роль даже если клиент прислал role_id. + """ + d = Department.objects.create(name="Dept") + target = make_user("roleless@example.com") + role = make_role(d, "ShouldNotBeAssigned") + + manager = make_user("managerX@example.com") + api_client.force_authenticate(manager) + grant_manage_in_dept(manager, d) + + resp = api_client.post( + url_add_member(d.pk), + {"employee_id": target.id, "role_id": role.id}, # попытка подсунуть роль + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + + link = EmployeeDepartment.objects.get(employee=target, department=d) + assert link.is_active is True + assert link.role_id is None, "add_member не должен трогать роль"