Corp/backend/employees/views_front.py
Игорь Надеин 3f0ff230ca **feat: управление отделами и ролями (API + шаблоны) + правки тестов**
### Backend / API

* **DepartmentViewSet**

  * Вернул поведение поиска/сортировки как в старой реализации: `?search=` по имени/описанию/ФИО руководителя; `?ordering=name|id` (по умолчанию `name`).
  * Добавил аннотацию **`employees_count`**: считает уникальных активных участников по связям `EmployeeDepartment`, и **прибавляет руководителя**, если он не входит в активные линкы.
  * `partial_update`: смена руководителя требует `change_department_head` **только если значение реально меняется**; остальное редактируется по `manage_department`.
  * `set_head`:

    * `POST /departments/{id}/set_head` — установка нового руководителя (200 + сериализованный отдел).
    * `head_id=null|""` — снятие руководителя (204).
    * Валидация: **разрешаем** назначение любому существующему и активному (`is_active=True`) пользователю; поле `email_verified` не блокирует назначение.
  * `set_member_role`:

    * `POST /departments/{id}/set_member_role` — создание/обновление линка участника с отделом.
    * Валидирует, что `role_id` принадлежит текущему отделу.
    * Возвращает JSON строго в формате, который ждут тесты: `{"employee_id", "role_id", "is_active"}`.

* **DepartmentRoleViewSet**

  * Доступ на изменение ролей отделов только при скоуп-праве **`assign_department_role`** (через `AdminOrDeptAllowed`).
  * Новые эндпоинты:

    * `GET /department-roles/perm_choices/` — отдает справочник прав из `DeptPerm.CHOICES`; при необходимости создаёт записи в БД (id + code + name).
    * `GET /department-roles/{id}/perms/` — текущие права роли (id, code, name).
    * `POST /department-roles/{id}/set_perms` — полная замена прав по `permission_ids` **или** `permission_codes`.
  * Использование константного набора прав **`DeptPerm`**: `MANAGE`, `CHANGE_HEAD`, `ASSIGN_ROLE` (+ `CHOICES`).

* **Сериализаторы**

  * `DepartmentSerializer`: `head` — вложенно (кратко), `head_id` — write-only (`PrimaryKeyRelatedField`), `employees_count` — read-only (берётся из аннотации).
  * `EmployeeBriefSerializer` — для краткого отображения руководителя и кандидатов.

* **Права**

  * Везде применён общий пермишен `AdminOrDeptAllowed(required_code=...)` для скоуп-прав по отделу:

    * `manage_department` — редактирование/удаление отдела,
    * `change_department_head` — назначение/снятие руководителя,
    * `assign_department_role` — операции с ролями и назначениями участникам.

### Frontend / Templates

* **Новый частичный шаблон** `backend/templates/employees/_department_controls.html`

  * Разбит на **3 блока**:

    1. *Управление отделом* (название/описание) — доступно при `can_manage`.
    2. *Роли отдела* — доступно при `can_assign_roles`.

       * Кнопка «Создать роль» открывает панель с формой + модал выбора прав.
       * Редактирование существующей роли: выпадающий список ролей, изменение названия и прав.
       * **Одна кнопка «Сохранить изменения»**: JS сам определяет, что изменилось, и вызывает нужные операции (`op=update` и/или `op=set_perms`) последовательно.
       * Отдельно — «Удалить роль».
       * Выбор прав выполнен через модальные окна, как в блоке с должностями в `_employee_edit.html`; визуал выбранных прав — «чипсы».
    3. *Назначение руководителя* — доступно при `can_change_head` (выпадающий список кандидатов).
  * Разметка и стили согласованы с `_employee_edit.html` (карточки, ios-аккордеон, кнопки, модалки). Дополнительный CSS **не** вводился — всё опирается на базовые стили проекта и Bootstrap.
* **`department_detail` (view для фронта)**:

  * Сначала грузит отдел, затем роли, сотрудников и посты.
  * Собирает `head_choices` и `links` для шаблона.
  * Подтягивает `dept_perm_choices` из `GET /api/v1/department-roles/perm_choices/`.

### Тесты

* Полностью переписаны/обновлены:

  * `tests/api/v1/employees/test_departments.py`

    * Поиск/сортировка по имени.
    * `employees_count` считает head, если его нет в активных линках.
    * `set_head`: валидирует активность; снятие — 204; изменения head без доп. права — 403; сохранение того же head — 200.
    * `set_member_role`: «счастливый путь», запрет чужих ролей, формат ответа.
  * `tests/api/v1/employees/test_department_roles.py` и `test_department_roles_extra.py`

    * Список с фильтром по отделу, стабильный `ordering`.
    * Создание/обновление/удаление с учётом скоуп-прав.
    * `perm_choices`, `perms`, `set_perms` (валидаторы ids/codes).
  * `tests/api/v1/employees/test_department_head_rights.py`

    * Руководитель может править отдел без явных ролей.
    * Руководитель может менять head и **теряет** права после снятия.
* Фиксы фикстур:

  * `make_user` всегда создаёт пользователя с валидным телефоном, `email` в нижнем регистре, отключена отсылка писем; приводим к активному состоянию, чтобы не мешать правам.
* Убраны ошибки:

  * `NameError: User is not defined` → везде используется `get_user_model()`.
  * `NoReverseMatch` в шаблоне → корректные URL’ы: `employees:department_edit`, `employees:department_set_head`, `employees:edit_department_role`.

### Прочее

* Повсеместно добавлены докстринги для новых классов и функций (соответствие внутреннему гайдлайну).
* Без миграций данных/схемы.

**Итог:** добавлены управляемые через API роли отделов с выбором прав (скоуп-прав), унифицированы проверки доступа, восстановлено ожидаемое поведение списка/деталей отделов, подготовлены UI-блоки в едином стиле с «должностями», обновлены и позеленели тесты (остальные регрессии устранены).
2025-09-03 17:17:44 +03:00

912 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend/employees/views_front.py
from __future__ import annotations
import base64
import json
import mimetypes
from typing import Any, Dict, Optional, 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.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
# =========================
# Утилиты
# =========================
def _file_to_data_uri(uploaded) -> str:
"""
Превращаем загруженный файл (InMemoryUploadedFile/TemporaryUploadedFile)
в data URI (data:image/...;base64,xxxx).
"""
if not uploaded:
return ""
content = uploaded.read()
if hasattr(uploaded, "seek"):
uploaded.seek(0)
mime = (
getattr(uploaded, "content_type", None)
or mimetypes.guess_type(getattr(uploaded, "name", ""))[0]
or "application/octet-stream"
)
b64 = base64.b64encode(content).decode("ascii")
return f"data:{mime};base64,{b64}"
def _extract_items(payload: Any):
"""
Универсальный парсер: пагинированный ответ DRF или «сырой» список.
"""
if isinstance(payload, dict) and "results" in payload:
return (
payload["results"],
payload.get("count", 0),
payload.get("next"),
payload.get("previous"),
)
if isinstance(payload, list):
return payload, len(payload), None, None
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 []
return payload or []
def _fetch_employee_posts(api, employee_id: int):
# сначала пробуем ?author=, если в API другое имя фильтра — fallback на ?employee=
for url in (
f"v1/posts/?author={employee_id}&ordering=-created_at",
f"v1/posts/?employee={employee_id}&ordering=-created_at",
):
r = api.get(url)
if r.ok:
data = r.json or []
return _extract_results(data)
def _json_body(request: HttpRequest) -> Dict[str, Any]:
"""Безопасно парсим JSON; если не JSON — возвращаем пустой dict."""
try:
if request.body:
return json.loads(request.body.decode("utf-8"))
except Exception:
pass
return {}
def _error(message: str, status: int = 400) -> JsonResponse:
return JsonResponse({"detail": message}, status=status)
def _ok(data: Dict[str, Any], status: int = 200) -> JsonResponse:
return JsonResponse(data, status=status)
def _api_ok_or_error(resp) -> Tuple[bool, Dict[str, Any], int]:
"""Унифицированная распаковка ответа API."""
try:
data = resp.json or {}
except Exception:
data = {}
return resp.ok, data, getattr(resp, "status", 500)
def _has_role(link):
r = link.get("role")
if r is None:
return False
if isinstance(r, dict):
return bool(r.get("id") or r.get("name"))
s = str(r).strip()
return s not in {"", "None", "null", "0"}
# =========================
# Сотрудники
# =========================
# ---------- LIST ----------
@require_api_auth
def employee_list(request):
"""
Список сотрудников.
Ходит в /api/v1/employees/ c поддержкой поиска/сортировки/пагинации.
"""
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 отдела)
params = {"page": page, "ordering": ordering}
if q:
params["search"] = q
if department:
params["department"] = department
resp = api.get("v1/employees/", params=params)
if not resp.ok:
messages.error(request, f"Ошибка API ({resp.status})")
return render(
request,
"employees/employees_list.html",
{"employees": [], "q": q, "o": ordering, "page": page},
)
payload = resp.json or {}
items, count, next_url, prev_url = _extract_items(payload)
context = {
"employees": items,
"count": count,
"q": q,
"o": ordering,
"page": page,
"next_url": next_url,
"prev_url": prev_url,
}
return render(request, "employees/employees_list.html", context)
# ---------- DETAIL / ME ----------
@require_api_auth
def employee_profile(request, pk="me"):
"""
/employees/me/ и /employees/<int:pk>/
Если pk == текущему пользователю — редиректим на /employees/me/
"""
# --- редирект "свой id" -> /me ---
try:
int_pk = int(pk)
except (TypeError, ValueError):
int_pk = None
if (
int_pk is not None
and request.user.is_authenticated
and request.user.id == int_pk
):
url = reverse("employees:profile")
qs = request.META.get("QUERY_STRING")
if qs: # сохраняем ?tab=skills и т.п.
url = f"{url}?{qs}"
return redirect(url) # 302 достаточно, чтобы не кэшировать навсегда
# --- обычная логика detail/me ---
api = get_api_client(request)
is_me = str(pk) in {"me", "self"}
endpoint = "v1/employees/me/" if is_me else f"v1/employees/{pk}/"
resp = api.get(endpoint)
if not resp.ok:
messages.error(request, f"Сотрудник не найден ({resp.status})")
return redirect("employees:employees_list")
emp = resp.json or {}
emp_id = emp.get("id") or (request.user.id if is_me else pk)
posts = _fetch_employee_posts(api, emp_id)
sresp = api.get("v1/skills/", params={"ordering": "name"})
sdata = sresp.json if sresp.ok else []
all_skills = sdata.get("results", sdata) if isinstance(sdata, dict) else sdata
gresp = None
for path in ("v1/auth/groups/", "v1/groups/"):
r = api.get(path, params={"ordering": "name"})
if r.ok:
gresp = r
break
gdata = gresp.json if gresp and gresp.ok else []
all_groups = gdata.get("results", gdata) if isinstance(gdata, dict) else gdata
presp = api.get("v1/positions/", params={"ordering": "name"})
pdata = presp.json if presp.ok else []
all_positions = pdata.get("results", pdata) if isinstance(pdata, dict) else pdata
can_edit = (
True
if is_me
else bool(
request.user and (request.user.is_staff or request.user.id == emp.get("id"))
)
)
emp_depts = []
for d in emp.get("departments") or []:
did = d.get("id")
if not did:
emp_depts.append(d)
continue
# узнаём head отдела
dresp = api.get(f"v1/departments/{did}/")
head_id = None
if dresp.ok:
dj = dresp.json or {}
head = dj.get("head") or {}
head_id = head.get("id") or dj.get("head_id")
# подгружаем роли отдела
rresp = api.get(
"v1/department-roles/", params={"department": did, "ordering": "name"}
)
roles = []
if rresp.ok:
rj = rresp.json
roles = (rj.get("results", rj) if isinstance(rj, dict) else rj) or []
can_edit_role = (
bool(head_id)
and str(head_id) == str(getattr(request.user, "id", None))
and not d.get("is_head") # нельзя править главу
and str(emp_id) != str(getattr(request.user, "id", None)) # не себе
)
d2 = dict(d)
d2.update(
{
"roles_choices": roles, # [{id, name, ...}]
"can_edit_role": can_edit_role, # bool
}
)
emp_depts.append(d2)
can_edit = (
True
if is_me
else bool(
request.user and (request.user.is_staff or request.user.id == emp.get("id"))
)
)
ctx = {
"emp": emp,
"emp_depts": emp_depts,
"can_edit": can_edit,
"all_skills": all_skills,
"all_positions": all_positions,
"all_groups": all_groups,
"posts": posts,
"is_me": True if is_me else False,
}
return render(request, "employees/employee_detail.html", ctx)
# ---------- EDIT (self or staff) ----------
@require_api_auth
@require_http_methods(["PATCH", "POST"])
def employee_edit(request, pk: int):
"""
Редактирование пользователя по id.
"""
api = get_api_client(request)
try:
payload = json.loads(request.body or b"{}")
except Exception:
payload = {}
resp = api.patch(f"v1/employees/{pk}/", json=payload)
try:
data = resp.json
except Exception:
data = {"detail": resp.text}
return JsonResponse(data or {}, status=resp.status)
# ---------- EDIT ME ----------
@require_api_auth
@require_http_methods(["PATCH", "POST"])
def employee_edit_me(request):
"""
Редактирование собственного профиля через /api/v1/employees/me/
"""
api = get_api_client(request)
# принимаем JSON из браузера
try:
payload = json.loads(request.body or b"{}")
except Exception:
payload = {}
# отправляем в DRF
resp = api.patch("v1/employees/me/", json=payload)
try:
data = resp.json
except Exception:
data = {"detail": resp.text}
return JsonResponse(data or {}, status=resp.status)
# ---------- CREATE (staff) ----------
@require_api_auth
@require_http_methods(["GET", "POST"])
def employee_create(request):
api = get_api_client(request)
if request.method == "GET":
return render(request, "employees/employee_create.html")
data = {
"email": request.POST.get("email", "").strip(),
"phone_number": request.POST.get("phone_number", "").strip(),
"last_name": request.POST.get("last_name", "").strip(),
"first_name": request.POST.get("first_name", "").strip(),
"patronymic": request.POST.get("patronymic", "").strip(),
"gender": request.POST.get("gender") or None,
"birth_date": request.POST.get("birth_date") or None,
"position": request.POST.get("position") or None,
"telegram": request.POST.get("telegram", "").strip(),
"whatsapp": request.POST.get("whatsapp", "").strip(),
"wechat": request.POST.get("wechat", "").strip(),
}
if request.FILES.get("avatar"):
data["avatar"] = _file_to_data_uri(request.FILES["avatar"])
skills = request.POST.getlist("skills")
if skills:
data["skills"] = skills
password = request.POST.get("password", "").strip()
if password:
data["password"] = password # API сгенерирует сам, если не передали
resp = api.post("v1/employees/", json=data)
if not resp.ok:
messages.error(request, f"Не удалось создать: {resp.status} — {resp.text}")
return render(request, "employees/employee_create.html", {"form": request.POST})
new_emp = resp.json or {}
messages.success(request, "Сотрудник создан.")
return redirect("employees:employee_detail", pk=new_emp.get("id"))
# =========================
# Отделы
# =========================
# ---------- LIST ----------
@require_api_auth
def department_list(request):
"""
Список отделов.
Ходит в /api/v1/departments/ c поддержкой поиска/сортировки/пагинации DRF.
"""
api = get_api_client(request)
page = request.GET.get("page") or 1
ordering = request.GET.get("o") or "name"
q = (request.GET.get("q") or "").strip()
params = {"page": page, "ordering": ordering}
if q:
params["search"] = q # DRF SearchFilter
resp = api.get("v1/departments/", params=params)
if not resp.ok:
messages.error(request, f"Ошибка API ({resp.status})")
return render(
request,
"employees/department_list.html",
{
"departments": [],
"count": 0,
"q": q,
"o": ordering,
"page": page,
"next_url": None,
"prev_url": None,
},
)
payload = resp.json # важно: без "or {}" — иначе потеряем список
# ✅ поддерживаем оба формата DRF: dict с results или list без пагинации
if isinstance(payload, dict) and "results" in payload:
items = payload["results"]
count = payload.get("count", len(items))
next_url = payload.get("next")
prev_url = payload.get("previous")
elif isinstance(payload, list):
items = payload
count = len(items)
next_url = prev_url = None
else:
items = []
count = 0
next_url = prev_url = None
context = {
"departments": items,
"count": count,
"q": q,
"o": ordering,
"page": page,
"next_url": next_url,
"prev_url": prev_url,
}
return render(request, "employees/department_list.html", context)
# ---------- DETAIL ----------
@require_api_auth
def department_detail(request, pk: int):
"""
Тонкая вьюха: берёт агрегированный контекст у API и рендерит шаблон.
"""
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})")
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),
})
return render(request, "employees/department_detail.html", ctx)
# ---------- EDIT BASIC FIELDS ----------
@require_api_auth
@require_http_methods(["POST"])
def department_edit(request, pk: int):
"""
Редактирование name/description через PATCH /api/v1/departments/{id}/.
Права проверяет API (manage_department).
"""
api = get_api_client(request)
form = DepartmentEditForm(request.POST)
if not form.is_valid():
messages.error(request, "Проверьте форму")
return redirect("employees:department_detail", pk=pk)
payload = {
"name": form.cleaned_data["name"],
"description": form.cleaned_data.get("description") or "",
}
resp = api.patch(f"v1/departments/{pk}/", json=payload)
if not resp.ok:
messages.error(
request,
f"Не удалось обновить отдел ({resp.status}) — {resp.json or resp.text}",
)
else:
messages.success(request, "Отдел обновлён")
return redirect("employees:department_detail", pk=pk)
# ---------- SET HEAD ----------
@require_api_auth
@require_http_methods(["POST"])
def department_set_head(request, pk: int):
"""
Назначение/снятие руководителя через POST /api/v1/departments/{id}/set_head/.
Права проверяет API (change_department_head).
"""
api = get_api_client(request)
form = SetHeadForm(request.POST)
if not form.is_valid():
messages.error(request, "Проверьте форму")
return redirect("employees:department_detail", pk=pk)
head_id = form.cleaned_data.get("head_id", None)
payload = {"head_id": head_id if head_id is not None else None} # None → снять
resp = api.post(f"v1/departments/{pk}/set_head/", json=payload)
if not resp.ok:
messages.error(
request,
f"Не удалось изменить руководителя ({resp.status}) — {resp.json or resp.text}",
)
else:
messages.success(
request, "Руководитель обновлён" if head_id else "Руководитель снят"
)
return redirect("employees:department_detail", pk=pk)
# ---------- SET MEMBER ROLE ----------
@require_api_auth
@require_http_methods(["POST"])
def set_member_role(request, dept_id: int, emp_id: int):
"""
Серверный proxy: меняем роль сотрудника в отделе.
Тело: role_id (пусто => снять роль), next (опционально для редиректа).
"""
api = get_api_client(request)
role_id_raw = request.POST.get("role_id", "")
role_id = None
if str(role_id_raw).strip() not in {"", "None", "null"}:
try:
role_id = int(role_id_raw)
except (TypeError, ValueError):
role_id = None
# заодно не даём менять самому себе (UI тоже скрывает)
if str(emp_id) == str(getattr(request.user, "id", None)):
messages.error(request, "Нельзя изменять собственную роль в отделе.")
return redirect(
request.POST.get("next") or reverse("employees:profile", args=("me",))
)
# пробрасываем в API — он сам проверит, что вы глава отдела
resp = api.post(
f"v1/departments/{dept_id}/set_member_role/",
json={
"employee_id": emp_id,
"role_id": role_id,
},
)
if resp.ok:
messages.success(request, "Роль обновлена.")
else:
try:
data = resp.json
except Exception:
data = {}
err = data.get("detail") or data.get("error") or f"HTTP {resp.status}"
messages.error(request, f"Не удалось обновить роль: {err}")
return redirect(
request.POST.get("next")
or reverse("employees:employee_profile", args=(emp_id,))
)
class StaffOnlyMixin(UserPassesTestMixin):
def test_func(self):
return self.request.user.is_staff
def handle_no_permission(self):
messages.error(self.request, "Недостаточно прав.")
return redirect("employees:department_list")
class DepartmentCreateView(LoginRequiredMixin, StaffOnlyMixin, CreateView):
model = Department
form_class = DepartmentForm
template_name = "employees/department_form.html"
success_url = reverse_lazy("employees:department_list")
class DepartmentUpdateView(LoginRequiredMixin, StaffOnlyMixin, UpdateView):
model = Department
form_class = DepartmentForm
template_name = "employees/department_form.html"
success_url = reverse_lazy("employees:department_list")
class DepartmentDeleteView(LoginRequiredMixin, StaffOnlyMixin, DeleteView):
model = Department
template_name = "employees/department_confirm_delete.html"
success_url = reverse_lazy("employees:department_list")
@require_api_auth
@require_POST
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=delete : DELETE v1/department-roles/<role_id>/
"""
api = get_api_client(request)
op = (request.POST.get("op") or "").lower().strip()
# Быстрая проверка прав (как и в шаблоне)
dept_resp = api.get(f"v1/departments/{pk}/")
if not dept_resp.ok:
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)
if not allowed:
messages.error(request, "Недостаточно прав для управления ролями отдела.")
return redirect("employees:department_detail", pk=pk)
try:
if op == "create":
name = (request.POST.get("name") or "").strip()
if not name:
messages.error(request, "Название роли не может быть пустым.")
return redirect("employees:department_detail", pk=pk)
resp = api.post("v1/department-roles/", json={"department": pk, "name": name})
if resp.ok:
messages.success(request, f"Роль «{name}» создана.")
else:
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) Сохраняем имя
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})
if resp.ok:
messages.success(request, f"Роль обновлена: «{name}».")
else:
messages.error(request, f"Ошибка обновления роли: {getattr(resp, 'status', '')} {resp.text or ''}")
elif op == "delete":
role_id = request.POST.get("role_id")
if not role_id:
messages.error(request, "Не указан идентификатор роли для удаления.")
return redirect("employees:department_detail", pk=pk)
resp = api.delete(f"v1/department-roles/{role_id}/")
if resp.ok or getattr(resp, "status", None) == 204:
messages.success(request, "Роль удалена.")
else:
messages.error(request, f"Ошибка удаления роли: {getattr(resp, 'status', '')} {resp.text or ''}")
else:
messages.error(request, "Неизвестная операция.")
except Exception as exc:
messages.error(request, f"Ошибка: {exc}")
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},
)
# =========================
# Навыки
# =========================
@login_required
@require_POST
@require_api_auth
def skill_add(request, pk: int):
api = get_api_client(request)
skill_id = request.POST.get("skill_id")
skill_name = (request.POST.get("skill_name") or "").strip()
payload = {
k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v
}
resp = api.post(f"v1/employees/{pk}/add_skill/", data=payload)
return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500)
@login_required
@require_POST
@require_api_auth
def skill_remove(request, pk: int):
api = get_api_client(request)
skill_id = request.POST.get("skill_id")
skill_name = (request.POST.get("skill_name") or "").strip()
payload = {
k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v
}
# NB: у DRF-экшена underscore: remove_skill
resp = api.post(f"v1/employees/{pk}/remove_skill/", data=payload)
return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500)
# =========================
# Должности
# =========================
# ---------- CREATE POSITION ----------
@csrf_protect
@login_required
@require_POST
def position_create_front(request: HttpRequest) -> JsonResponse:
"""
Создать должность (прокси к POST /api/v1/positions/).
Ожидает JSON или form-data с полями: name, description?, groups? (list[int]).
"""
payload = _json_body(request) or request.POST.dict()
name = (payload.get("name") or "").strip()
description = (payload.get("description") or "").strip()
# groups может прийти как список, как csv или отсутствовать
groups = payload.get("groups")
if isinstance(groups, str):
groups = [int(x) for x in groups.split(",") if x.strip().isdigit()]
elif isinstance(groups, list):
groups = [int(x) for x in groups if str(x).isdigit()]
else:
groups = []
if not name:
return _error("Укажите название должности", 400)
api = get_api_client(request)
api_payload = {"name": name}
if description:
api_payload["description"] = description
if groups:
api_payload["groups"] = groups
resp = api.post("v1/positions/", json=api_payload)
ok, data, status = _api_ok_or_error(resp)
if not ok:
return JsonResponse(
data or {"detail": "Не удалось создать должность"}, status=status
)
return _ok(data, status=201)
# ---------- UPDATE POSITION ----------
@csrf_protect
@login_required
@require_http_methods(["PATCH", "POST"])
def position_update_front(request: HttpRequest, pos_id: int) -> JsonResponse:
"""
Обновить должность (прокси к PATCH /api/v1/positions/{id}/).
Приходит JSON с любыми изменяемыми полями (name, description, groups).
Разрешаем метод PATCH и POST (для совместимости) — на POST тоже шлём PATCH в API.
"""
payload = _json_body(request) or request.POST.dict()
# Если groups пришли строкой — распарсим
groups = payload.get("groups", None)
if isinstance(groups, str):
payload["groups"] = [int(x) for x in groups.split(",") if x.strip().isdigit()]
api = get_api_client(request)
resp = api.patch(f"v1/positions/{pos_id}/", json=payload)
ok, data, status = _api_ok_or_error(resp)
if not ok:
return JsonResponse(
data or {"detail": "Не удалось обновить должность"}, status=status
)
return _ok(data)
# ---------- ASSIGN POSITION TO EMPLOYEE ----------
@csrf_protect
@login_required
@require_POST
def employee_set_position_front(request: HttpRequest, emp_id: int) -> JsonResponse:
"""
Назначить сотруднику position_id (прокси к PATCH /api/v1/employees/{id}/).
Ожидает JSON/form-data: position_id (int или пусто для снятия).
"""
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return JsonResponse({"detail": "Bad JSON"}, status=400)
raw = payload.get("position_id", None)
try:
position_id = int(str(raw).strip()) if raw is not None else None
except (ValueError, TypeError):
return JsonResponse({"detail": "position_id must be an integer"}, status=400)
if not position_id:
return JsonResponse({"detail": "position_id is required"}, status=400)
api = get_api_client(request)
resp = api.patch(f"v1/employees/{emp_id}/", json={"position_id": position_id})
ok, data, status = _api_ok_or_error(resp)
if not ok:
return JsonResponse(
data or {"detail": "Не удалось назначить должность"}, status=status
)
return _ok(data)
# Удобная обёртка под «/me/»
@csrf_protect
@login_required
@require_POST
def employee_set_position_me_front(request: HttpRequest) -> JsonResponse:
"""
Назначить должность текущему пользователю (прокси к PATCH /api/v1/employees/me/).
"""
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return JsonResponse({"detail": "Bad JSON"}, status=400)
raw = payload.get("position_id", None)
try:
position_id = int(str(raw).strip()) if raw is not None else None
except (ValueError, TypeError):
return JsonResponse({"detail": "position_id must be an integer"}, status=400)
if not position_id:
return JsonResponse({"detail": "position_id is required"}, status=400)
api = get_api_client(request)
resp = api.patch("v1/employees/me/", json={"position_id": position_id})
ok, data, status = _api_ok_or_error(resp)
if not ok:
return JsonResponse(
data or {"detail": "Не удалось назначить должность"}, status=status
)
return _ok(data)