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:
parent
e24caabb76
commit
07d5209f43
@ -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
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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"]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
|
||||
@ -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 не должен трогать роль"
|
||||
Loading…
x
Reference in New Issue
Block a user