feat(employees): управление составом отдела + разделение прав; фронт-модалки добавления/удаления; фиксы UI/тесты

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: нет
This commit is contained in:
admin i 2025-09-04 17:10:27 +03:00
parent 3f0ff230ca
commit 1c0523fd55
10 changed files with 2031 additions and 994 deletions

Binary file not shown.

View File

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

View File

@ -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": <int>, "name": <display_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": <int>, "role_id": <int|null> }
Права:
AssignRolePerm (DeptPerm.ASSIGN_ROLE)
Ответ:
200 {"employee_id":..., "role_id": <int|null>, "is_active": <bool>}
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": <int> }
Права:
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": <int> }
Права:
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):
"""

View File

@ -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:<mime>;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"]

View File

@ -13,11 +13,6 @@ urlpatterns = [
path("<int:pk>/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/<int:pk>/roles/edit/",
views_front.edit_department_role,
@ -42,13 +37,36 @@ urlpatterns = [
views_front.set_member_role,
name="set_member_role",
),
path(
"departments/<int:pk>/add-member/",
views_front.department_add_member,
name="department_add_member",
),
path(
"departments/<int:pk>/remove-member/",
views_front.department_remove_member,
name="department_remove_member",
),
# --- Навыки ---
path("<int:pk>/skill/add/", views_front.skill_add, name="skill_add"),
path("<int:pk>/skill/remove/", views_front.skill_remove, name="skill_remove"),
# --- Должности ---
path("positions/create/", views_front.position_create_front, name="position_create"),
path("positions/<int:pos_id>/update/", views_front.position_update_front, name="position_update"),
path("<int:emp_id>/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/<int:pos_id>/update/",
views_front.position_update_front,
name="position_update",
),
path(
"<int:emp_id>/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",
),
]

View File

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

View File

@ -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": <int>}.
- Сообщения об успехе/ошибке через 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": <int>}.
- Не изменяет роль (эта логика на 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/<role_id>/ {name}
- op=create : POST v1/department-roles/ {department: pk, name} + сразу POST set_perms/
- op=update : PATCH v1/department-roles/<role_id>/ {name} + POST set_perms/
- op=delete : DELETE v1/department-roles/<role_id>/
"""
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},
)
# =========================
# Навыки

File diff suppressed because it is too large Load Diff

View File

@ -15,34 +15,37 @@
<i class="bi-diagram-3 fs-2 text-primary"></i>
<h2 class="title mb-0">{{ dept.name }}</h2>
{# Кнопки только для staff или текущего руководителя #}
{% if user.is_staff or dept.head and dept.head.id == user.id %}
<div class="ms-auto d-flex gap-2">
{% url 'feed:post_create' as post_create_url %}
{% if post_create_url %}
<a href="{{ post_create_url }}?department={{ dept_id }}&next={{ request.get_full_path|urlencode }}"
class="btn btn-primary">
<i class="bi-plus-circle me-1"></i> Создать публикацию
</a>
{% endif %}
{% url 'employees:invite_to_department' dept_id as invite_url %}
{% if invite_url %}
<a href="{{ invite_url }}" class="btn btn-success">
<i class="bi-person-plus me-1"></i> Пригласить
</a>
{% endif %}
</div>
{% endif %}
<div class="ms-auto d-flex gap-2">
{% url 'feed:post_create' as post_create_url %}
{% if post_create_url %}
<a href="{{ post_create_url }}?department={{ dept_id }}&next={{ request.get_full_path|urlencode }}"
class="btn btn-primary">
<i class="bi-plus-circle me-1"></i> Создать публикацию
</a>
{% 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 %}
<button type="button"
class="btn btn-success btn-sm d-flex align-items-center"
data-bs-toggle="modal"
data-bs-target="#addMemberModal-{{ dept.id }}">
<i class="bi-person-plus me-1"></i>Пригласить
</button>
{% endif %}
</div>
</div>
{# ===== Инфо-блок ===== #}
{% if dept.description or dept.head %}
<div class="feed-new mb-3">
<div class="feed-new mb-3 position-relative pb-4 pe-3 pt-0">
{% if dept.description %}
<div class="mb-1">{{ dept.description }}</div>
<div class="text-break" style="white-space: pre-line; overflow-wrap:anywhere;">
{{ dept.description }}
</div>
{% endif %}
{% if dept.head %}
<div class="small">
<div class="small mt-2">
<span class="text-secondary">Руководитель:</span>
{% url 'employees:employee_detail' dept.head.id as head_url %}
{% if head_url %}
@ -54,94 +57,127 @@
{% endif %}
</div>
{% endif %}
{# «голая» шестерёнка, немного отступлена от краёв #}
<button
id="deptSettingsToggle-{{ dept.id }}"
type="button"
class="position-absolute p-0 bg-transparent border-0 text-secondary"
style="right:.5rem; bottom:.5rem;"
aria-controls="deptControls-{{ dept.id }}"
aria-expanded="false"
title="Настройки отдела"
aria-label="Показать/скрыть настройки отдела">
<i class="bi-gear fs-5"></i>
</button>
</div>
{% 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 %}
<div id="deptControls-{{ dept.id }}" class="d-none">
{% include "employees/_department_controls.html" with dept=dept edit_form=edit_form head_choices=head_choices %}
</div>
{% endif %}
{# ===== Команда отдела (только сотрудники с назначённой ролью) ===== #}
<div class="feed-new mb-4">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="mb-0 text-secondary">Команда отдела</h6>
{% url 'employees:employees_list' as employees_list_url %}
{% if employees_list_url %}
<a class="small text-primary text-decoration-none"
href="{{ employees_list_url }}?department={{ dept_id }}">
Все сотрудники →
</a>
{% endif %}
</div>
{# ===== Команда отдела — как в "Профиль" + две кнопки ===== #}
<section class="feed-card mb-4">
<div class="feed-hd">
<i class="bi-people"></i>
<div class="feed-meta">
<div class="feed-author">Команда отдела</div>
<div class="feed-sub">Участники и их роли</div>
</div>
{# показываем только те связи, где есть 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 %}
<div class="ms-auto d-flex align-items-center gap-2">
{% url 'employees:employees_list' as employees_list_url %}
{% if employees_list_url %}
<a class="btn btn-link btn-sm text-decoration-none"
href="{{ employees_list_url }}?department={{ dept_id }}">
Все сотрудники →
</a>
{% endif %}
<div class="d-flex flex-column gap-2">
{% for link in links %}
{% if link.role %}
{% with emp=link.employee cur_role_id=link.role.id|default:link.role %}
<div class="d-flex align-items-center justify-content-between p-2 border rounded">
<div class="d-flex align-items-center gap-2">
<div class="feed-ava" style="width:44px;height:44px;">
{% if emp.avatar %}<img src="{{ emp.avatar }}" alt="">{% else %}<i class="bi-person"></i>{% endif %}
</div>
<div>
<div class="fw-semibold">
{% with eid=emp.id|default:emp %}
<a href="{% url 'employees:employee_detail' eid %}"
class="text-decoration-none text-reset">
{% if emp.last_name %}
{{ emp.last_name }}{% if emp.first_name %} {{ emp.first_name }}{% endif %}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
{% else %}
Сотрудник #{{ eid }}
{% endif %}
</a>
{% endwith %}
</div>
<div class="text-secondary small">
{% if link.role.name %}{{ link.role.name }}{% else %}Роль #{{ cur_role_id }}{% endif %}
</div>
</div>
</div>
{% if user.is_staff or user.is_superuser or can_manage or dept.head and dept.head.id == user.id %}
<button type="button"
class="btn btn-success btn-sm d-flex align-items-center"
data-bs-toggle="modal"
data-bs-target="#addMemberModal-{{ dept.id }}">
<i class="bi-person-plus me-1"></i>Пригласить
</button>
{# Право менять роль: 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 %}
<form method="post"
action="{% url 'employees:set_member_role' dept.id eid %}"
class="d-flex align-items-center gap-2 mb-0">
{% csrf_token %}
<select name="role_id" class="form-select form-select-sm">
<option value="">— Без роли —</option>
{% for r in roles %}
<option value="{{ r.id }}"
{% if cur_role_id == r.id %}selected{% endif %}>
{{ r.name }}
</option>
{% endfor %}
</select>
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="btn btn-sm btn-outline-primary">OK</button>
</form>
{% endwith %}
<button type="button"
class="btn btn-outline-danger btn-sm d-flex align-items-center"
data-bs-toggle="modal"
data-bs-target="#removeMemberModal-{{ dept.id }}">
<i class="bi-person-dash me-1"></i>Удалить
</button>
{% endif %}
</div>
</div>
<div class="feed-body">
<div class="feed-info">
{% 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 %}
<div class="info-row align-items-center">
<div class="info-k d-flex align-items-center gap-2">
<span class="feed-ava" style="width:44px;height:44px;">
{% if emp.avatar %}<img src="{{ emp.avatar }}" alt="">{% else %}<i class="bi-person"></i>{% endif %}
</span>
<a href="{% url 'employees:employee_detail' eid %}"
class="text-decoration-none">
{% if emp.last_name %}
{{ emp.last_name }}{% if emp.first_name %} {{ emp.first_name }}{% endif %}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
{% else %}
Сотрудник #{{ eid }}
{% endif %}
</a>
</div>
<div class="info-v d-flex align-items-center flex-wrap gap-2 justify-content-end">
<span class="text-secondary small me-2">
{% if link.role.name %}{{ link.role.name }}{% else %}Роль #{{ cur_role_id }}{% endif %}
</span>
{% if user.is_staff or user.is_superuser or dept.head and dept.head.id == user.id %}
{% if roles %}
<form method="post"
action="{% url 'employees:set_member_role' dept.id eid %}"
class="d-flex align-items-center gap-2 mb-0">
{% csrf_token %}
<select name="role_id" class="form-select form-select-sm">
<option value="">— Без роли —</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if cur_role_id == r.id %}selected{% endif %}>
{{ r.name }}
</option>
{% endfor %}
</select>
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="btn btn-outline-primary btn-sm">OK</button>
</form>
{% endif %}
{% endif %}
</div>
{% endwith %}
{% endif %}
{% empty %}
<div class="text-secondary small">Пока нет сотрудников с назначёнными ролями.</div>
{% endfor %}
</div>
{% endwith %}
{% endwith %}
{% endif %}
{% empty %}
<div class="info-row">
<div class="info-k text-secondary">—</div>
<div class="info-v text-secondary small">Пока нет сотрудников с назначёнными ролями.</div>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{# ===== Лента отдела ===== #}
<div class="feed-stream">
@ -164,5 +200,394 @@
</div>
</div>
</div>
{# ---------- МОДАЛ «Удалить участника» ---------- #}
{% if user.is_staff or user.is_superuser or can_manage or dept.head and dept.head.id == user.id %}
<div class="modal fade" id="removeMemberModal-{{ dept.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi-person-dash me-2"></i>Удалить участника</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<form method="post" action="{% url 'employees:department_remove_member' dept.id %}">
{% csrf_token %}
<input type="hidden" name="employee_id" id="removeMemberChosen-{{ dept.id }}">
<div class="modal-body">
<div class="mb-3">
<input id="removeMemberSearch-{{ dept.id }}" type="search"
class="form-control" placeholder="Поиск по ФИО или email" autocomplete="off">
<div class="form-text">Отображаются участники этого отдела.</div>
</div>
<div id="removeMemberResults-{{ dept.id }}"
class="list-group list-group-flush border rounded">
{# список текущих участников; руководителя можно скрыть, если нельзя удалять #}
{% for li in links %}
{% with emp=li.employee eid=emp.id %}
<button type="button"
class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2"
data-id="{{ eid }}"
data-name="{{ emp.display_name|default:'' }}"
data-email="{{ emp.email|default:'' }}">
<span class="feed-ava"
style="width:32px;height:32px;flex:0 0 32px;border-radius:50%;overflow:hidden;background:#f3f3f3;display:flex;align-items:center;justify-content:center;">
{% if emp.avatar %}<img src="{{ emp.avatar }}" alt="" style="width:100%;height:100%;object-fit:cover;">{% else %}<i class="bi-person"></i>{% endif %}
</span>
<span class="d-flex flex-column text-start">
<span class="fw-semibold">{{ emp.display_name|default:emp.last_name }}</span>
{% if emp.email %}<span class="text-muted small">{{ emp.email }}</span>{% endif %}
</span>
</button>
{% endwith %}
{% empty %}
<div class="list-group-item text-secondary small py-3">В отделе пока нет участников.</div>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link" data-bs-dismiss="modal">
<i class="bi-x-lg me-1"></i>Отмена
</button>
<button type="submit" id="removeMemberSubmit-{{ dept.id }}" class="btn btn-outline-danger" disabled>
<i class="bi-person-dash me-1"></i>Удалить
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{# ---------- МОДАЛ «Добавить участника» ---------- #}
{% if user.is_staff or user.is_superuser or dept.head and dept.head.id == user.id %}
{% with exclude_ids=members_exclude_ids %}
<div class="modal fade" id="addMemberModal-{{ dept.id }}" tabindex="-1" aria-hidden="true"
data-exclude="{% if exclude_ids %}{{ exclude_ids|join:',' }}{% endif %}">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi-person-plus me-2"></i>Добавить участника</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<form method="post" action="{% url 'employees:department_add_member' dept.id %}">
{% csrf_token %}
<input type="hidden" name="employee_id" id="addMemberChosen-{{ dept.id }}">
<div class="modal-body">
<div class="mb-3">
<input id="addMemberSearch-{{ dept.id }}" type="search"
class="form-control" placeholder="Поиск по ФИО или email" autocomplete="off"
data-add-member-q>
<div class="form-text">Показываются только те, кто ещё не состоит в отделе.</div>
</div>
<div id="addMemberResults-{{ dept.id }}" class="list-group list-group-flush border rounded"
data-add-member-list>
<div class="list-group-item text-muted small py-3">Начните вводить для поиска…</div>
</div>
<template id="addMemberItemTpl-{{ dept.id }}">
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2" data-id="">
<span class="feed-ava" style="width:32px;height:32px;flex:0 0 32px;border-radius:50%;overflow:hidden;background:#f3f3f3;display:flex;align-items:center;justify-content:center;">
<i class="bi-person"></i>
</span>
<span class="d-flex flex-column text-start">
<span class="fw-semibold js-name"></span>
<span class="text-muted small js-email"></span>
</span>
</button>
</template>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link" data-bs-dismiss="modal">
<i class="bi-x-lg me-1"></i>Отмена
</button>
<button type="submit" id="addMemberSubmit-{{ dept.id }}" class="btn btn-success"
data-add-member-submit disabled>
<i class="bi-person-plus me-1"></i>Добавить
</button>
</div>
</form>
</div>
</div>
</div>
{% endwith %}
{% endif %}
{% endwith %}
{% endblock %}
{% block extra_js %}
<script>
(function(){
const btn = document.getElementById('deptSettingsToggle-{{ dept.id }}');
const box = document.getElementById('deptControls-{{ dept.id }}');
if (!btn || !box) return;
const key = 'deptControlsVisible:' + String({{ dept.id }});
let visible = localStorage.getItem(key) === '1';
function apply(){
if (visible){
box.classList.remove('d-none');
btn.setAttribute('aria-expanded', 'true');
btn.classList.remove('text-secondary');
btn.classList.add('text-primary');
} else {
box.classList.add('d-none');
btn.setAttribute('aria-expanded', 'false');
btn.classList.remove('text-primary');
btn.classList.add('text-secondary');
}
}
apply();
btn.addEventListener('click', function(){
visible = !visible;
localStorage.setItem(key, visible ? '1' : '0');
apply();
});
})();
</script>
{% 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 %}
<script>
/**
* Модал «Добавить участника»:
* - запросы идут через авторизованную фронт-вьюху /employees/list/?format=json
* - исключаются уже состоящие (атрибут data-exclude у модалки)
* - результаты рендерятся в список внутри модалки
*/
(function(){
const modal = document.getElementById('addMemberModal-{{ dept.id }}');
if (!modal) return;
const form = modal.querySelector('form[action$="{% url "employees:department_add_member" dept.id %}"]');
const input = modal.querySelector('[data-add-member-q]');
const listEl = modal.querySelector('[data-add-member-list]');
const tpl = modal.querySelector('#addMemberItemTpl-{{ dept.id }}');
const hidden = form.querySelector('input[name="employee_id"]');
const submit = modal.querySelector('[data-add-member-submit]');
const excludeAttr = (modal.getAttribute('data-exclude') || '').trim();
const exclude = new Set(
excludeAttr ? excludeAttr.split(',').map(x => parseInt(x, 10)).filter(n => !Number.isNaN(n)) : []
);
function esc(s){ return String(s).replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
let aborter = null;
async function searchEmployees(q){
if (aborter) aborter.abort();
aborter = new AbortController();
const params = new URLSearchParams({
format: 'json',
q: q,
limit: '20',
exclude: Array.from(exclude).join(',')
});
const r = await fetch('{{ employees_list_url }}?' + params.toString(), {
credentials: 'same-origin',
signal: aborter.signal
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json().catch(()=>({results:[]}));
return Array.isArray(data) ? data : (data.results || []);
}
function render(items){
listEl.innerHTML = '';
if (!items.length){
const empty = document.createElement('div');
empty.className = 'list-group-item text-muted small py-3';
empty.textContent = 'Ничего не найдено';
listEl.appendChild(empty);
return;
}
items.forEach(e => {
const node = tpl.content.firstElementChild.cloneNode(true);
node.dataset.id = e.id;
const ava = node.querySelector('.feed-ava');
if (e.avatar){
ava.innerHTML = `<img src="${esc(e.avatar)}" alt="" style="width:100%;height:100%;object-fit:cover;">`;
}
node.querySelector('.js-name').textContent =
e.display_name || (e.last_name ? (e.last_name + (e.first_name ? ' ' + e.first_name : '')) : ('Сотрудник #' + e.id));
node.querySelector('.js-email').textContent = e.email || '';
node.addEventListener('click', () => {
// снять прошлый выбор
listEl.querySelectorAll('.list-group-item').forEach(li => li.classList.remove('active'));
node.classList.add('active');
hidden.value = String(e.id);
submit.disabled = false;
});
listEl.appendChild(node);
});
}
const run = debounce(async (q) => {
submit.disabled = true;
hidden.value = '';
try { render(await searchEmployees(q)); }
catch { listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Ошибка загрузки</div>'; }
}, 200);
// Навигация
input.addEventListener('input', () => run(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter'){
const cur = listEl.querySelector('.list-group-item.active') || listEl.querySelector('.list-group-item');
if (cur && cur.dataset.id){
e.preventDefault();
cur.click();
}
}
});
// При открытии модалки — фокус и первичный запрос
modal.addEventListener('shown.bs.modal', () => {
input.value = '';
hidden.value = '';
submit.disabled = true;
input.focus();
run('');
});
// Подстраховка при отправке формы
form.addEventListener('submit', (e) => {
if (!hidden.value){
e.preventDefault();
input.focus();
}
});
})();
</script>
<script>
(function(){
const btn = document.getElementById('deptSettingsToggle-{{ dept.id }}');
const box = document.getElementById('deptControls-{{ dept.id }}');
if (!btn || !box) return;
const key = 'deptControlsVisible:' + String({{ dept.id }});
let visible = localStorage.getItem(key) === '1';
function apply(){
if (visible){
box.classList.remove('d-none');
btn.setAttribute('aria-expanded', 'true');
btn.classList.remove('text-secondary');
btn.classList.add('text-primary');
} else {
box.classList.add('d-none');
btn.setAttribute('aria-expanded', 'false');
btn.classList.remove('text-primary');
btn.classList.add('text-secondary');
}
}
apply();
btn.addEventListener('click', function(){
visible = !visible;
localStorage.setItem(key, visible ? '1' : '0');
apply();
});
})();
</script>
{% 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 %}
<script>
/**
* Модал «Добавить участника»: ... (без изменений, оставляю как есть)
*/
(function(){
// ... (весь скрипт для addMemberModal без изменений)
})();
</script>
<script>
(function(){
const modalId = 'removeMemberModal-{{ dept.id }}';
const modal = document.getElementById(modalId);
if (!modal) return;
const form = modal.querySelector('form[action="{% url "employees:department_remove_member" dept.id %}"]');
const input = document.getElementById('removeMemberSearch-{{ dept.id }}');
const listBox = document.getElementById('removeMemberResults-{{ dept.id }}');
const hidden = document.getElementById('removeMemberChosen-{{ dept.id }}');
const submit = document.getElementById('removeMemberSubmit-{{ dept.id }}');
function clearActive(){
listBox.querySelectorAll('.list-group-item-action.active').forEach(el => el.classList.remove('active'));
}
// живой фильтр по имени/почте
function norm(s){ return String(s||'').toLowerCase(); }
function matches(item, q){
const name = norm(item.getAttribute('data-name') || '');
const email = norm(item.getAttribute('data-email') || '');
const id = String(item.getAttribute('data-id') || '');
return !q || name.includes(q) || email.includes(q) || id.includes(q);
}
function applyFilter(){
const q = norm(input.value);
let any = false;
listBox.querySelectorAll('.list-group-item-action').forEach(it => {
const ok = matches(it, q);
it.classList.toggle('d-none', !ok);
if (ok) any = true;
});
if (!any){
// если ничего — можно показать пустую заглушку
// (оставим список пустым без отдельного элемента для простоты)
}
}
input.addEventListener('input', applyFilter);
listBox.addEventListener('click', (e) => {
const it = e.target.closest('.list-group-item-action');
if (!it) return;
clearActive();
it.classList.add('active');
hidden.value = it.getAttribute('data-id') || '';
submit.disabled = !hidden.value;
});
// сброс при открытии
modal.addEventListener('shown.bs.modal', () => {
input.value = '';
clearActive(); // Добавлено: очистка active класса при открытии
applyFilter();
hidden.value = '';
submit.disabled = true;
input.focus(); // Добавлено: фокус на поиск для удобства
});
// Подстраховка при отправке формы (аналогично addMember)
form.addEventListener('submit', (e) => {
if (!hidden.value){
e.preventDefault();
input.focus();
}
});
})();
</script>
{% endif %}
{% endif %}
{% endblock %}

View File

@ -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 не должен трогать роль"