### 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-блоки в едином стиле с «должностями», обновлены и позеленели тесты (остальные регрессии устранены).
912 lines
32 KiB
Python
912 lines
32 KiB
Python
# 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)
|