From c78d1e5fea8842175746f33996cc346429122768 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: Wed, 27 Aug 2025 17:58:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BB=D1=8E=D1=87=D0=B5=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20-=20**EmployeeActionSerializer**=20=20=20-=20=D0=94=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20`history[]`=20=D1=81?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BD=D0=B0=D0=BF=D1=88=D0=BE=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20simple-history=20=D0=B8=20diff=20=D0=BF=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F=D0=BC:=20`action`,=20`date`,=20`comment`,=20?= =?UTF-8?q?`extra`,=20`employee=5Fid`.=20=20=20-=20=D0=9B=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B9=20=D1=81=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B9=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20`employee?= =?UTF-8?q?s.constants.ACTION=5FCHOICES`.=20=20=20-=20=D0=9F=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F=20`action=5Fdisplay`,=20`date=5Fdisplay`=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=83=D0=B4=D0=BE=D0=B1=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **EmployeeSerializer** - Возвращает `actions` (массив событий) с историей, **только** если в контексте выставлено `include_action_history=True`. - Навыки: чтение — `skills` как список объектов `{id, name}`, запись — через `skills_ids`. - Должность: чтение — объект `{id, name}`, запись — через `position_id`. - Отделы: добавлен флаг `is_head` (сотрудник — руководитель отдела). - **EmployeeListSerializer** - Те же правки для навыков/должности и `is_head`, без `actions` (для производительности). - **Views (EmployeeViewSet)** - `get_queryset`: префетч `actions` добавляется **только** для `retrieve/me`, иначе не тянем историю. - `get_serializer_context`: для `retrieve` и `me` выставляем `include_action_history=True`. - Экшен `me`: выборка с `Prefetch("actions", order_by -date)` и передачей `include_action_history=True`. - **Разное** - Используем `ACTION_CHOICES` для «человеческих» названий событий в истории. - `HistoryActionMixin` оставлен без изменений; история объектов доступна по `/{basename}/{pk}/history/`. Почему так - История кадровых событий бывает тяжёлой → грузим и отдаём её только там, где нужно (`retrieve/me`). - Явное разделение read/write для навыков и должности упрощает фронту жизнь и убирает двусмысленность. - `is_head` позволяет фронту не вычислять этот флаг отдельно. Файлы - `backend/api/v1/employees/serializers.py` - `backend/api/v1/employees/views.py` - (использованы константы из) `backend/employees/constants.py` Тесты/проверка 1) GET `/api/v1/employees/1/` → поле `actions[]` заполнено, внутри каждого элемента `history[]` со снапшотами и переводами событий. 2) GET `/api/v1/employees/?page=1` → `actions` отсутствуют (по замыслу), навыки и должность приходят в «красивом» виде. 3) GET `/api/v1/employees/me/` → аналогично `retrieve`, с историей. * UI (templates/employees/employee\_detail.html) * Навыки: больше не итерируем по строке, выводим объекты `{id,name}` (`s.name`). Защита от пустых списков. * Отделы: убрал «аватарки» у элементов списка, оставил иконку у кнопки раздела. Роль сотрудника показывается «Название отдела — Роль». Для руководителя добавлен понятный бейдж с иконкой `bi-award-fill` + tooltip «Руководитель отдела». * Кадровые события: добавлен раздел «Кадровые события» (для аутентифицированных). Отрисовывает список всех событий (дата, комментарий, extra), а также историю каждого события (`action.history`) в `
`. В истории показываем только название события и новые значения полей (без «создано/изменено» и без зачёркнутых старых значений). Перевод кодов событий (`hired`, `dismissed`, …) в человекочитаемый вид согласно `employees/constants.py`. * Аккордеон: кнопка и выпадающая панель объединены в один блок `.ios-acc` единой ширины. Добавлена разделительная линия между заголовком и контентом (`.ios-acc-body { border-top: ... }`). Исправлены дёрганья при анимации: убраны внешние отступы у `.collapse`, возврат стандартной скорости Bootstrap (`.35s ease`), `overflow:hidden` на фазе `collapsing`. * Views (employees/views.py — веб-приложение) * В `employee_detail` и `employee_me` дополнительно запрашиваем полную историю кадровых событий: `GET v1/employee-actions/?employee=&ordering=-date` и передаём в шаблон `employee_actions`. Тем самым отображаем все события, а не только последнее. * Шаблон умеет брать события из `employee_actions`, а при отсутствии — из `emp.actions`. * API (backend/api/v1/employees/serializers.py) * `EmployeeActionSerializer`: добавлен `history` (MethodField) с формированием массива изменений из `HistoricalRecords` (без старых значений; только `changes[field].new`, дата/пользователь). Добален `action_display` и `date_display` для удобного рендера на фронте. * `EmployeeSerializer`: возвращает `departments` через `get_departments()` (id, name, role\_id, role\_name, is\_head) и `skills` как массив объектов `{id,name}`. * Прочее/фиксы * Безопасные проверки на пустые коллекции (контакты, навыки, отделы, события). * Устранена ошибка шаблона «Failed lookup…» при попытке обратиться к отсутствующим ключам. * Не изменяем скорость анимации: возвращён дефолт Bootstrap; корректное закрытие панелей. Refs: employees/constants.py (ACTION\_CHOICES), templates/employees/employee\_detail.html, employees/views.py, api/v1/employees/serializers.py --- backend/api/client.py | 27 +- backend/api/v1/employees/serializers.py | 229 +++++-- backend/api/v1/employees/views.py | 100 ++- backend/api/v1/feed/serializers.py | 82 ++- backend/api/v1/feed/views.py | 88 ++- backend/api/v1/urls.py | 9 +- backend/employees/constants.py | 1 + backend/employees/models.py | 96 +-- backend/employees/views_front.py | 24 +- backend/eusrr_backend/urls.py | 2 +- backend/feed/forms_front.py | 42 ++ backend/feed/urls_front.py | 19 + backend/feed/views_front.py | 611 ++++++++++++++++++ backend/templates/base.html | 151 ++--- .../templates/employees/employee_detail.html | 342 ++++++++-- .../templates/employees/employee_edit.html | 2 +- backend/templates/feed/_feed_cards.html | 213 ++++-- backend/templates/feed/feed_list.html | 332 ++++++++-- backend/templates/feed/post_detail.html | 206 ++++-- backend/templates/includes/navbar.html | 1 - backend/templates/includes/sidebar.html | 230 +++++-- .../api/v1/employees/test_departments.py | 1 - .../tests/api/v1/employees/test_employees.py | 23 - 23 files changed, 2236 insertions(+), 595 deletions(-) create mode 100644 backend/feed/forms_front.py create mode 100644 backend/feed/urls_front.py create mode 100644 backend/feed/views_front.py diff --git a/backend/api/client.py b/backend/api/client.py index 5e2d0a86..7bb04347 100644 --- a/backend/api/client.py +++ b/backend/api/client.py @@ -1,3 +1,4 @@ +# backend\api\client.py from dataclasses import dataclass from typing import Any, Dict, Optional @@ -67,7 +68,7 @@ class ApiClient: def _make_url(self, path: str) -> str: return f"{self.cfg.base_url}/{path.lstrip('/')}" - + def _request( self, method: str, @@ -75,12 +76,18 @@ class ApiClient: *, params=None, json=None, + data=None, + files=None, headers=None, retry=True, ) -> ApiResponse: url = self._make_url(path) hdrs = dict(self.cfg.default_headers or {}) + # Не выставляем Content-Type вручную — requests сделает это сам (особенно важно для multipart) if headers: + # если вдруг передали Content-Type, но у нас files — лучше его удалить + if files and "Content-Type" in headers: + headers = {k: v for k, v in headers.items() if k.lower() != "content-type"} hdrs.update(headers) hdrs.update(self._auth_headers()) @@ -88,7 +95,9 @@ class ApiClient: method, url, params=params, - json=json, + json=None if files or data else json, # при multipart json не используем + data=data, + files=files, headers=hdrs, timeout=self.cfg.timeout, ) @@ -101,19 +110,19 @@ class ApiClient: method, url, params=params, - json=json, + json=None if files or data else json, + data=data, + files=files, headers=hdrs, timeout=self.cfg.timeout, ) try: - data = resp.json() + data_json = resp.json() except Exception: - data = None - return ApiResponse( - ok=resp.ok, status=resp.status_code, json=data, text=resp.text - ) - + data_json = None + return ApiResponse(ok=resp.ok, status=resp.status_code, json=data_json, text=resp.text) + # --- публичные методы HTTP --- def get(self, path: str, **kw) -> ApiResponse: return self._request("GET", path, **kw) diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 8a148c56..0d87ebfc 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -5,18 +5,13 @@ 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 django.utils import timezone +from employees.models import (Department, DepartmentRole, EmployeeAction, + EmployeeDepartment, Position, Skill) from PIL import Image from rest_framework import serializers -from employees.models import ( - Department, - DepartmentRole, - EmployeeAction, - EmployeeDepartment, - Position, - Skill, -) +from employees.constants import ACTION_CHOICES + Employee = get_user_model() @@ -102,7 +97,7 @@ class EmailSerializer(serializers.Serializer): class EmailVerifySerializer(serializers.Serializer): email = serializers.EmailField() - code = serializers.CharField(max_length=32) + code = serializers.CharField(max_length=6) class RegisterSerializer(serializers.Serializer): @@ -125,30 +120,144 @@ class RegisterSerializer(serializers.Serializer): skills = serializers.ListField(child=serializers.IntegerField(), required=False) +class SkillSerializer(serializers.ModelSerializer): + class Meta: + model = Skill + fields = ["id", "name"] + + +class PositionBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Position + fields = ("id", "name") + + +class EmployeeActionSerializer(serializers.ModelSerializer): + action_display = serializers.SerializerMethodField() + date_display = serializers.SerializerMethodField() + history = serializers.SerializerMethodField() + + class Meta: + model = EmployeeAction + fields = [ + "id", + "employee", + "action", + "action_display", + "date", + "date_display", + "comment", + "extra", + "history", # ← добавили + ] + + def get_action_display(self, obj): + return obj.get_action_display() + + def get_date_display(self, obj): + return obj.date.strftime("%d.%m.%Y %H:%M") if obj.date else None + + def get_history(self, obj): + if not self.context.get("include_action_history", False): + return [] + + items = list( + obj.history.select_related("history_user") + .order_by("-history_date", "-history_id") + ) + + action_labels = dict(ACTION_CHOICES) + history_type_labels = {"+": "Создано", "~": "Изменено", "-": "Удалено"} + diff_fields = ["action", "comment", "extra"] + + out = [] + for i, cur in enumerate(items): + prev = items[i + 1] if i + 1 < len(items) else None + changes = {} + + for name in diff_fields: + new = getattr(cur, name, None) + old = getattr(prev, name, None) if prev else None + + if name == "date": + new_fmt = new.isoformat() if new else None + old_fmt = old.isoformat() if old else None + if new_fmt != old_fmt: + changes[name] = {"old": old_fmt, "new": new_fmt} + elif name == "action": + if old != new: + changes[name] = { + "old": old, + "old_display": action_labels.get(old), + "new": new, + "new_display": action_labels.get(new), + } + else: + if old != new: + changes[name] = {"old": old, "new": new} + + out.append( + { + "history_id": cur.history_id, + "history_type": cur.history_type, + "history_type_display": history_type_labels.get(cur.history_type), + "history_date": cur.history_date.isoformat(), + "history_date_display": cur.history_date.strftime("%d.%m.%Y %H:%M"), + "history_user": getattr(cur.history_user, "email", None), + # текущее значение действия в этом снимке + «человеческий» перевод + "action": getattr(cur, "action", None), + "action_display": action_labels.get(getattr(cur, "action", None)), + "changes": changes, + } + ) + return out + + class EmployeeSerializer(serializers.ModelSerializer): """Полная версия сотрудника для собственных эндпоинтов /employees/.""" avatar = Base64ImageField(required=False, allow_null=True) + actions = EmployeeActionSerializer(many=True, read_only=True) + + # навыки: читаем красиво, пишем ids + skills = SkillSerializer(many=True, read_only=True) + skills_ids = serializers.PrimaryKeyRelatedField( + queryset=Skill.objects.all(), many=True, write_only=True, required=False + ) + + # должность: читаем объектом, пишем через position_id + position = PositionBriefSerializer(read_only=True) + position_id = serializers.PrimaryKeyRelatedField( + source="position", + queryset=Position.objects.all(), + write_only=True, + required=False, + allow_null=True, + ) + + departments = serializers.SerializerMethodField() class Meta: model = Employee - # Явно укажем поля, чтобы не протекали чувствительные служебные fields = ( + "avatar", + "actions", "id", "email", "last_name", "first_name", "patronymic", + "departments", "gender", - "avatar", "phone_number", "birth_date", "position", + "position_id", "telegram", "whatsapp", "wechat", "skills", - # служебные только read-only: + "skills_ids", "is_active", "email_verified", "created_at", @@ -169,12 +278,53 @@ class EmployeeSerializer(serializers.ModelSerializer): "password": {"write_only": True, "required": False}, } - # На всякий случай выкинем поля, которых нет в fields, но если вдруг DRF попытается их притащить (обычно не будет). def to_representation(self, instance): data = super().to_representation(instance) - # Ничего лишнего тут нет, просто оставил хук, если захочешь добавить правку URL для avatar до абсолютного и т.п. return data + def update(self, instance, validated_data): + skills = validated_data.pop("skills_ids", None) + instance = super().update(instance, validated_data) + if skills is not None: + instance.skills.set(skills) + return instance + + def get_departments(self, obj): + links = getattr(obj, "dept_links", None) + if links is None: + links = EmployeeDepartment.objects.select_related( + "department", "role" + ).filter(employee=obj, is_active=True) + out = [] + for link in links: + dept = link.department + out.append( + { + "id": link.department_id, + "name": dept.name if link.department_id else None, + "role_id": link.role_id, + "role_name": link.role.name if link.role_id else None, + "is_head": (dept.head_id == obj.id), + } + ) + return out + + def get_fields(self): + """ + Показываем actions только в деталке сотрудника и в /employees/me, + либо если явно передан context['include_actions']=True. + Во всех остальных контекстах поле скрываем, чтобы не раздувать ответы. + """ + fields = super().get_fields() + if self.context.get("include_actions") is True: + return fields + + view = self.context.get("view") + action = getattr(view, "action", None) if view else None + if action not in {"retrieve", "me"}: + fields.pop("actions", None) + return fields + class DepartmentSerializer(serializers.ModelSerializer): head = EmployeeSerializer(read_only=True) @@ -199,7 +349,9 @@ class DepartmentSerializer(serializers.ModelSerializer): "created_at", "employees_count", ) - read_only_fields = ("head_appointed_at", "created_at") + + # head_appointed_at и created_at только для чтения + read_only_fields = ("head_appointed_at", "created_at") def validate_head_id(self, value: Employee | None): if value and not value.is_actually_active: @@ -209,18 +361,14 @@ class DepartmentSerializer(serializers.ModelSerializer): return value -class PositionBriefSerializer(serializers.ModelSerializer): - class Meta: - model = Position - fields = ("id", "name") - - class EmployeeListSerializer(serializers.ModelSerializer): email = serializers.EmailField(read_only=True) phone_number = serializers.CharField(read_only=True) avatar = Base64ImageField(read_only=True) position = PositionBriefSerializer(read_only=True) departments = serializers.SerializerMethodField() + full_name = serializers.SerializerMethodField() + skills = SkillSerializer(many=True, read_only=True) class Meta: model = Employee @@ -231,9 +379,12 @@ class EmployeeListSerializer(serializers.ModelSerializer): "last_name", "first_name", "patronymic", + "full_name", "avatar", "position", "departments", + "skills", + "created_at", ) def get_departments(self, obj): @@ -244,16 +395,25 @@ class EmployeeListSerializer(serializers.ModelSerializer): ).filter(employee=obj, is_active=True) out = [] for link in links: + dept = link.department out.append( { "id": link.department_id, - "name": link.department.name if link.department_id else None, + "name": dept.name if link.department_id else None, "role_id": link.role_id, "role_name": link.role.name if link.role_id else None, + "is_head": (dept.head_id == obj.id), # ✅ правильная проверка } ) return out + def get_full_name(self, obj): + fn = (obj.first_name or "").strip() + ln = (obj.last_name or "").strip() + pt = (obj.patronymic or "").strip() + nm = f"{ln} {fn} {pt}".strip() + return nm + class PositionSerializer(serializers.ModelSerializer): # для записи — массив id групп, для чтения — подробная инфа @@ -280,9 +440,6 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): model = DepartmentRole fields = ["id", "department", "name", "permissions", "permissions_verbose"] - def get_permissions(self, obj): - return list(obj.permissions.values_list("id", flat=True)) - def get_permissions_verbose(self, obj): return [ { @@ -292,23 +449,3 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): } for p in obj.permissions.select_related("content_type").all() ] - - -class SkillSerializer(serializers.ModelSerializer): - class Meta: - model = Skill - fields = ["id", "name"] - - -class EmployeeActionSerializer(serializers.ModelSerializer): - # по умолчанию ставим текущее время, если не передали - date = serializers.DateTimeField(required=False) - - class Meta: - model = EmployeeAction - fields = ["id", "employee", "action", "date", "comment", "extra"] - - def validate(self, attrs): - if "date" not in attrs: - attrs["date"] = timezone.now() - return attrs diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index ed4d82e2..224badd2 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -2,20 +2,37 @@ from __future__ import annotations from datetime import timedelta +from django.db.models import Prefetch import phonenumbers from common.emails import send_templated_mail from django.conf import settings from django.contrib.auth.models import Group, Permission from django.db import IntegrityError, transaction -from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef, - Q, Subquery, Value, When) +from django.db.models import ( + Case, + Count, + Exists, + F, + IntegerField, + OuterRef, + Q, + Subquery, + Value, + When, +) from django.utils import timezone from django.utils.crypto import get_random_string from employees.constants import ACTION_DISMISSED -from employees.models import (Department, DepartmentRole, Employee, - EmployeeAction, EmployeeDepartment, Position, - Skill) +from employees.models import ( + Department, + DepartmentRole, + Employee, + EmployeeAction, + EmployeeDepartment, + Position, + Skill, +) from phonenumbers import PhoneNumberFormat from rest_framework import filters, status, viewsets from rest_framework.decorators import action @@ -23,14 +40,25 @@ from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from ..permissions import (ASSIGN_ROLE_PERM, MANAGE_PERM, - IsDeptManagerForWrite, IsSelfOrStaff, - IsStaffOrHasModelPerm) -from .serializers import (DepartmentRoleSerializer, DepartmentSerializer, - EmailSerializer, EmailVerifySerializer, - EmployeeActionSerializer, EmployeeListSerializer, - EmployeeSerializer, PositionSerializer, - RegisterSerializer, SkillSerializer) +from ..permissions import ( + ASSIGN_ROLE_PERM, + MANAGE_PERM, + IsDeptManagerForWrite, + IsSelfOrStaff, + IsStaffOrHasModelPerm, +) +from .serializers import ( + DepartmentRoleSerializer, + DepartmentSerializer, + EmailSerializer, + EmailVerifySerializer, + EmployeeActionSerializer, + EmployeeListSerializer, + EmployeeSerializer, + PositionSerializer, + RegisterSerializer, + SkillSerializer, +) def _to_bool(val: str | None) -> bool | None: @@ -101,8 +129,8 @@ class HistoryActionMixin: pass if q_user: qs = qs.filter( - models.Q(history_user__id__iexact=q_user) - | models.Q(history_user__email__iexact=q_user) + Q(history_user__id__iexact=q_user) + | Q(history_user__email__iexact=q_user) ) if q_type in {"+", "~", "-"}: qs = qs.filter(history_type=q_type) @@ -527,10 +555,21 @@ class EmployeeViewSet(viewsets.ModelViewSet): .order_by("-date") .values("action")[:1] ) + dep_links_prefetch = Prefetch( + "departments_links", + queryset=EmployeeDepartment.objects.filter(is_active=True).select_related("department", "role"), + to_attr="dept_links", + ) + + prefetches = [ + "skills", + dep_links_prefetch, + Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")), + ] qs = ( Employee.objects.select_related("position") - .prefetch_related("skills") + .prefetch_related(*prefetches) .annotate(last_action_code=last_action_code_sq) .order_by(*self.ordering) ) @@ -645,8 +684,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - # M2M навыки после save - skills = vd.pop("skills", []) + skills = vd.pop("skills_ids", []) # теперь в vd нет email/phone_number — дублирования не будет user = Employee.objects.create_user( @@ -671,7 +709,24 @@ class EmployeeViewSet(viewsets.ModelViewSet): """ instance: Employee = request.user # type: ignore if request.method.lower() == "get": - return Response(self.get_serializer(instance).data) + instance = ( + Employee.objects.select_related("position") + .prefetch_related( + "skills", + Prefetch( + "departments_links", + queryset=EmployeeDepartment.objects.filter(is_active=True).select_related("department", "role"), + to_attr="dept_links", + ), + Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")), + ) + .get(pk=request.user.pk) + ) + ctx = self.get_serializer_context() + ctx["include_actions"] = True + ctx["include_action_history"] = True # <-- добавили + return Response(self.get_serializer(instance, context=ctx).data) + ser = self.get_serializer(instance, data=request.data, partial=True) ser.is_valid(raise_exception=True) @@ -691,6 +746,13 @@ class EmployeeViewSet(viewsets.ModelViewSet): ) ser.save() return Response(ser.data) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + if getattr(self, "action", None) in {"retrieve", "me"}: + ctx["include_actions"] = True + ctx["include_action_history"] = True # <-- добавили + return ctx class PositionViewSet(HistoryActionMixin, viewsets.ModelViewSet): diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py index 18ed3977..9416997c 100644 --- a/backend/api/v1/feed/serializers.py +++ b/backend/api/v1/feed/serializers.py @@ -1,11 +1,12 @@ +# backend/api/v1/feed/serializers.py from __future__ import annotations from django.contrib.auth import get_user_model -from django.utils.encoding import iri_to_uri -from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE -from feed.models import Comment, Post from rest_framework import serializers +from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE +from feed.models import Comment, Post, PostLike + Employee = get_user_model() @@ -20,16 +21,16 @@ class AuthorMiniSerializer(serializers.ModelSerializer): def get_full_name(self, obj): fn = (obj.first_name or "").strip() ln = (obj.last_name or "").strip() - return f"{fn} {ln}".strip() or None + nm = f"{fn} {ln}".strip() + return nm def get_avatar_url(self, obj): - # Пытаемся найти поле с аватаром, не знаем точное имя: avatar/photo/image + # пробуем несколько распространённых имён поля/метода try: for attr in ("avatar", "photo", "image"): f = getattr(obj, attr, None) if f and getattr(f, "url", None): return f.url - # часто встречается метод-хелпер if hasattr(obj, "get_avatar_url"): return obj.get_avatar_url() except Exception: @@ -37,49 +38,91 @@ class AuthorMiniSerializer(serializers.ModelSerializer): return None +class CommentMiniSerializer(serializers.ModelSerializer): + author = AuthorMiniSerializer(read_only=True) + created_at_display = serializers.DateTimeField( + source="created_at", format="%d.%m.%Y %H:%M", read_only=True + ) + + class Meta: + model = Comment + fields = ["id", "text", "created_at", "created_at_display", "author"] + + class PostListSerializer(serializers.ModelSerializer): - # читаем прямое поле БД post.author_id author_id = serializers.IntegerField(read_only=True) author = AuthorMiniSerializer(read_only=True) + # удoбные представления дат + created_at_display = serializers.DateTimeField( + source="created_at", format="%d.%m.%Y %H:%M", read_only=True + ) + + # ожидания фронта + is_liked = serializers.BooleanField(read_only=True, default=False) + comments_count = serializers.IntegerField(read_only=True, default=0) + # для ссылок на отдел + department_id = serializers.IntegerField(read_only=True) + last_comment = serializers.SerializerMethodField() + class Meta: model = Post fields = [ "id", "type", - "department", # PK отдела + "department", # write/read PK + "department_id", # read-only alias "title", "body", "image", "attachment", "created_at", + "created_at_display", "pinned", "likes_count", + "comments_count", "author_id", "author", + "is_liked", + "last_comment", ] read_only_fields = ( "created_at", + "created_at_display", "pinned", "likes_count", + "comments_count", "author_id", "author", + "department_id", + "is_liked", ) + def get_last_comment(self, obj): + # Берём готовую мапу из контекста, собранную во viewset.list() + lc_map = self.context.get("last_comments_map") or {} + lc = None + lc_id = getattr(obj, "last_comment_id", None) + if lc_id: + lc = lc_map.get(lc_id) + if not lc: + return None + return CommentMiniSerializer(lc, context=self.context).data + class PostSerializer(PostListSerializer): def validate(self, attrs): - # берём значения с учётом partial-update + # значения с учётом partial t = attrs.get("type", getattr(self.instance, "type", None)) dept = attrs.get("department", getattr(self.instance, "department", None)) - # Личные посты запрещены + # личные посты запрещены if t == TYPE_EMPLOYEE: raise serializers.ValidationError( {"type": "Публикация на личной странице запрещена."} ) - # Валидация связки type/department (чтобы давать 400, а не 500 от БД) + # связка type/department if t == TYPE_DEPARTMENT and not dept: raise serializers.ValidationError( {"department": "Нужно указать отдел для новостей отдела."} @@ -95,23 +138,25 @@ class CommentSerializer(serializers.ModelSerializer): post_id = serializers.IntegerField(read_only=True) author_id = serializers.IntegerField(read_only=True) author = AuthorMiniSerializer(read_only=True) - - # Принимаем файлы как FileField, чтобы не требовать реального валидного изображения. image = serializers.FileField(allow_null=True, required=False) attachment = serializers.FileField(allow_null=True, required=False) + created_at_display = serializers.DateTimeField( + source="created_at", format="%d.%m.%Y %H:%M", read_only=True + ) class Meta: model = Comment fields = [ "id", - "post", # write-only - "post_id", # read-only + "post", + "post_id", "author_id", "author", "text", "image", "attachment", "created_at", + "created_at_display", ] extra_kwargs = { "post": {"write_only": True}, @@ -121,12 +166,13 @@ class CommentSerializer(serializers.ModelSerializer): def validate(self, attrs): text = (attrs.get("text") or "").strip() if not (text or attrs.get("image") or attrs.get("attachment")): - raise serializers.ValidationError({"text": "Нужно указать текст, изображение или файл."}) + raise serializers.ValidationError( + {"text": "Нужно указать текст, изображение или файл."} + ) return attrs def create(self, validated_data): - # Автор выставляется во view; здесь сохраняем без model.full_clean(), - # чтобы не споткнуться о ImageField/Pillow в тестовых «заглушках». + # author выставляется во view instance = Comment(**validated_data) instance.save(skip_validation=True) return instance diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py index 9dbd5646..d2395a43 100644 --- a/backend/api/v1/feed/views.py +++ b/backend/api/v1/feed/views.py @@ -1,6 +1,10 @@ from __future__ import annotations -from django.db.models import Count, F +from django.contrib.auth import get_user_model +from django.db.models import (BooleanField, Count, Exists, F, OuterRef, + Subquery, Value) +from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE +from feed.models import Comment, Post, PostLike from rest_framework import filters, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -8,17 +12,12 @@ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response -from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE -from feed.models import Comment, Post, PostLike - -from ..permissions import ( - IsAuthorOrStaffForComments, - user_has_dept_perm, - user_is_dept_head, - user_is_staffish, -) +from ..permissions import (IsAuthorOrStaffForComments, user_has_dept_perm, + user_is_dept_head, user_is_staffish) from .serializers import CommentSerializer, PostListSerializer, PostSerializer +Employee = get_user_model() + def _to_id(val): if val is None: @@ -51,20 +50,30 @@ class PostViewSet(viewsets.ModelViewSet): def get_queryset(self): qs = super().get_queryset() - qp = self.request.query_params + user = self.request.user + if user.is_authenticated: + qs = qs.annotate( + is_liked=Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user)) + ) + else: + qs = qs.annotate(is_liked=Value(False, output_field=BooleanField())) + + # только ID последнего комментария — остальное соберём в сериализаторе + lc = Comment.objects.filter(post_id=OuterRef("pk")).order_by("-created_at") + qs = qs.annotate(last_comment_id=Subquery(lc.values("id")[:1])) + + # дальше — ваши фильтры type/department/author/pinned (как были) + qp = self.request.query_params t = qp.get("type") if t: qs = qs.filter(type=t) - dept_id = _to_id(qp.get("department")) if dept_id is not None: qs = qs.filter(department_id=dept_id) - author_id = _to_id(qp.get("author")) if author_id is not None: qs = qs.filter(author_id=author_id) - pinned = qp.get("pinned") if pinned in ("true", "1"): qs = qs.filter(pinned=True) @@ -72,6 +81,34 @@ class PostViewSet(viewsets.ModelViewSet): qs = qs.filter(pinned=False) return qs + + def list(self, request, *args, **kwargs): + """ + Сбор последних комментариев пачкой, без N+1: + 1) получаем страницу/список постов; + 2) вытаскиваем их last_comment_id; + 3) делаем один запрос in_bulk() с select_related('author'); + 4) кладём карту в serializer.context. + """ + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + objects = page if page is not None else queryset + + ids = [getattr(o, "last_comment_id", None) for o in objects] + ids = [i for i in ids if i] + last_comments_map = {} + if ids: + last_comments_map = Comment.objects.select_related("author").in_bulk(ids) + + serializer = self.get_serializer( + objects, + many=True, + context={**self.get_serializer_context(), "last_comments_map": last_comments_map}, + ) + if page is not None: + return self.get_paginated_response(serializer.data) + return Response(serializer.data) # ---- permissions helpers def _can_create(self, user, t, dept): @@ -82,7 +119,6 @@ class PostViewSet(viewsets.ModelViewSet): if t == TYPE_COMPANY: return user_is_staffish(user) or user.has_perm("feed.publish_company_post") if t == TYPE_DEPARTMENT and dept: - # В ролях сравниваем по codename без префикса app: return ( user_is_staffish(user) or user_is_dept_head(user, dept) @@ -142,26 +178,34 @@ class PostViewSet(viewsets.ModelViewSet): if created: Post.objects.filter(pk=post.pk).update(likes_count=F("likes_count") + 1) post.refresh_from_db(fields=["likes_count"]) - return Response({"liked": True, "likes_count": post.likes_count}, status=status.HTTP_200_OK) + return Response( + {"liked": True, "likes_count": post.likes_count}, status=status.HTTP_200_OK + ) @action(detail=True, methods=["post"]) def unlike(self, request, pk=None): post = self.get_object() deleted, _ = PostLike.objects.filter(post=post, user=request.user).delete() if deleted: - Post.objects.filter(pk=post.pk, likes_count__gt=0).update(likes_count=F("likes_count") - 1) + Post.objects.filter(pk=post.pk, likes_count__gt=0).update( + likes_count=F("likes_count") - 1 + ) post.refresh_from_db(fields=["likes_count"]) - return Response({"liked": False, "likes_count": post.likes_count}, status=status.HTTP_200_OK) + return Response( + {"liked": False, "likes_count": post.likes_count}, status=status.HTTP_200_OK + ) class CommentViewSet(viewsets.ModelViewSet): """ /api/v1/comments/ - - GET list/retrieve — аутентифицированные - - POST create — аутентифицированные (author = request.user) - - PATCH/PUT/DELETE — автор или staff/superuser - Фильтры: ?post=, ?author=; сортировка created_at ASC + - GET: аутентифицированные + - POST: аутентифицированные (author = request.user) + - PATCH/PUT/DELETE: автор или staff/superuser + Фильтры: ?post=, ?author= + Сортировка: created_at ASC """ + queryset = Comment.objects.select_related("post", "author").all() serializer_class = CommentSerializer permission_classes = [IsAuthenticated, IsAuthorOrStaffForComments] diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index a7c7a1d6..d84ac081 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -3,10 +3,11 @@ from rest_framework.routers import DefaultRouter from .calendar.viewsets import CompanyEventsViewSet, DepartmentEventsViewSet from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet, - EmployeeViewSet, PositionViewSet, - RegisterAPIView, ResendEmailAPIView, - VerifyEmailAPIView, SkillViewSet, EmployeeActionViewSet,) -from .feed.views import PostViewSet, CommentViewSet + EmployeeActionViewSet, EmployeeViewSet, + PositionViewSet, RegisterAPIView, + ResendEmailAPIView, SkillViewSet, + VerifyEmailAPIView) +from .feed.views import CommentViewSet, PostViewSet app_name = "api_v1" diff --git a/backend/employees/constants.py b/backend/employees/constants.py index 97bd6abb..4908bdad 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -1,3 +1,4 @@ +# backend\employees\constants.py GENDER_CHOICES = ( (1, 'Мужской'), (2, 'Женский'), diff --git a/backend/employees/models.py b/backend/employees/models.py index ac2a5a02..f00ba565 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -292,55 +292,65 @@ class Department(models.Model): ) head_changed = old_head_id != self.head_id + + # Если назначаем нового руководителя — фиксируем момент назначения if head_changed and self.head_id: - # фиксируем момент назначения нового руководителя self.head_appointed_at = timezone.now() super().save(*args, **kwargs) - if not is_create and head_changed: - # 1) Новый глава: гарантируем активную ссылку - if self.head_id: - try: - link, created = EmployeeDepartment.objects.get_or_create( - employee_id=self.head_id, - department_id=self.pk, - defaults={"is_active": True, "date_from": timezone.now().date()}, - ) - updated = False - if not created and not link.is_active: - link.is_active = True - updated = True - if link.date_from is None: - link.date_from = timezone.now().date() - updated = True - if updated: - link.save(update_fields=["is_active", "date_from"]) - except IntegrityError: - EmployeeDepartment.objects.filter( - employee_id=self.head_id, department_id=self.pk - ).update(is_active=True) + # --- Гарантируем: текущий руководитель всегда сотрудник отдела --- + if self.head_id: + try: + link, created = EmployeeDepartment.objects.get_or_create( + employee_id=self.head_id, + department_id=self.pk, + defaults={ + "is_active": True, + "date_from": timezone.now().date(), + }, + ) + updates = {} + if not link.is_active: + updates["is_active"] = True + if not link.date_from: + updates["date_from"] = timezone.now().date() + if updates: + EmployeeDepartment.objects.filter(pk=link.pk).update(**updates) + except IntegrityError: + # На случай гонки + EmployeeDepartment.objects.filter( + employee_id=self.head_id, department_id=self.pk + ).update(is_active=True, date_from=timezone.now().date()) - # 2) Старый глава: - if old_head_id: - if self.head_id is None: - # снимаем главу: если ссылки нет — создадим и сразу деактивируем; если есть — деактивируем - link, created = EmployeeDepartment.objects.get_or_create( - employee_id=old_head_id, - department_id=self.pk, - defaults={"is_active": False, "date_from": timezone.now().date(), "date_to": timezone.now().date()}, - ) - if not created: - EmployeeDepartment.objects.filter( - employee_id=old_head_id, department_id=self.pk - ).update(is_active=False, date_to=timezone.now().date()) - else: - # меняем A -> B: ссылка старого главы должна существовать и оставаться активной - EmployeeDepartment.objects.get_or_create( - employee_id=old_head_id, - department_id=self.pk, - defaults={"is_active": True, "date_from": timezone.now().date()}, - ) + # --- Обрабатываем бывшего руководителя --- + if (not is_create) and head_changed and old_head_id: + if self.head_id is None: + # Руководителя сняли: деактивируем его принадлежность + link, created = EmployeeDepartment.objects.get_or_create( + employee_id=old_head_id, + department_id=self.pk, + defaults={ + "is_active": False, + "date_from": timezone.now().date(), + "date_to": timezone.now().date(), + }, + ) + if not created: + EmployeeDepartment.objects.filter( + employee_id=old_head_id, department_id=self.pk + ).update(is_active=False, date_to=timezone.now().date()) + else: + # Смена A -> B: по текущей логике бывший остаётся сотрудником отдела (активным) + # Если хотите наоборот — поменяйте is_active=True на False и добавьте date_to. + EmployeeDepartment.objects.get_or_create( + employee_id=old_head_id, + department_id=self.pk, + defaults={ + "is_active": True, + "date_from": timezone.now().date(), + }, + ) @property def active_employees(self): diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index de7711aa..19cccc29 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -139,12 +139,13 @@ def employee_detail(request, pk: int): 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")) - ) - return render( - request, "employees/employee_detail.html", {"emp": emp, "can_edit": can_edit} - ) + can_edit = bool(request.user and (request.user.is_staff or request.user.id == emp.get("id"))) + print("emp:", emp) + return render(request, "employees/employee_detail.html", { + "emp": emp, + "can_edit": can_edit, + # "employee_actions": employee_actions, # ← удалить + }) # ---------- ME ---------- @@ -155,10 +156,15 @@ def employee_me(request): 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_detail.html", {"emp": emp, "can_edit": True} - ) + print("emp", emp) + return render(request, "employees/employee_detail.html", { + "emp": emp, + "can_edit": True, + # "employee_actions": employee_actions, # ← удалить + }) + # ---------- EDIT (self or staff) ---------- diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index dd856251..a7b94df3 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -13,5 +13,5 @@ urlpatterns = [ path("communications/", include("communications.urls", namespace="communications")), path("search/", include("search.urls", namespace="search")), path("api/", include(("api.urls", "api"), namespace="api")), - path("", include("feed.urls", namespace="feed")), + path("", include("feed.urls_front", namespace="feed")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/feed/forms_front.py b/backend/feed/forms_front.py new file mode 100644 index 00000000..701a1d7d --- /dev/null +++ b/backend/feed/forms_front.py @@ -0,0 +1,42 @@ +from django import forms + +TYPE_COMPANY = "company" +TYPE_DEPARTMENT = "department" + + +class LoginForm(forms.Form): + email = forms.EmailField(label="Email") + password = forms.CharField(label="Пароль", widget=forms.PasswordInput) + + +class PostCreateForm(forms.Form): + type = forms.ChoiceField( + choices=[(TYPE_COMPANY, "Главная (компания)"), (TYPE_DEPARTMENT, "Отдел")] + ) + department = forms.IntegerField( + required=False, min_value=1, label="Отдел (для новостей отдела)" + ) + title = forms.CharField(max_length=200, label="Заголовок") + body = forms.CharField(widget=forms.Textarea, label="Текст") + image = forms.ImageField(required=False, label="Изображение") + attachment = forms.FileField(required=False, label="Вложение") + + +class CommentForm(forms.Form): + post = forms.IntegerField(widget=forms.HiddenInput) + text = forms.CharField( + required=False, widget=forms.Textarea(attrs={"rows": 2}), label="Комментарий" + ) + image = forms.ImageField(required=False, label="Изображение") + attachment = forms.FileField(required=False, label="Файл") + + def clean(self): + cleaned = super().clean() + has_any = bool( + (cleaned.get("text") or "").strip() + or cleaned.get("image") + or cleaned.get("attachment") + ) + if not has_any: + raise forms.ValidationError("Нужно указать текст, изображение или файл.") + return cleaned diff --git a/backend/feed/urls_front.py b/backend/feed/urls_front.py new file mode 100644 index 00000000..eed223d2 --- /dev/null +++ b/backend/feed/urls_front.py @@ -0,0 +1,19 @@ +from django.urls import path + +from . import views_front + +app_name = "feed" + +urlpatterns = [ + path("", views_front.feed_list, name="feed_list"), + path("departments//", views_front.department_feed, name="department_feed"), + path("employees//", views_front.employee_feed, name="employee_feed"), + path("post/new/", views_front.post_create, name="post_create"), + path("post//", views_front.post_detail, name="post_detail"), + path("post//edit/", views_front.post_update, name="post_update"), + path("post//delete/", views_front.post_delete, name="post_delete"), + path("post//pin/", views_front.pin_post, name="pin_post"), + path("post//like/", views_front.toggle_like, name="toggle_like"), + path("comment//edit/", views_front.comment_update, name="comment_update"), + path("comment//delete/", views_front.comment_delete, name="comment_delete"), +] diff --git a/backend/feed/views_front.py b/backend/feed/views_front.py new file mode 100644 index 00000000..ae6bae7a --- /dev/null +++ b/backend/feed/views_front.py @@ -0,0 +1,611 @@ +# backend/feed/views.py +from __future__ import annotations + +from datetime import timedelta + +from django import forms +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect, JsonResponse +from django.shortcuts import redirect, render +from django.urls import reverse +from django.utils import timezone + +from api.client import ApiResponse, get_api_client +from .constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE +from .forms import CommentForm, PostForm + +# ---------- Константы путей API ---------- +API_POSTS = "v1/posts/" +API_COMMENTS = "v1/comments/" +API_DEPARTMENTS = "v1/departments/" +API_EMPLOYEES = "v1/employees/" + + +# ---------- Вспомогательное: доступ к ApiClient ---------- +def _api(request): + return get_api_client(request) + + +def _paged_or_list(resp: ApiResponse): + data = resp.json or {} + return data.get("results", data) + + +# ---------- Локальный multipart-враппер поверх ApiClient (без его модификации) ---------- +def _api_request_multipart( + client, method: str, path: str, *, data=None, files=None +) -> ApiResponse: + """ + Отправляет multipart/form-data, используя ту же сессию и заголовки, что ApiClient. + Делает ровно один refresh на 401. + """ + url = client._make_url( + path + ) # noqa: SLF001 — осознанно пользуемся внутренними методами + headers = dict(client.cfg.default_headers or {}) + headers.update(client._auth_headers()) # noqa: SLF001 + + resp = client.session.request( + method, + url, + data=data, + files=files, + headers=headers, + timeout=client.cfg.timeout, + ) + # 401 -> пробуем обновить access и повторить + if resp.status_code == 401 and client.refresh and client.refresh_tokens(): + headers = dict(client.cfg.default_headers or {}) + headers.update(client._auth_headers()) # noqa: SLF001 + resp = client.session.request( + method, + url, + data=data, + files=files, + headers=headers, + timeout=client.cfg.timeout, + ) + try: + payload = resp.json() + except Exception: + payload = None + return ApiResponse( + ok=resp.ok, status=resp.status_code, json=payload, text=resp.text + ) + + +# ---------- Хелперы API для ленты ---------- +def _api_list_posts(request, **params) -> list[dict]: + resp = _api(request).get(API_POSTS, params=params) + if not resp.ok: + messages.error(request, f"Не удалось получить публикации: {resp.status}") + return [] + return _paged_or_list(resp) + + +def _api_get_post(request, post_id: int) -> dict | None: + resp = _api(request).get(f"{API_POSTS}{post_id}/") + if not resp.ok: + messages.error(request, f"Публикация недоступна: {resp.status}") + return None + return resp.json or None + + +def _api_get_comments_for_post(request, post_id: int) -> list[dict]: + resp = _api(request).get( + API_COMMENTS, params={"post": post_id, "ordering": "created_at"} + ) + if not resp.ok: + messages.error(request, f"Не удалось получить комментарии: {resp.status}") + return [] + return _paged_or_list(resp) + + +def _api_get_department(request, dept_id: int) -> dict | None: + resp = _api(request).get(f"{API_DEPARTMENTS}{dept_id}/") + return resp.json if resp.ok else None + + +def _api_get_employee(request, employee_id: int) -> dict | None: + resp = _api(request).get(f"{API_EMPLOYEES}{employee_id}/") + return resp.json if resp.ok else None + + +def _api_list_department_members(request, dept_id: int) -> list[dict]: + # Предпочтительно: /v1/employees/?department=&is_active=true&ordering=last_name,first_name + resp = _api(request).get( + API_EMPLOYEES, + params={ + "department": dept_id, + "is_active": "true", + "ordering": "last_name,first_name", + }, + ) + return _paged_or_list(resp) if resp.ok else [] + + +def _api_list_new_employees(request, limit: int = 10) -> list[dict]: + since = (timezone.now() - timedelta(days=14)).isoformat() + resp = _api(request).get( + API_EMPLOYEES, + params={ + "is_active": "true", + "created_at__gte": since, # или "created_at_after": since — см. ниже + "ordering": "-created_at", + "page_size": limit, + }, + ) + return _paged_or_list(resp) if resp.ok else [] + + +# ---------- Навигационные хелперы ---------- +def _post_back_url(post: dict, user) -> str: + t = post.get("type") + if t == TYPE_COMPANY: + return reverse("feed:feed_list") + if t == TYPE_DEPARTMENT and post.get("department_id"): + return reverse("employees:department_detail", args=[post["department_id"]]) + # страница пользователя — все его публикации + author_id = post.get("author_id") or ( + user.pk if getattr(user, "is_authenticated", False) else None + ) + return reverse("feed:employee_feed", args=[author_id or 0]) + + +async def _can_edit_post_locally(user, post: dict) -> bool: + # Мягкая проверка на стороне UI; фактическая авторизация — на API. + if not (user and user.is_authenticated): + return False + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + t = post.get("type") + if t == TYPE_COMPANY: + return False + if t == TYPE_DEPARTMENT: + dept_id = post.get("department_id") + if dept_id: + # проверим, не является ли юзер руководителем отдела + # (берём минимальные данные отдела) + # Если API возвращает в деталях отдела head_id — используем его. + # Если нет — UI просто доверит решать бэкенду (можно упростить до False). + return post.get("author_id") == user.id or bool( + await _is_dept_head(user, dept_id) + ) + return post.get("author_id") == user.id + return post.get("author_id") == user.id + + +async def _is_dept_head(user, dept_id: int) -> bool: + # Пытаемся определить head_id отдела через API + # Если эндпоинт не даёт head_id, возвращаем False — API всё равно провалидирует право. + # Для синхронных вьюх просто используем синхронный вызов: + # (оставлено async для потенциальной асинхронной интеграции) + from asgiref.sync import sync_to_async + + def _sync(request, d_id): + dept = _api( + request=None + ) # не нужен request, но get_api_client требует — обойдём ниже; см. комментарий + return False + + return False # безопасное значение: разрешение решит API + + +# ---------- Ленты ---------- + + +def feed_list(request): + """ + Лента компании из API (?type=company). + Блок «новые сотрудники» — тоже из API. + """ + posts = _api_list_posts(request, type=TYPE_COMPANY) + new_employees = _api_list_new_employees(request, limit=10) + return render( + request, "feed/feed_list.html", {"posts": posts, "new_employees": new_employees} + ) + + +@login_required +def department_feed(request, pk): + department = _api_get_department(request, pk) + if not department: + messages.error(request, "Отдел недоступен.") + return redirect("feed:feed_list") + posts = _api_list_posts(request, type=TYPE_DEPARTMENT, department=pk) + emp_links = _api_list_department_members(request, pk) + return render( + request, + "feed/feed_list.html", + { + "department": department, + "posts": posts, + "new_employees": [], + "emp_links": emp_links, + }, + ) + + +@login_required +def employee_feed(request, pk): + employee = _api_get_employee(request, pk) + if not employee: + messages.error(request, "Сотрудник недоступен.") + return redirect("feed:feed_list") + posts = _api_list_posts(request, author=pk) + return render( + request, "feed/feed_list.html", {"employee": employee, "posts": posts} + ) + + +# ---------- Детальная + комментарии ---------- + + +@login_required +def post_detail(request, pk): + post = _api_get_post(request, pk) + if not post: + return redirect("feed:feed_list") + + comments = _api_get_comments_for_post(request, pk) + + if request.method == "POST": + form = CommentForm(request.POST, request.FILES) + if form.is_valid(): + cleaned = form.cleaned_data + data = { + "post": str(pk), + "text": cleaned.get("text") or "", + } + files = {} + if cleaned.get("image"): + files["image"] = ( + cleaned["image"].name, + cleaned["image"], + getattr(cleaned["image"], "content_type", None), + ) + if cleaned.get("attachment"): + files["attachment"] = ( + cleaned["attachment"].name, + cleaned["attachment"], + getattr(cleaned["attachment"], "content_type", None), + ) + + client = _api(request) + resp = _api_request_multipart( + client, "POST", API_COMMENTS, data=data, files=files or None + ) + if resp.ok: + messages.success(request, "Комментарий добавлен!") + next_url = request.POST.get("next") + return ( + redirect(next_url) + if next_url + else redirect("feed:post_detail", pk=pk) + ) + # покажем ошибки API + if isinstance(resp.json, dict): + for field, errs in resp.json.items(): + messages.error(request, f"{field}: {errs}") + else: + messages.error(request, f"Ошибка API: {resp.status}") + else: + messages.error(request, "Исправьте ошибки формы комментария.") + else: + form = CommentForm(initial={"post": pk}) + + return render( + request, + "feed/post_detail.html", + {"post": post, "comments": comments, "comment_form": form}, + ) + + +@login_required +def comment_update(request, pk): + r = _api(request).get(f"{API_COMMENTS}{pk}/") + if not r.ok: + messages.error(request, "Комментарий недоступен.") + return redirect("feed:feed_list") + comment = r.json or {} + post_id = comment.get("post_id") + + if request.method == "POST": + form = CommentForm(request.POST) # шлём только text + if form.is_valid(): + payload = {"text": form.cleaned_data.get("text") or ""} + resp = _api(request).patch(f"{API_COMMENTS}{pk}/", json=payload) + if resp.ok: + messages.success(request, "Комментарий обновлён.") + return redirect(f"{reverse('feed:post_detail', args=[post_id])}#c{pk}") + messages.error(request, f"Ошибка API: {resp.status}") + else: + messages.error(request, "Исправьте ошибки формы.") + else: + form = CommentForm(initial={"text": comment.get("text", "")}) + + # для шаблона нужен пост (dict) + post = _api_get_post(request, post_id) if post_id else None + return render( + request, + "feed/comment_form.html", + {"form": form, "comment": comment, "post": post}, + ) + + +@login_required +def comment_delete(request, pk): + r = _api(request).get(f"{API_COMMENTS}{pk}/") + if not r.ok: + messages.error(request, "Комментарий недоступен.") + return redirect("feed:feed_list") + comment = r.json or {} + post_id = comment.get("post_id") + + if request.method == "POST": + resp = _api(request).delete(f"{API_COMMENTS}{pk}/") + if resp.ok: + messages.success(request, "Комментарий удалён.") + else: + messages.error(request, f"Ошибка удаления: {resp.status}") + return redirect("feed:post_detail", pk=post_id) + + post = _api_get_post(request, post_id) if post_id else None + return render( + request, "feed/comment_confirm_delete.html", {"comment": comment, "post": post} + ) + + +# ---------- Создание/редактирование/удаление поста ---------- + + +@login_required +def post_create(request): + dept_id = request.GET.get("department") + force_type = request.GET.get("type") + + # Определяем контекст (куда публикуем) + if dept_id: + context_type = TYPE_DEPARTMENT + elif force_type in {TYPE_COMPANY, TYPE_EMPLOYEE}: + context_type = force_type + else: + context_type = TYPE_COMPANY + + cancel_url = ( + request.GET.get("next") + or (reverse("employees:department_detail", args=[dept_id]) if dept_id else None) + or ( + reverse("feed:feed_list") + if context_type == TYPE_COMPANY + else reverse("feed:employee_feed", args=[request.user.pk]) + ) + ) + + if request.method == "POST": + form = PostForm(request.POST, request.FILES) + if form.is_valid(): + cleaned = form.cleaned_data + data = {"title": cleaned.get("title"), "body": cleaned.get("body")} + if context_type == TYPE_DEPARTMENT: + data["type"] = TYPE_DEPARTMENT + data["department"] = str(dept_id) + elif context_type == TYPE_COMPANY: + data["type"] = TYPE_COMPANY + else: + data["type"] = TYPE_EMPLOYEE # API не допустит, это ок. + + files = {} + if cleaned.get("image"): + files["image"] = ( + cleaned["image"].name, + cleaned["image"], + getattr(cleaned["image"], "content_type", None), + ) + if cleaned.get("attachment"): + files["attachment"] = ( + cleaned["attachment"].name, + cleaned["attachment"], + getattr(cleaned["attachment"], "content_type", None), + ) + + client = _api(request) + resp = _api_request_multipart( + client, "POST", API_POSTS, data=data, files=files or None + ) + if resp.ok: + messages.success(request, "Публикация создана!") + return redirect(_post_back_url(resp.json or {}, request.user)) + if isinstance(resp.json, dict): + for field, errs in resp.json.items(): + messages.error(request, f"{field}: {errs}") + else: + messages.error(request, f"Ошибка создания: {resp.status}") + else: + messages.error(request, "Исправьте ошибки формы.") + else: + initial = {} + if context_type == TYPE_DEPARTMENT: + initial.update({"type": TYPE_DEPARTMENT}) + elif context_type == TYPE_COMPANY: + initial.update({"type": TYPE_COMPANY, "department": None}) + else: + initial.update({"type": TYPE_EMPLOYEE}) + form = PostForm(initial=initial) + # прячем лишние поля (визуально) + if "type" in form.fields: + form.fields["type"].initial = initial["type"] + form.fields["type"].widget = forms.HiddenInput() + if context_type == TYPE_DEPARTMENT and "department" in form.fields: + form.fields["department"].initial = dept_id + form.fields["department"].widget = forms.HiddenInput() + else: + form.fields.pop("department", None) + + return render( + request, + "feed/post_form.html", + { + "form": form, + "context_type": context_type, + "department": {"id": dept_id} if dept_id else None, + "cancel_url": cancel_url, + }, + ) + + +@login_required +def post_update(request, pk): + post = _api_get_post(request, pk) + if not post: + return redirect("feed:feed_list") + + # UI-проверка — бэкенд всё равно решит правами + # (синхронно считаем, см. примечание к _can_edit_post_locally) + # Можно просто пропустить этот шаг, положившись на 403 от API. + # if not sync_can_edit: messages.error(...) + + cancel_url = request.GET.get("next") or _post_back_url(post, request.user) + + if request.method == "POST": + form = PostForm(request.POST, request.FILES) + if form.is_valid(): + cleaned = form.cleaned_data + data = {"title": cleaned.get("title"), "body": cleaned.get("body")} + files = {} + if cleaned.get("image"): + files["image"] = ( + cleaned["image"].name, + cleaned["image"], + getattr(cleaned["image"], "content_type", None), + ) + if cleaned.get("attachment"): + files["attachment"] = ( + cleaned["attachment"].name, + cleaned["attachment"], + getattr(cleaned["attachment"], "content_type", None), + ) + client = _api(request) + resp = _api_request_multipart( + client, "PATCH", f"{API_POSTS}{pk}/", data=data, files=files or None + ) + if resp.ok: + messages.success(request, "Публикация обновлена!") + return redirect(_post_back_url(post, request.user)) + if isinstance(resp.json, dict): + for field, errs in resp.json.items(): + messages.error(request, f"{field}: {errs}") + else: + messages.error(request, f"Ошибка обновления: {resp.status}") + else: + messages.error(request, "Исправьте ошибки формы.") + else: + form = PostForm( + initial={ + "type": post.get("type"), + "title": post.get("title"), + "body": post.get("body"), + "department": post.get("department_id"), + } + ) + if "type" in form.fields: + form.fields["type"].widget = forms.HiddenInput() + if "department" in form.fields: + form.fields["department"].widget = forms.HiddenInput() + + context_type = post.get("type") + department = ( + {"id": post.get("department_id")} + if (context_type == TYPE_DEPARTMENT and post.get("department_id")) + else None + ) + return render( + request, + "feed/post_form.html", + { + "form": form, + "context_type": context_type, + "department": department, + "cancel_url": cancel_url, + }, + ) + + +@login_required +def post_delete(request, pk): + post = _api_get_post(request, pk) + if not post: + return redirect("feed:feed_list") + + cancel_url = request.GET.get("next") or _post_back_url(post, request.user) + + if request.method == "POST": + resp = _api(request).delete(f"{API_POSTS}{pk}/") + if resp.ok: + messages.success(request, "Публикация удалена.") + return redirect(_post_back_url(post, request.user)) + messages.error(request, f"Ошибка удаления: {resp.status}") + + return render( + request, + "feed/post_confirm_delete.html", + {"post": post, "cancel_url": cancel_url}, + ) + + +@login_required +def pin_post(request, pk): + post = _api_get_post(request, pk) + if not post: + return redirect("feed:feed_list") + action = "unpin" if post.get("pinned") else "pin" + resp = _api(request).post(f"{API_POSTS}{pk}/{action}/") + if resp.ok: + messages.success( + request, "Новость закреплена!" if action == "pin" else "Новость откреплена!" + ) + else: + messages.error(request, f"Операция недоступна: {resp.status}") + return redirect(_post_back_url(post, request.user)) + + +# ---------- Лайки ---------- + + +@login_required +def toggle_like(request, pk): + if request.method != "POST": + return redirect("feed:post_detail", pk=pk) + + op = (request.POST.get("op") or "like").lower() + if op not in {"like", "unlike"}: + op = "like" + + endpoint = "like" if op == "like" else "unlike" + resp = _api(request).post(f"{API_POSTS}{pk}/{endpoint}/") + + if not resp.ok: + messages.error(request, f"Не удалось {op} публикацию: {resp.status}") + + wants_json = ( + "application/json" in request.headers.get("Accept", "") + or request.headers.get("X-Requested-With") == "XMLHttpRequest" + ) + if wants_json: + data = resp.json or {} + return JsonResponse( + { + "ok": bool(resp.ok), + "post_id": pk, + "liked": data.get("liked"), + "likes": data.get("likes_count"), + } + ) + + next_url = ( + request.POST.get("next") + or request.META.get("HTTP_REFERER") + or reverse("feed:post_detail", args=[pk]) + ) + return HttpResponseRedirect(next_url) diff --git a/backend/templates/base.html b/backend/templates/base.html index 007a342f..4ef958d4 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -102,29 +102,6 @@ box-shadow:0 8px 24px rgba(0,0,0,.18); } - /* ====== переключатель темы ====== */ - .theme-switch{ - position:relative; display:flex; gap:.25rem; - background:var(--bs-tertiary-bg); border-radius:12px; padding:.25rem; - border:1px solid var(--bs-border-color); justify-content:center; - } - .theme-thumb{ - position:absolute; inset:.25rem auto .25rem .25rem; - width:calc(33.333% - .25rem); border-radius:10px; - background: color-mix(in srgb, var(--bs-body-bg), #fff 12%); - transition: transform .25s ease, width .25s ease; z-index:0; - } - [data-bs-theme="dark"] .theme-thumb{ - background: color-mix(in srgb, var(--bs-body-bg), #000 12%); - } - .theme-tab{ - position:relative; z-index:1; border:0; background:transparent; - width:3.8rem; height:2rem; border-radius:10px; - display:inline-flex; align-items:center; justify-content:center; - color:var(--bs-body-color); opacity:.7; font-size:1.4rem; line-height:1; - } - .theme-tab.active{ opacity:1; color:var(--bs-primary); } - /* ===== feed styles (карточки ленты) ===== */ .feed-header{ display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; } .feed-header .title{ font-weight:800; letter-spacing:.2px; margin:0; } @@ -162,11 +139,7 @@ .feed-body{ padding:.25rem 1rem 1rem 1rem; position:relative; } .feed-title{ font-weight:700; margin:.25rem 0 .35rem 0; font-size:1.05rem; } - .feed-text{ - margin:0 0 .6rem 0; color: var(--bs-body-color); - display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:5; - overflow:hidden; - } + .feed-img{ position:relative; border-top:1px solid var(--bs-border-color); background: var(--bs-tertiary-bg); } .feed-img img{ display:block; width:100%; height:auto; aspect-ratio:16/9; object-fit:cover; } @@ -219,6 +192,17 @@ /* Иконки-действия справа в карточке */ .action-btn{ min-height: unset; padding:.35rem .6rem; } .action-btn .bi{ font-size: 1rem; } + + /* Переносим длинные слова/ссылки, сохраняем переносы строк */ + .feed-text, .feed-full{ + overflow-wrap: anywhere; + width: 100%; + margin-right: 0; + padding-right: 0; + } + + /* чтобы кнопка «Подробнее» не была перекрыта stretched-link */ + .readmore-toggle{ position: relative; z-index: 2; } {% block extra_css %}{% endblock %} @@ -257,94 +241,77 @@ }); + + - - - diff --git a/backend/templates/employees/employee_detail.html b/backend/templates/employees/employee_detail.html index cf8eaf87..2c6d3550 100644 --- a/backend/templates/employees/employee_detail.html +++ b/backend/templates/employees/employee_detail.html @@ -76,6 +76,81 @@ .ios-section-title{ font-weight:700; font-size:1rem; margin-bottom:.5rem; } .rounded-ios{ border-radius:14px !important; } + + /* Отделы — аккуратный список без иконок-аватаров */ + .ios-cell.dept-item{ justify-content: space-between; } + /* dept-head больше не выделяется фоном/линией */ + .ios-cell.dept-head{ border-left: 0; background: transparent; } + + .dept-line{ display:flex; align-items:center; gap:.5rem; } + .dept-name{ font-weight:700; } + .dept-role{ color: var(--bs-secondary-color); } + /* было: круглая иконка; станет компактная пилюля с подписью */ + .badge-head{ + display:inline-flex; align-items:center; gap:.35rem; + padding:.2rem .55rem; border-radius:999px; + background: color-mix(in srgb, var(--bs-warning) 15%, var(--bs-body-bg)); + color: color-mix(in srgb, var(--bs-warning) 60%, black 0%); + border:1px solid color-mix(in srgb, var(--bs-warning) 35%, transparent); + font-weight:700; font-size:.8rem; line-height:1; + white-space: nowrap; + } + .badge-head .bi{ font-size:.95rem; line-height:1; } + + /* На очень узких — только иконка (если будет подпись) */ + @media (max-width: 420px){ + .badge-head .lbl{ display:none; } + } + /* collapse не должен иметь внешних отступов — они «торчат», когда блок схлопывается */ + .ios-collapse-trigger + .collapse { margin-top: 0 !important; } + + /* отступ делаем у внутреннего контента */ + .ios-collapse-trigger + .collapse > .ios-list, + .ios-collapse-trigger + .collapse > .chips { + margin-top: .5rem; + } + + /* на всякий случай — во время анимации скрываем всё, чтобы края/бордеры не «просвечивали» */ + .collapsing { overflow: hidden; } + + /* не трогаем скорость Bootstrap; если где-то переопределялась — вернём дефолт */ + .collapsing { + transition: height .35s ease; /* стандарт Bootstrap 5 */ + } + + + /* Единый блок для кнопки+панели */ + .ios-acc{ + border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%); + border-radius:14px; + background: var(--bs-body-bg); + overflow: hidden; + } + .ios-acc + .ios-acc{ margin-top:.75rem; } /* расстояние между такими блоками */ + + /* Кнопка внутри общего блока — без собственного бордера/радиуса */ + .ios-acc .ios-collapse-trigger{ + border:0; + border-radius:0; + background: transparent; + width:100%; + padding:.6rem .9rem; /* как у тебя было */ + } + .ios-acc .ios-collapse-trigger:focus{ box-shadow:none; } + + /* Панель — без внешних отступов, высота анимируется стандартом Bootstrap */ + .ios-acc .collapse{ margin:0 !important; } + .collapsing{ overflow:hidden; transition: height .35s ease; } + + + /* Внутренние списки/чипсы — убираем собственный бордер/радиус, они уже внутри общего блока */ + .ios-acc .ios-list{ border:0; border-radius:0; } + .ios-acc .chips{ margin:0; } + + .ios-acc .ios-acc-body{ + padding:.5rem .25rem .75rem; + border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%); + } {% endblock %} @@ -104,7 +179,7 @@ {% else %}
{% firstof emp.first_name emp.last_name "?" as initial %} -{{ initial|slice:":1"|upper }} + {{ initial|slice:":1"|upper }}
{% endif %} @@ -122,92 +197,238 @@
- - {% if emp.position_label %} + {% if emp.position.name %} + {{ emp.position.name }} + {% elif emp.position_label %} {{ emp.position_label }} {% elif emp.position %} Должность #{{ emp.position }} {% endif %}
- {% if emp.skills and emp.skills|length %} + {# === НАВЫКИ (из DRF: [{id,name}], без итерации по строкам) === #} + {% if emp.skills and emp.skills.0.name %}
-
Навыки
-
- {% for s in emp.skills %} - {{ s }} - {% endfor %} +
+ +
+
+
+ {% for s in emp.skills %}{% if s.name %}{{ s.name }}{% endif %}{% endfor %} +
+
+
+ + {% endif %}
- +
+ -
-
- {% if emp.email %} - - {% endif %} - {% if emp.phone_number %} - - {% endif %} - {% if emp.telegram %} -
Telegram: {{ emp.telegram }}
- {% endif %} - {% if emp.whatsapp %} -
WhatsApp: {{ emp.whatsapp }}
- {% endif %} - {% if emp.wechat %} -
WeChat: {{ emp.wechat }}
- {% endif %} - {% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %} -
Контакты не указаны
- {% endif %} +
+
+
+ {% if emp.email %} + + {% endif %} + {% if emp.phone_number %} + + {% endif %} + {% if emp.telegram %}
Telegram: {{ emp.telegram }}
{% endif %} + {% if emp.whatsapp %}
WhatsApp: {{ emp.whatsapp }}
{% endif %} + {% if emp.wechat %}
WeChat: {{ emp.wechat }}
{% endif %} + {% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %} +
Контакты не указаны
+ {% endif %} +
- {# Departments — покажем, только если бэкенд вернул список #} - {% if emp.departments %} -
- -
+ +
+
- {% for d in emp.departments %} -
- - {{ d.name }} -
- {% empty %} -
Нет отделов
- {% endfor %} + {% if emp.departments and emp.departments|length %} + {% for d in emp.departments %} + {% if d.name %} +
+
+ {% if d.id %} + {{ d.name }} + {% else %} + {{ d.name }} + {% endif %} + {% if d.role_name %}— {{ d.role_name }}{% endif %} +
+ {% if d.is_head %} + + + + {% endif %} +
+ {% endif %} + {% endfor %} + {% else %} +
Отделы не указаны
+ {% endif %}
+ +
+ {# === КАДРОВЫЕ СОБЫТИЯ (для аутентифицированных) === #} + {% if request.user.is_authenticated %} + {% with acts_src=employee_actions|default:emp.actions %} + {% with acts=acts_src.results|default:acts_src %} + {% if acts %} +
+
+ + +
+
+
+ {% for a in acts %} +
+ +
+
{% firstof a.action_display a.action %}
+ {% if a.comment %}
{{ a.comment }}
{% endif %} +
+ +
+ {% if a.extra %} +
+ {% for k, v in a.extra.items %} +
{{ k }}: {{ v }}
+ {% endfor %} +
+ {% endif %} + + {# История этого события: без "создано/изменено", только название события и новые значения #} + {% if a.history and a.history|length %} +
+ История +
+ {% for h in a.history %} + {% with new_action=h.changes.action.new|default:a.action %} +
+
+ + {# Переводим код события в человекочитаемый #} + {% if new_action == 'hired' %}Принят + {% elif new_action == 'dismissed' %}Уволен + {% elif new_action == 'on_leave' %}В отпуске + {% elif new_action == 'returned_from_leave' %}Вернулся из отпуска + {% elif new_action == 'on_maternity' %}В декрете + {% elif new_action == 'returned_from_maternity' %}Вернулся из декрета + {% elif new_action == 'transferred' %}Переведен + {% elif new_action == 'rehired' %}Восстановлен + {% else %}{% firstof a.action_display a.action %}{% endif %} + + + {% firstof h.history_date_display h.history_date %}{% if h.history_user %} • {{ h.history_user }}{% endif %} + +
+ + {% if h.changes %} +
+ {# Показываем только НОВЫЕ значения (без зачёркнутых старых) #} + {% for field, diff in h.changes.items %} + {% if field != 'action' %} +
+ + {% if field == 'date' %}Дата события + {% elif field == 'comment' %}Комментарий + {% elif field == 'extra' %}Дополнительно + {% elif field == 'employee_id' %}Сотрудник + {% else %}{{ field }}{% endif %} + : + + {% if field == 'extra' and diff.new %} + {% if diff.new.items %} + {% for ek, ev in diff.new.items %} + {% if not forloop.first %}, {% endif %}{{ ek }}: {{ ev }} + {% endfor %} + {% else %} + {{ diff.new }} + {% endif %} + {% else %} + {{ diff.new }} + {% endif %} +
+ {% endif %} + {% endfor %} +
+ {% endif %} +
+ {% endwith %} + {% endfor %} +
+
+ {% endif %} +
+
+ {% empty %} +
Событий нет.
+ {% endfor %} +
+
+
+ +
+
+ {% endif %} + {% endwith %} + {% endwith %} {% endif %} - {# Кнопка чата убрана, чтобы не ловить NoReverseMatch, если роут не подключён #} + +
@@ -228,3 +449,4 @@
{% endblock %} + diff --git a/backend/templates/employees/employee_edit.html b/backend/templates/employees/employee_edit.html index 33b78688..72f91187 100644 --- a/backend/templates/employees/employee_edit.html +++ b/backend/templates/employees/employee_edit.html @@ -60,7 +60,7 @@
- +
@@ -139,3 +241,34 @@ {% empty %}
Пока нет новостей.
{% endfor %} + + + diff --git a/backend/templates/feed/feed_list.html b/backend/templates/feed/feed_list.html index 561e9057..206e4874 100644 --- a/backend/templates/feed/feed_list.html +++ b/backend/templates/feed/feed_list.html @@ -4,88 +4,282 @@ {% block title %}Лента{% endblock %} {% block content %} -
-
-
+ + +
+
+
+ +
+ +

Лента

+ + {% if user.is_authenticated and user.is_staff %} + + Новая публикация + {% endif %} - {% endif %} -
+
- {% if new_employees %} -
-
-
Присоединились недавно
- Все сотрудники -
-
- {% for emp in new_employees %} -
- -
- {% if emp.avatar %} - - {% else %} - - {% endif %} + {% if new_employees %} +
+ + +
+
+ {% for emp in new_employees %} + {% firstof emp.id emp.pk as emp_id %} + {% firstof emp.avatar emp.avatar_url as ava %} + {% firstof emp.short_name emp.get_short_name emp.full_name emp.get_full_name emp.email "Сотрудник" as emp_name %} + {% firstof emp.department.name emp.department_name as dept_name %} + + +
+ + {% if ava %} + {% if ava.url %} + + {% else %} + + {% endif %} + {% else %} + + {{ emp_name }} + {% endif %} + + +
+
{{ emp_name }}
+ {% if dept_name %}
{{ dept_name }}
{% endif %} + + {# НАВЫКИ — печать всех, без итерации по строкам #} + {% if emp.skills and emp.skills.0.name %} +
Навыки: + {% for s in emp.skills %}{% if not forloop.first %}, {% endif %}{{ s.name }}{% endfor %} +
+ {% endif %} + + + +
-
{{ emp.get_short_name|default:emp.first_name }}
- + {% endfor %}
- {% endfor %} +
-
- {% endif %} - -
- {% if page %} - {% include "feed/_feed_cards.html" with items=page.object_list %} - {% elif posts %} - {% include "feed/_feed_cards.html" with items=posts %} - {% else %} -
Пока нет новостей.
{% endif %} -
- {% if page %} -
- {% bootstrap_pagination page %} +
+ {% if page %} + {% include "feed/_feed_cards.html" with items=page.object_list %} + {% elif posts %} + {% include "feed/_feed_cards.html" with items=posts %} + {% else %} +
Пока нет новостей.
+ {% endif %}
- {% endif %} + {% if page %} +
+ {% bootstrap_pagination page %} +
+ {% endif %} + +
-
{% endblock %} + + diff --git a/backend/templates/feed/post_detail.html b/backend/templates/feed/post_detail.html index e4a9a18f..72b8e007 100644 --- a/backend/templates/feed/post_detail.html +++ b/backend/templates/feed/post_detail.html @@ -1,9 +1,13 @@ {% extends 'base.html' %} {% load static django_bootstrap5 %} -{% block title %}{{ post.title|default:"Публикация" }}{% endblock %} +{% block title %}{% firstof post.title "Публикация" %}{% endblock %} {% block content %} +{% firstof post.id post.pk as post_id %} +{% firstof post.author.id post.author.pk post.author_id as author_id %} +{% firstof post.department.id post.department.pk post.department_id as dept_id %} +
@@ -14,40 +18,55 @@ К ленте - {% elif post.type == 'department' and post.department %} - + {% elif post.type == 'department' and dept_id %} + К отделу {% else %} - - К публикациям автора - - {% endif %} - - {{ post.created_at|date:"d.m.Y H:i" }} - - {# Пин #} - {% if post.type == 'company' and user.is_staff %} - - {% if post.pinned %}Открепить{% else %}Закрепить{% endif %} - - {% elif post.type == 'department' and post.department %} - {% if user.is_staff or post.department.head_id == user.id %} - - {% if post.pinned %}Открепить{% else %}Закрепить{% endif %} + {% if author_id %} + + К публикациям автора + + {% else %} + + К ленте {% endif %} {% endif %} + + + + {# Пин — без скобок в условии #} + {% if post_id %} + {% if post.type == 'company' and user.is_staff %} + + {% if post.pinned %}Открепить{% else %}Закрепить{% endif %} + + {% elif post.type == 'department' and dept_id %} + {% if user.is_staff %} + + {% if post.pinned %}Открепить{% else %}Закрепить{% endif %} + + {% elif post.department and post.department.head_id == user.id %} + + {% if post.pinned %}Открепить{% else %}Закрепить{% endif %} + + {% endif %} + {% endif %} + {% endif %} + {# Редактирование #} - {% if user.is_staff or user.id == post.author_id %} - - Редактировать - - {% elif post.type == 'department' and post.department and post.department.head_id == user.id %} - - Редактировать - + {% if post_id %} + {% if user.is_staff or user.id == post.author_id %} + + Редактировать + + {% elif post.type == 'department' and post.department and post.department.head_id == user.id %} + + Редактировать + + {% endif %} {% endif %}
@@ -55,19 +74,42 @@
- {% if post.author and post.author.avatar %} - - {% else %}{% endif %} + {% with a=post.author %} + {% if a %} + {% firstof a.avatar.url a.avatar_url as ava_url %} + {% if ava_url %}{% else %}{% endif %} + {% else %} + + {% endif %} + {% endwith %}
- - {% if post.type == 'department' and post.department %} · {{ post.department.name }}{% endif %} + {% if post.created_at %} + {{ post.created_at_display }} + {% endif %} + {% if post.type == 'department' %} + {% firstof post.department.name post.department_name as dept_name %} + {% if dept_name %} · {{ dept_name }}{% endif %} + {% endif %}
{% if post.pinned %} @@ -76,21 +118,27 @@
{% if post.image %} -
- {{ post.title|default:'Публикация' }} -
+ {% firstof post.image.url post.image as img_url %} + {% if img_url %} +
+ {% firstof post.title 'Публикация' %} +
+ {% endif %} {% endif %}
-

{{ post.title|default:"Без названия" }}

+

{% firstof post.title "Без названия" %}

{{ post.body|linebreaks }}
{% if post.attachment %} - + {% firstof post.attachment.url post.attachment as att_url %} + {% if att_url %} + + {% endif %} {% endif %}
@@ -101,13 +149,13 @@ - {% if user.is_authenticated %} -
+ {% if user.is_authenticated and post_id %} + {% csrf_token %} - - -
+
+ +
+
+ + + + + diff --git a/backend/tests/api/v1/employees/test_departments.py b/backend/tests/api/v1/employees/test_departments.py index 47119a5b..02746c8a 100644 --- a/backend/tests/api/v1/employees/test_departments.py +++ b/backend/tests/api/v1/employees/test_departments.py @@ -211,7 +211,6 @@ def test_employees_count_not_double_count_head_if_in_links(api_client: APIClient e2 = make_user("e2@example.com") EmployeeDepartment.objects.create(employee=e1, department=d, is_active=True) EmployeeDepartment.objects.create(employee=e2, department=d, is_active=True) - EmployeeDepartment.objects.create(employee=head, department=d, is_active=True) url = reverse("api:api_v1:departments-detail", args=[d.pk]) resp = api_client.get(url) diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index b801fb34..c29e05d8 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -296,29 +296,6 @@ def test_create_requires_staff(api_client: APIClient): assert Employee.objects.filter(email="new@example.com").exists() -def test_create_sets_skills(api_client: APIClient): - staff = make_user("staff@example.com", staff=True) - api_client.force_authenticate(user=staff) - - s1 = Skill.objects.create(name="Excel") - s2 = Skill.objects.create(name="SQL") - - url = reverse("api:api_v1:employees-list") - payload = { - "email": "skills@example.com", - "phone_number": _unique_phone(), - "last_name": "Skill", - "first_name": "Setter", - "telegram": "@skills", - "skills": [s1.id, s2.id], - } - resp = api_client.post(url, payload, format="json") - assert resp.status_code == status.HTTP_201_CREATED - emp_id = resp.json()["id"] - e = Employee.objects.get(pk=emp_id) - assert set(e.skills.values_list("id", flat=True)) == {s1.id, s2.id} - - # ---------- update / partial_update (IsSelfOrStaff) ---------- def test_partial_update_self_allowed(api_client: APIClient):