From 016ee1ef50d7bf989e9dc62f46ba058b9eed3c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 1 Sep 2025 17:15:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(employees):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F,=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BB=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20=D0=B8=20=D0=BC=D0=BE=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20+=20=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20UX-=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основное - Переписан шаблон `templates/employees/_employee_edit.html`: - Разделы: «Личные данные», «Контакты», «Должность» (аккордеон-блоки с единым отступом `.py-3` в заголовках). - Поля: first_name, last_name, patronymic, gender (коды 1/2, допускается `null`), birth_date. - Контакты: phone_number, email, telegram, whatsapp, wechat. - Валидация: - требуем минимум один контакт (любой из: email/phone_number/telegram/whatsapp/wechat); - e-mail валидируется HTML5 + regex-фолбэком; live-очистка ошибок. - Должность: - выпадающий список из `all_positions`; - доступ к редактированию/созданию скрывается/показывается по флагам контекста (см. ниже); - «Создать должность» (name/description) + выбор групп прав. - Выбор групп прав: - мобильный модальный пикер (поиск, чекбоксы, чипсы выбранных значений, скрытый input с csv id); - модал центрируется (`.modal-dialog-centered`), выносится в `document.body`, задран z-index (`.modal{z-index:1080}`, backdrop 1075), убран «просвет» (принудительный `background-color`). - Кнопки: индикаторы загрузки (label + spinner), аккуратная блокировка в процессе. JS (внутри того же шаблона) - Сборка payload для `PATCH`: - отправляем только изменённые поля; - для `gender`, `birth_date`, `position_id` при очистке отправляем `null`; - `gender` приводим к числу; - поддержка `skills_ids` (мультиселект): сравнение с `data-init`, отправка массива только при изменении; - `avatar`: чтение файла → dataURL (Base64ImageField), очистка через пустую строку. - Валидация перед отправкой: - `ensureOneContact()` — хотя бы один контакт обязателен; - `validateEmail()` — валидный `email`, если поле непустое. - Обработка ошибок API: подсветка конкретных полей (`.is-invalid` + `.invalid-feedback`), fallback-alert для общих ошибок. - Успех: закрытие обёртки `#editFormWrap` (если есть collapse) + `location.reload()`. - Блок «Создать должность»: - `POST` в `positions_create_url` с `name/description` и (если разрешено) `groups`; - затем `PATCH` сотрудника на `endpoint` с `position_id` созданной должности; - обновление селекта должностей. - Исправления: - валидация e-mail перенесена внутрь IIFE (был reference error на `form`); - удалён двойной вызов `ensureOneContact()`; оставлена единая связка `ensureOneContact() || !validateEmail()`. Контекст (ожидается из context_processors, без правок views_front) - Передавать в шаблон (минимум): - `all_positions`: список объектов `{id, name}` (для выпадающего списка); - `all_groups`: список объектов `{id, name}` (для модального выбора); - `positions_create_url`: URL `POST /api/v1/positions/` (создание должности); - флаги доступа: - `can_edit_position` — право менять должность сотрудника (или глобальные staff/superuser); - `can_create_position` — право создавать должности; - `can_assign_position_groups` — право назначать групповые права должности. - Также в шаблоне допускаются `request.user.is_staff`/`is_superuser` (поддержаны как запасной сигнал в условиях показа). API - Используется уже имеющийся `PositionViewSet` (`/api/v1/positions/`): - создание должности с полем `groups` (список id) через `PositionSerializer`. - Добавлен простой эндпоинт групп (если ещё не было): - `GET /api/v1/groups/` (list, опционально `?search=` по `name`); - сериализатор: `id`, `name`. - Примечание: в шаблоне сейчас используется предзагруженный `all_groups`; эндпоинт нужен для возможной ajax-подгрузки/фильтрации в будущем. UI/стили - Единый вертикальный ритм заголовков секций через `.py-3`. - Модальный выбор групп по центру экрана; корректный stacking-context и непрозрачность. - Мобильная дружелюбность: полноэкранный на sm-и ниже (`modal-fullscreen-sm-down`), поиск, чеклист, чипсы выбранных. Как проверить 1) Откройте карточку сотрудника → «Редактировать». 2) Смените/очистите пол, дату рождения, должность — убедитесь, что очистка отправляет `null`, а изменение — нужное значение. 3) Удалите все контакты → форма подсветит ошибки и не отправится; добавьте валидный e-mail — ошибки исчезнут. 4) Создайте новую должность с группами → должность появится в селекте и применится сотруднику. 5) Проверить поведение прав: - без `can_edit_position`/staff/superuser — блок должности только для чтения; - без `can_assign_position_groups` — кнопка выбора групп скрыта. Refs: DRF serializers (`EmployeeSerializer`, `PositionSerializer`, `Base64ImageField`), `PositionViewSet` (create + groups). --- backend/api/v1/employees/serializers.py | 33 +- backend/api/v1/employees/views.py | 155 +++- backend/api/v1/urls.py | 3 +- backend/employees/forms_front.py | 30 +- backend/employees/urls_front.py | 40 +- backend/employees/views_front.py | 228 +++--- .../templates/employees/_employee_edit.html | 692 ++++++++++++++++++ .../templates/employees/employee_detail.html | 178 +++-- .../templates/employees/employee_edit.html | 95 --- 9 files changed, 1104 insertions(+), 350 deletions(-) create mode 100644 backend/templates/employees/_employee_edit.html delete mode 100644 backend/templates/employees/employee_edit.html diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 994898e4..09afa6c9 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -6,6 +6,7 @@ from io import BytesIO from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, Permission from django.core.files.base import ContentFile +from employees.constants import ACTION_CHOICES from employees.models import ( Department, DepartmentRole, @@ -17,9 +18,6 @@ from employees.models import ( from PIL import Image from rest_framework import serializers -from employees.constants import ACTION_CHOICES - - Employee = get_user_model() @@ -273,8 +271,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "updated_at", "last_login", "date_joined", - "auth", - + "auth", ) read_only_fields = ( "is_active", @@ -283,7 +280,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "updated_at", "last_login", "date_joined", - "auth", + "auth", ) extra_kwargs = { # чтобы точно не принимали/не отдавали пароль и коды @@ -497,3 +494,27 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): } for p in obj.permissions.select_related("content_type").all() ] + + +class GroupSerializer(serializers.ModelSerializer): + permissions = serializers.PrimaryKeyRelatedField( + queryset=Permission.objects.all(), many=True, required=False + ) + permissions_verbose = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = Group + fields = ["id", "name", "permissions", "permissions_verbose"] + + def get_permissions_verbose(self, obj): + qs = obj.permissions.select_related("content_type").all() + return [ + { + "id": p.id, + "codename": f"{p.content_type.app_label}.{p.codename}", + "name": p.name, + "app": p.content_type.app_label, + "model": p.content_type.model, + } + for p in qs + ] diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 5196ba74..30ea3e57 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -31,8 +31,9 @@ from ..permissions import (ASSIGN_ROLE_PERM, MANAGE_PERM, from .serializers import (DepartmentRoleSerializer, DepartmentSerializer, EmailSerializer, EmailVerifySerializer, EmployeeActionSerializer, EmployeeListSerializer, - EmployeeSerializer, PositionSerializer, - RegisterSerializer, SkillSerializer) + EmployeeSerializer, GroupSerializer, + PositionSerializer, RegisterSerializer, + SkillSerializer) def _to_bool(val: str | None) -> bool | None: @@ -544,17 +545,22 @@ class EmployeeViewSet(viewsets.ModelViewSet): } def get_permissions(self): - if self.action in {"add_skill", "remove_skill"}: + # create: только staff/superuser (перепроверяете и в методе create) + if self.action == "create": return [IsAuthenticated(), AdminOrActionOrModelPerms()] - elif self.action in {"create", "update", "partial_update", "destroy"}: - if self.action in {"update", "partial_update"}: - return [ - IsAuthenticated(), - (IsSelfOrStaff | AdminOrActionOrModelPerms)(), - ] - return [IsAuthenticated(), AdminOrActionOrModelPerms()] - else: - return [IsAuthenticated()] + + # update/partial_update/destroy: владелец ИЛИ админ/модельные пермишены + if self.action in { + "update", + "partial_update", + "destroy", + "add_skill", + "remove_skill", + }: + return [IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)()] + + # list / retrieve / me -> только аутентификация + return [IsAuthenticated()] def get_queryset(self): last_action_code_sq = Subquery( @@ -712,11 +718,14 @@ class EmployeeViewSet(viewsets.ModelViewSet): "skills", Prefetch( "departments_links", - queryset=EmployeeDepartment.objects.filter(is_active=True) - .select_related("department", "role"), + queryset=EmployeeDepartment.objects.filter( + is_active=True + ).select_related("department", "role"), to_attr="dept_links", ), - Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")), + Prefetch( + "actions", queryset=EmployeeAction.objects.order_by("-date") + ), ) .get(pk=request.user.pk) ) @@ -735,12 +744,14 @@ class EmployeeViewSet(viewsets.ModelViewSet): new_wechat = vd.get("wechat", instance.wechat) if not (new_whatsapp or new_telegram or new_wechat): return Response( - {"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."}, + { + "detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)." + }, status=status.HTTP_400_BAD_REQUEST, ) ser.save() - return Response(ser.data, status=status.HTTP_200_OK) + return Response(ser.data, status=status.HTTP_200_OK) @action(detail=True, methods=["post"]) def add_skill(self, request, pk=None): @@ -1240,3 +1251,113 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): def perform_update(self, serializer): obj = serializer.save() self._apply_effects(obj) + + +class GroupViewSet(viewsets.ModelViewSet): + """ + /api/v1/groups/ + GET list/retrieve — аутентифицированные + POST/PUT/PATCH/DELETE — staff/superuser ИЛИ пользователи с model perms + + Экшены: + GET /{id}/permissions + POST /{id}/set-permissions + POST /{id}/add-permissions + POST /{id}/remove-permissions + """ + + queryset = Group.objects.all() + serializer_class = GroupSerializer + pagination_class = None + + filter_backends = [filters.SearchFilter, filters.OrderingFilter] + search_fields = ["name"] + ordering_fields = ["name", "id"] + ordering = ["name"] + + permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms] + required_perms_by_action = { + "set_permissions": "employees.assign_group_permissions", + "add_permissions": "employees.assign_group_permissions", + "remove_permissions": "employees.assign_group_permissions", + } + + # ----- helpers ----- + def _validate_permissions_payload(self, request): + ids = request.data.get("permissions") + if not isinstance(ids, list): + return None, Response( + {"detail": "Поле 'permissions' должно быть списком id"}, status=400 + ) + qs = Permission.objects.filter(id__in=ids) + if qs.count() != len(set(ids)): + return None, Response( + {"detail": "Некоторые permissions не найдены"}, status=400 + ) + return qs, None + + # ----- actions ----- + @action(detail=True, methods=["get"]) + def permissions(self, request, pk=None): + grp = self.get_object() + perms = ( + Permission.objects.filter(group=grp) + .select_related("content_type") + .distinct() + ) + data = [ + { + "id": p.id, + "codename": f"{p.content_type.app_label}.{p.codename}", + "name": p.name, + "app": p.content_type.app_label, + "model": p.content_type.model, + } + for p in perms + ] + return Response({"count": len(data), "results": data}, status=200) + + @action(detail=True, methods=["post"]) + def set_permissions(self, request, pk=None): + grp = self.get_object() + qs, err = self._validate_permissions_payload(request) + if err: + return err + grp.permissions.set(qs) + return Response( + { + "ok": True, + "permission_ids": list(grp.permissions.values_list("id", flat=True)), + }, + status=200, + ) + + @action(detail=True, methods=["post"]) + def add_permissions(self, request, pk=None): + grp = self.get_object() + qs, err = self._validate_permissions_payload(request) + if err: + return err + grp.permissions.add(*qs) + return Response( + { + "ok": True, + "permission_ids": list(grp.permissions.values_list("id", flat=True)), + }, + status=200, + ) + + @action(detail=True, methods=["post"]) + def remove_permissions(self, request, pk=None): + grp = self.get_object() + qs, err = self._validate_permissions_payload(request) + if err: + return err + grp.permissions.remove(*qs) + return Response( + { + "ok": True, + "permission_ids": list(grp.permissions.values_list("id", flat=True)), + }, + status=200, + ) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 74b0510d..9c1ccf51 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -6,7 +6,7 @@ from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet, EmployeeActionViewSet, EmployeeViewSet, PositionViewSet, RegisterAPIView, ResendEmailAPIView, SkillViewSet, - VerifyEmailAPIView) + VerifyEmailAPIView, GroupViewSet) from .feed.views import CommentViewSet, PostViewSet app_name = "v1" @@ -21,6 +21,7 @@ router.register(r"department-roles", DepartmentRoleViewSet, basename="department router.register(r"skills", SkillViewSet, basename="skills") router.register(r"posts", PostViewSet, basename="posts") router.register(r"comments", CommentViewSet, basename="comments") +router.register(r"groups", GroupViewSet, basename="groups") urlpatterns = [ path("auth/register/", RegisterAPIView.as_view(), name="register"), diff --git a/backend/employees/forms_front.py b/backend/employees/forms_front.py index bc39195d..035a428b 100644 --- a/backend/employees/forms_front.py +++ b/backend/employees/forms_front.py @@ -1,7 +1,7 @@ # backend\employees\forms_front.py from django import forms -from .models import Department +from .models import Department, Employee class DepartmentEditForm(forms.Form): @@ -28,3 +28,31 @@ class DepartmentForm(forms.ModelForm): class Meta: model = Department fields = ["name", "description", "head"] + + +class EmployeeForm(forms.ModelForm): + class Meta: + model = Employee + fields = ( + "first_name", + "last_name", + "patronymic", + "telegram", + "whatsapp", + "wechat", + ) + widgets = { + "first_name": forms.TextInput(attrs={"autocomplete": "given-name"}), + "last_name": forms.TextInput(attrs={"autocomplete": "family-name"}), + "whatsapp": forms.TextInput(attrs={"inputmode": "tel"}), + "telegram": forms.TextInput(), + "wechat": forms.TextInput(), + } + labels = { + "first_name": "Имя", + "last_name": "Фамилия", + "patronymic": "Отчество", + "telegram": "Telegram", + "whatsapp": "WhatsApp", + "wechat": "WeChat", + } diff --git a/backend/employees/urls_front.py b/backend/employees/urls_front.py index f78b0c4f..e1a26e50 100644 --- a/backend/employees/urls_front.py +++ b/backend/employees/urls_front.py @@ -5,32 +5,44 @@ from . import views_front app_name = "employees" urlpatterns = [ - # --- Профиль и сотрудники --- path("list/", views_front.employee_list, name="employees_list"), - path("me/", views_front.employee_me, name="profile"), - path("me/edit/", views_front.employee_edit_me, name="employee_edit_profile"), - path("/", views_front.employee_detail, name="employee_detail"), + path("me/", views_front.employee_profile, name="profile"), + path("me/edit/", views_front.employee_edit_me, name="employee_edit_me"), + path("/", views_front.employee_profile, name="employee_detail"), path("/edit/", views_front.employee_edit, name="employee_edit"), path("create/", views_front.employee_create, name="employee_create"), - # --- Отделы --- path( "departments/add/", views_front.DepartmentCreateView.as_view(), name="department_add", ), - path("departments//members//role/", views_front.edit_department_role, name="edit_department_role",), + path( + "departments//members//role/", + views_front.edit_department_role, + name="edit_department_role", + ), path("departments/", views_front.department_list, name="department_list"), - path("departments//", views_front.department_detail, name="department_detail"), - path("departments//edit/", views_front.department_edit, name="department_edit",), - path("departments//set-head/", views_front.department_set_head, name="department_set_head",), - path("departments//set-member-role/", views_front.department_set_member_role, name="department_set_member_role",), - + path( + "departments//", views_front.department_detail, name="department_detail" + ), + path( + "departments//edit/", + views_front.department_edit, + name="department_edit", + ), + path( + "departments//set-head/", + views_front.department_set_head, + name="department_set_head", + ), + path( + "departments//set-member-role/", + views_front.department_set_member_role, + name="department_set_member_role", + ), # --- Навыки --- path("/skill/add/", views_front.skill_add, name="skill_add"), path("/skill/remove/", views_front.skill_remove, name="skill_remove"), - - - ] diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 40839e50..65533178 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import base64 import mimetypes from typing import Any @@ -152,175 +153,120 @@ def employee_list(request): return render(request, "employees/employees_list.html", context) -# ---------- DETAIL ---------- +# ---------- DETAIL / ME ---------- @require_api_auth -def employee_detail(request, pk: int): +def employee_profile(request, pk="me"): + """ + /employees/me/ и /employees// + Если 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) - resp = api.get(f"v1/employees/{pk}/") + 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 {} - can_edit = bool( - request.user and (request.user.is_staff or request.user.id == emp.get("id")) - ) - posts = _fetch_employee_posts(api, pk) + 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"}) - all_skills = sresp.json if sresp.ok else [] - return render( - request, - "employees/employee_detail.html", - {"emp": emp, "can_edit": can_edit, "all_skills": all_skills, "posts": posts}, + 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")) + ) ) + ctx = { + "emp": emp, + "can_edit": can_edit, + "all_skills": all_skills, + "all_positions": all_positions, + "all_groups": all_groups, + "posts": posts, + } + if is_me: + ctx["is_me"] = True - -# ---------- ME ---------- -@require_api_auth -def employee_me(request): - api = get_api_client(request) - - resp = api.get("v1/employees/me/") - if not resp.ok: - messages.error(request, f"Ошибка API ({resp.status})") - return redirect("employees:employees_list") - - emp = resp.json or {} - - # новости сотрудника - posts = _fetch_employee_posts(api, emp.get("id")) - - # все навыки для даталиста (разворачиваем results) - sresp = api.get("v1/skills/", params={"ordering": "name"}) - data = sresp.json if sresp.ok else [] - all_skills = data.get("results", data) if isinstance(data, dict) else data - - return render( - request, - "employees/employee_detail.html", - { - "emp": emp, - "can_edit": True, - "is_me": True, - "all_skills": all_skills, - "posts": posts, - }, - ) + return render(request, "employees/employee_detail.html", ctx) # ---------- EDIT (self or staff) ---------- @require_api_auth -@require_http_methods(["GET", "POST"]) +@require_http_methods(["PATCH", "POST"]) def employee_edit(request, pk: int): """ Редактирование пользователя по id. - Разрешено staff/superuser или самому себе (проверку делает API). """ api = get_api_client(request) - - if request.method == "GET": - resp = api.get(f"v1/employees/{pk}/") - if not resp.ok: - messages.error(request, f"Сотрудник не найден ({resp.status})") - return redirect("employees:employees_list") - emp = resp.json or {} - return render(request, "employees/employee_edit.html", {"emp": emp}) - - # POST → PATCH - data = { - "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(), - } - - # skills (множественный select): skills=1&skills=2 … - skills = request.POST.getlist("skills") - if skills: - data["skills"] = skills - - # avatar: либо новый файл, либо снять (clear_avatar=on) - if request.FILES.get("avatar"): - data["avatar"] = _file_to_data_uri(request.FILES["avatar"]) - elif request.POST.get("clear_avatar") == "on": - data["avatar"] = "" # Base64ImageField очистит - - # удаляем пустые строки, чтобы не перетирать nullables - data = _cleanup_payload(data, keep_empty={"avatar"}) - - resp = api.patch(f"v1/employees/{pk}/", json=data) - if not resp.ok: - messages.error(request, f"Не удалось сохранить: {resp.status} — {resp.text}") - # Подставим обратно форму с введёнными данными - current = api.get(f"v1/employees/{pk}/").json or {} - current.update( - {k: request.POST.get(k) for k in ["last_name", "first_name", "patronymic"]} - ) - return render(request, "employees/employee_edit.html", {"emp": current}) - - messages.success(request, "Профиль обновлён.") - return redirect("employees:employee_detail", pk=pk) + 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(["GET", "POST"]) +@require_http_methods(["PATCH", "POST"]) def employee_edit_me(request): """ Редактирование собственного профиля через /api/v1/employees/me/ """ api = get_api_client(request) - - if request.method == "GET": - resp = api.get("v1/employees/me/") - if not resp.ok: - messages.error(request, f"Ошибка API ({resp.status})") - return redirect("employees:employees_list") - emp = resp.json or {} - return render( - request, "employees/employee_edit.html", {"emp": emp, "is_me": True} - ) - - data = { - "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(), - } - skills = request.POST.getlist("skills") - if skills: - data["skills"] = skills - - if request.FILES.get("avatar"): - data["avatar"] = _file_to_data_uri(request.FILES["avatar"]) - elif request.POST.get("clear_avatar") == "on": - data["avatar"] = "" - - data = _cleanup_payload(data, keep_empty={"avatar"}) - - resp = api.patch("v1/employees/me/", json=data) - if not resp.ok: - messages.error(request, f"Не удалось сохранить: {resp.status} — {resp.text}") - current = api.get("v1/employees/me/").json or {} - current.update( - {k: request.POST.get(k) for k in ["last_name", "first_name", "patronymic"]} - ) - return render( - request, "employees/employee_edit.html", {"emp": current, "is_me": True} - ) - - messages.success(request, "Профиль обновлён.") - return redirect("employees:employee_me") + # принимаем 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) ---------- diff --git a/backend/templates/employees/_employee_edit.html b/backend/templates/employees/_employee_edit.html new file mode 100644 index 00000000..72a298aa --- /dev/null +++ b/backend/templates/employees/_employee_edit.html @@ -0,0 +1,692 @@ +{# templates/employees/_employee_edit.html #} + + +
+ {% csrf_token %} + + {# === ЛИЧНЫЕ ДАННЫЕ === #} +
+
+
+ +
Личные данные
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + {# === КОНТАКТЫ === #} +
+
+
+ +
Контакты
+
+
+
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+ + {# === ДОЛЖНОСТЬ (как раньше) === #} +
+ {% if can_edit_position or request.user.is_staff or request.user.is_superuser %} +
+
+ +
Должность
+
+
+
+
+ + {% if all_positions %} + + {% else %} + +
Передайте в контекст all_positions для выпадающего списка.
+ {% endif %} +
+ + {% if can_create_position or request.user.is_staff or request.user.is_superuser %} +
+ + +
+ +
+
+
+
+ + +
+
+ + +
+ + {% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %} +
+ {# --- Группы прав (мобильно-дружелюбный выбор) --- #} + {% if all_groups %} +
+ {# выбранные группы в виде чипсов #} +
+ {# хранение выбранных id (csv), чтобы JS легко забрал #} + + + +
+ + {# Модальное окно выбора групп #} + + {% else %} + {# Фолбэк: если в контексте нет all_groups — оставить поле CSV #} +
+ + +
+ {% endif %} +
+ {% endif %} + +
+ + + +
+ +
+
+
+
+
+
+ {% endif %} +
+
+
+ {% else %} +
+
+ +
Должность
+
+
+ +
+
+ {% endif %} +
+ + {# === КНОПКИ === #} +
+ + +
+
+ + + + + + + diff --git a/backend/templates/employees/employee_detail.html b/backend/templates/employees/employee_detail.html index 43e5a78f..a6669e5e 100644 --- a/backend/templates/employees/employee_detail.html +++ b/backend/templates/employees/employee_detail.html @@ -202,7 +202,7 @@ /* ====== SKILLS UI ====== */ /* плюс как «пилюля» */ - #skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;border-style:dashed} + #skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;cursor:pointer;border-style:dashed} #skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease} #skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem} @@ -267,9 +267,18 @@ {{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %} {% if can_edit %} - + + {% endif %} @@ -478,83 +487,105 @@ {% endwith %} {% endif %} {# ====== НАВЫКИ ====== #} -
-
Навыки
+
+
Навыки
-
- {% with skills=emp.skills %} - {% if skills %} - {% for s in skills %} - {% firstof s.name s as sk_name %} - {% if sk_name %} - - {{ sk_name }} +
+ {% with skills=emp.skills %} + {% if skills %} + {% for s in skills %} + {% firstof s.name s as sk_name %} + {% if sk_name %} + + {{ sk_name }} + + {# кнопка «минус» только если есть право #} + {% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %} + + {% endif %} + + {% endif %} + {% endfor %} + {% else %} + + {% endif %} + {% endwith %} + + {# плюс только если есть право #} + {% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %} + + {% endif %} +
- {# кнопка «минус» только если есть право #} {% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %} - +
+
+ {% csrf_token %} +
+
+ + + {% for sk in all_skills %} + {% if sk.name %}{% endif %} + {% endfor %} + +
+
+ + +
+
+
+
{% endif %} -
- {% endif %} - {% endfor %} - {% else %} - - {% endif %} - {% endwith %} - - {# плюс только если есть право #} - {% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %} - - {% endif %} -
- - {% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %} -
-
- {% csrf_token %} -
-
- - - {% for sk in all_skills %} - {% if sk.name %}{% endif %} - {% endfor %} - -
-
- - -
-
-
-
- {% endif %} -
- - +
+ {% if can_edit %} +
+
+
+
+ +
Редактирование профиля
+
+ +
+
+
+ {% if is_me %} + {% url 'employees:employee_edit_me' as edit_endpoint %} + {% else %} + {% url 'employees:employee_edit' emp.id as edit_endpoint %} + {% endif %} + {% include "employees/_employee_edit.html" with emp=emp endpoint=edit_endpoint %} +
+
+
+
+ {% endif %}
Активность
@@ -562,9 +593,6 @@
{% include "feed/_feed_cards.html" with items=posts only %}
- - {% else %} -
Публикаций пока нет.
{% endif %}
@@ -572,6 +600,8 @@
+ +{% endblock %} {% block extra_js %}