Ключевые изменения

- **EmployeeActionSerializer**
  - Добавлен `history[]` со снапшотами simple-history и diff по полям: `action`, `date`, `comment`, `extra`, `employee_id`.
  - Локализация названий событий через `employees.constants.ACTION_CHOICES`.
  - Поля `action_display`, `date_display` для удобного фронтового рендера.

- **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`) в `<details>`.
    В истории показываем только название события и новые значения полей (без «создано/изменено» и без зачёркнутых старых значений).
    Перевод кодов событий (`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=<id>&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
This commit is contained in:
admin i 2025-08-27 17:58:46 +03:00
parent 2fd3b1e562
commit c78d1e5fea
23 changed files with 2236 additions and 595 deletions

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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=<id>, ?author=<id>; сортировка created_at ASC
- GET: аутентифицированные
- POST: аутентифицированные (author = request.user)
- PATCH/PUT/DELETE: автор или staff/superuser
Фильтры: ?post=<id>, ?author=<id>
Сортировка: created_at ASC
"""
queryset = Comment.objects.select_related("post", "author").all()
serializer_class = CommentSerializer
permission_classes = [IsAuthenticated, IsAuthorOrStaffForComments]

View File

@ -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"

View File

@ -1,3 +1,4 @@
# backend\employees\constants.py
GENDER_CHOICES = (
(1, 'Мужской'),
(2, 'Женский'),

View File

@ -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):

View File

@ -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) ----------

View File

@ -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)

View File

@ -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

View File

@ -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/<int:pk>/", views_front.department_feed, name="department_feed"),
path("employees/<int:pk>/", views_front.employee_feed, name="employee_feed"),
path("post/new/", views_front.post_create, name="post_create"),
path("post/<int:pk>/", views_front.post_detail, name="post_detail"),
path("post/<int:pk>/edit/", views_front.post_update, name="post_update"),
path("post/<int:pk>/delete/", views_front.post_delete, name="post_delete"),
path("post/<int:pk>/pin/", views_front.pin_post, name="pin_post"),
path("post/<int:pk>/like/", views_front.toggle_like, name="toggle_like"),
path("comment/<int:pk>/edit/", views_front.comment_update, name="comment_update"),
path("comment/<int:pk>/delete/", views_front.comment_delete, name="comment_delete"),
]

611
backend/feed/views_front.py Normal file
View File

@ -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=<id>&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)

View File

@ -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; }
</style>
{% block extra_css %}{% endblock %}
@ -257,94 +241,77 @@
});
</script>
<script>
// ===== Переключатель темы =====
(function(){
const media = window.matchMedia('(prefers-color-scheme: dark)');
const root = document.documentElement;
function effective(mode){ return mode === 'auto' ? (media.matches ? 'dark' : 'light') : mode; }
function setTheme(mode, persist=true){
root.setAttribute('data-bs-theme', effective(mode));
if (persist) localStorage.setItem('theme', mode);
syncUI(mode);
}
function syncUI(mode){
document.querySelectorAll('[data-theme-value]').forEach(el=>{
const val = el.getAttribute('data-theme-value');
const is = val === mode;
if (el.classList.contains('theme-tab')) el.classList.toggle('active', is);
const input = document.getElementById(el.getAttribute('for'));
if (input && input.classList.contains('btn-check')) input.checked = is;
});
const wrap = document.getElementById('themeSwitch');
const thumb = wrap ? wrap.querySelector('.theme-thumb') : null;
if (wrap && thumb){
const active = wrap.querySelector('.theme-tab.active') || wrap.querySelector('[data-theme-value="'+mode+'"]');
const wrapRect = wrap.getBoundingClientRect();
const btnRect = active ? active.getBoundingClientRect() : null;
if (btnRect){
const offsetX = btnRect.left - wrapRect.left;
thumb.style.width = btnRect.width + 'px';
thumb.style.transform = 'translateX(' + offsetX + 'px)';
}
}
}
document.addEventListener('DOMContentLoaded', ()=>{
setTheme(localStorage.getItem('theme') || 'auto', false);
document.body.addEventListener('click', (e)=>{
const trg = e.target.closest('[data-theme-value]'); if (!trg) return;
e.preventDefault(); setTheme(trg.getAttribute('data-theme-value'));
});
media.addEventListener('change', ()=>{
if ((localStorage.getItem('theme') || 'auto') === 'auto') setTheme('auto', false);
});
addEventListener('resize', ()=> syncUI(localStorage.getItem('theme') || 'auto'));
});
})();
</script>
<!-- Глобальный обработчик лайков -->
<script>
// Глобальный обработчик лайков (использует стили/классы базового шаблона)
(function(){
function getCookie(name){
const m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return m ? m.pop() : '';
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-like-post]');
if (!btn) return;
e.preventDefault();
const postId = btn.getAttribute('data-like-post');
const url = btn.getAttribute('data-like-url');
if (!url || !postId || btn.disabled) return;
const url = btn.getAttribute('data-like-url');
if (!url || btn.disabled) return;
// текущее состояние -> какая операция нужна
const currentlyLiked =
btn.classList.contains('is-liked') ||
btn.getAttribute('aria-pressed') === 'true';
const op = currentlyLiked ? 'unlike' : 'like';
btn.disabled = true;
try{
const resp = await fetch(url, {
method: 'POST',
headers: { 'X-CSRFToken': getCookie('csrftoken'), 'Accept': 'application/json' },
body: new URLSearchParams({ next: location.href })
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({ op })
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
const card = btn.closest('[data-post-card]');
const cnt = card && card.querySelector('[data-like-count]');
if (cnt) cnt.textContent = data.likes ?? 0;
const data = await resp.json().catch(() => ({}));
// совместимость с разными ключами
const liked = (data.liked !== undefined) ? !!data.liked
: (data.is_liked !== undefined) ? !!data.is_liked
: !currentlyLiked; // если что-то пошло не так — просто инвертируем
btn.classList.toggle('is-liked', !!data.is_liked);
btn.setAttribute('aria-pressed', data.is_liked ? 'true' : 'false');
const count = (data.likes !== undefined) ? data.likes
: (data.likes_count !== undefined) ? data.likes_count
: null;
// обновляем кнопку/иконку/aria
btn.classList.toggle('is-liked', liked);
btn.setAttribute('aria-pressed', liked ? 'true' : 'false');
const icon = btn.querySelector('.bi');
if (icon){
icon.classList.toggle('bi-heart', !data.is_liked);
icon.classList.toggle('bi-heart-fill', !!data.is_liked);
icon.classList.toggle('bi-heart', !liked);
icon.classList.toggle('bi-heart-fill', liked);
}
}catch(err){ console.error(err); }
finally{ btn.disabled = false; }
// счётчик (в пределах карточки)
const card = btn.closest('[data-post-card]');
const cnt = card && card.querySelector('[data-like-count]');
if (cnt && count !== null) cnt.textContent = String(count);
// если есть скрытый input name="op", поддержим и серверный постбэк
const opInput = btn.closest('form')?.querySelector('input[name="op"]');
if (opInput) opInput.value = liked ? 'unlike' : 'like';
}catch(err){
console.error(err);
}finally{
btn.disabled = false;
}
}, { passive:false });
})();
</script>

View File

@ -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%);
}
</style>
{% endblock %}
@ -104,7 +179,7 @@
{% else %}
<div class="ios-ava--ph">
{% firstof emp.first_name emp.last_name "?" as initial %}
{{ initial|slice:":1"|upper }}
{{ initial|slice:":1"|upper }}
</div>
{% endif %}
</div>
@ -122,92 +197,238 @@
</div>
<div class="ios-role">
{% if emp.position_label %}
{% if emp.position.name %}
{{ emp.position.name }}
{% elif emp.position_label %}
{{ emp.position_label }}
{% elif emp.position %}
Должность #{{ emp.position }}
{% endif %}
</div>
{% if emp.skills and emp.skills|length %}
{# === НАВЫКИ (из DRF: [{id,name}], без итерации по строкам) === #}
{% if emp.skills and emp.skills.0.name %}
<div class="mt-3 text-start">
<div class="ios-section-title mb-2"><i class="bi-award me-1"></i>Навыки</div>
<div class="chips justify-content-center">
{% for s in emp.skills %}
<span class="chip">{{ s }}</span>
{% endfor %}
<div class="ios-acc">
<button class="ios-collapse-trigger"
type="button"
data-bs-toggle="collapse"
data-bs-target="#skillsCollapse"
aria-expanded="false"
aria-controls="skillsCollapse">
<span class="title"><i class="bi-stars me-1"></i>Навыки</span>
<i class="bi-chevron-down ios-collapse-caret"></i>
</button>
<div id="skillsCollapse" class="collapse">
<div class="ios-acc-body">
<div class="chips justify-content-center">
{% for s in emp.skills %}{% if s.name %}<span class="chip">{{ s.name }}</span>{% endif %}{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Contacts -->
<div class="mt-4 text-start">
<button class="ios-collapse-trigger"
type="button"
data-bs-toggle="collapse"
data-bs-target="#contactsCollapse"
aria-expanded="false"
aria-controls="contactsCollapse">
<span class="title"><i class="bi-person-lines-fill me-1"></i>Контакты</span>
<i class="bi-chevron-down ios-collapse-caret"></i>
</button>
<div class="ios-acc">
<button class="ios-collapse-trigger"
type="button"
data-bs-toggle="collapse"
data-bs-target="#contactsCollapse"
aria-expanded="false"
aria-controls="contactsCollapse">
<span class="title"><i class="bi-person-lines-fill me-1"></i>Контакты</span>
<i class="bi-chevron-down ios-collapse-caret"></i>
</button>
<div id="contactsCollapse" class="collapse mt-2">
<div class="ios-list">
{% if emp.email %}
<div class="ios-cell">
<i class="bi-envelope"></i>
<a href="mailto:{{ emp.email }}">{{ emp.email }}</a>
</div>
{% endif %}
{% if emp.phone_number %}
<div class="ios-cell">
<i class="bi-telephone"></i>
<a href="tel:{{ emp.phone_number }}">{{ emp.phone_number }}</a>
</div>
{% endif %}
{% if emp.telegram %}
<div class="ios-cell"><i class="bi-telegram"></i><span>Telegram: {{ emp.telegram }}</span></div>
{% endif %}
{% if emp.whatsapp %}
<div class="ios-cell"><i class="bi-whatsapp"></i><span>WhatsApp: {{ emp.whatsapp }}</span></div>
{% endif %}
{% if emp.wechat %}
<div class="ios-cell"><i class="bi-wechat"></i><span>WeChat: {{ emp.wechat }}</span></div>
{% endif %}
{% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %}
<div class="ios-cell"><span class="text-secondary">Контакты не указаны</span></div>
{% endif %}
<div id="contactsCollapse" class="collapse">
<div class="ios-acc-body">
<div class="ios-list">
{% if emp.email %}
<div class="ios-cell">
<i class="bi-envelope"></i>
<a href="mailto:{{ emp.email }}">{{ emp.email }}</a>
</div>
{% endif %}
{% if emp.phone_number %}
<div class="ios-cell">
<i class="bi-telephone"></i>
<a href="tel:{{ emp.phone_number }}">{{ emp.phone_number }}</a>
</div>
{% endif %}
{% if emp.telegram %}<div class="ios-cell"><i class="bi-telegram"></i><span>Telegram: {{ emp.telegram }}</span></div>{% endif %}
{% if emp.whatsapp %}<div class="ios-cell"><i class="bi-whatsapp"></i><span>WhatsApp: {{ emp.whatsapp }}</span></div>{% endif %}
{% if emp.wechat %}<div class="ios-cell"><i class="bi-wechat"></i><span>WeChat: {{ emp.wechat }}</span></div>{% endif %}
{% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %}
<div class="ios-cell"><span class="text-secondary">Контакты не указаны</span></div>
{% endif %}
</div>
</div>
</div>
</div>
{# Departments — покажем, только если бэкенд вернул список #}
{% if emp.departments %}
<div class="mt-3 text-start">
<button class="ios-collapse-trigger" type="button"
data-bs-toggle="collapse" data-bs-target="#departmentsCollapse"
aria-expanded="false" aria-controls="departmentsCollapse">
{# === ОТДЕЛЫ (без картинок; роль через тире; руководитель — визуально выделен) === #}
<div class="mt-3 text-start">
<div class="ios-acc">
<button class="ios-collapse-trigger"
type="button"
data-bs-toggle="collapse"
data-bs-target="#departmentsCollapse"
aria-expanded="false"
aria-controls="departmentsCollapse">
<span class="title"><i class="bi-diagram-3 me-1"></i>Отделы</span>
<i class="bi-chevron-down ios-collapse-caret"></i>
</button>
<div id="departmentsCollapse" class="collapse mt-2">
<div id="departmentsCollapse" class="collapse">
<div class="ios-acc-body">
<div class="ios-list">
{% for d in emp.departments %}
<div class="ios-cell">
<i class="bi-diagram-3"></i>
<a href="{% url 'employees:department_detail' d.id %}">{{ d.name }}</a>
</div>
{% empty %}
<div class="ios-cell"><span class="text-secondary">Нет отделов</span></div>
{% endfor %}
{% if emp.departments and emp.departments|length %}
{% for d in emp.departments %}
{% if d.name %}
<div class="ios-cell dept-item">
<div class="dept-line">
{% if d.id %}
<a class="text-decoration-none dept-name" href="{% url 'employees:department_detail' d.id %}">{{ d.name }}</a>
{% else %}
<span class="dept-name">{{ d.name }}</span>
{% endif %}
{% if d.role_name %}<span class="dept-role">— {{ d.role_name }}</span>{% endif %}
</div>
{% if d.is_head %}
<span class="badge-head" data-bs-toggle="tooltip" data-bs-title="Руководитель отдела">
<i class="bi-award-fill" aria-hidden="true"></i>
</span>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% else %}
<div class="ios-cell"><span class="text-secondary">Отделы не указаны</span></div>
{% endif %}
</div>
</div>
</div>
</div>
{# === КАДРОВЫЕ СОБЫТИЯ (для аутентифицированных) === #}
{% if request.user.is_authenticated %}
{% with acts_src=employee_actions|default:emp.actions %}
{% with acts=acts_src.results|default:acts_src %}
{% if acts %}
<div class="mt-3 text-start">
<div class="ios-acc">
<button class="ios-collapse-trigger" type="button"
data-bs-toggle="collapse" data-bs-target="#actionsCollapse"
aria-expanded="false" aria-controls="actionsCollapse">
<span class="title"><i class="bi-journal-text me-1"></i>Кадровые события</span>
<i class="bi-chevron-down ios-collapse-caret"></i>
</button>
<div id="actionsCollapse" class="collapse">
<div class="ios-acc-body">
<div class="ios-list">
{% for a in acts %}
<div class="ios-cell">
<i class="bi-clipboard-check" style="width:1.25rem;text-align:center;opacity:.9;"></i>
<div class="flex-grow-1">
<div class="fw-bold">{% firstof a.action_display a.action %}</div>
{% if a.comment %}<div class="text-secondary">{{ a.comment }}</div>{% endif %}
<div class="small text-secondary">
<time>{% firstof a.date_display a.date|date:"d.m.Y H:i" %}</time>
</div>
{% if a.extra %}
<div class="small text-secondary mt-1">
{% for k, v in a.extra.items %}
<div>{{ k }}: {{ v }}</div>
{% endfor %}
</div>
{% endif %}
{# История этого события: без "создано/изменено", только название события и новые значения #}
{% if a.history and a.history|length %}
<details class="mt-2 small">
<summary class="text-secondary">История</summary>
<div class="mt-2">
{% for h in a.history %}
{% with new_action=h.changes.action.new|default:a.action %}
<div class="py-2 border-top">
<div class="d-flex align-items-baseline gap-2">
<span class="fw-semibold">
{# Переводим код события в человекочитаемый #}
{% 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 %}
</span>
<span class="text-secondary">
{% firstof h.history_date_display h.history_date %}{% if h.history_user %} • {{ h.history_user }}{% endif %}
</span>
</div>
{% if h.changes %}
<div class="ms-3 mt-1">
{# Показываем только НОВЫЕ значения (без зачёркнутых старых) #}
{% for field, diff in h.changes.items %}
{% if field != 'action' %}
<div>
<span class="text-secondary">
{% if field == 'date' %}Дата события
{% elif field == 'comment' %}Комментарий
{% elif field == 'extra' %}Дополнительно
{% elif field == 'employee_id' %}Сотрудник
{% else %}{{ field }}{% endif %}
:
</span>
{% 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 %}
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endwith %}
{% endfor %}
</div>
</details>
{% endif %}
</div>
</div>
{% empty %}
<div class="ios-cell"><span class="text-secondary">Событий нет.</span></div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
{# Кнопка чата убрана, чтобы не ловить NoReverseMatch, если роут не подключён #}
</div>
</article>
</div>
@ -228,3 +449,4 @@
</div>
</div>
{% endblock %}

View File

@ -60,7 +60,7 @@
</div>
<div class="col-md-12">
<label class="form-label">Навыки (id через Ctrl/⌘)</label>
<label class="form-label">Навыки </label>
<select name="skills" class="form-select" multiple>
{% comment %}
Тут можно подставить реальные варианты, если передать их из вьюхи.

View File

@ -1,67 +1,139 @@
{% for post in items %}
<article class="feed-card" data-post-card id="post-{{ post.pk }}">
{% firstof post.id post.pk as post_id %}
<article class="feed-card" data-post-card id="post-{{ post_id }}">
<header class="feed-hd">
<div class="feed-ava">
{% if post.author and post.author.avatar %}
<img src="{{ post.author.avatar.url }}" alt="">
{% else %}<i class="bi-person"></i>{% endif %}
{# --- Аватар автора --- #}
<div class="feed-ava d-flex align-items-center justify-content-center">
{% with a=post.author %}
{% if a %}
{% firstof a.avatar.url a.avatar_url as ava_url %}
{% if ava_url %}
<img src="{{ ava_url }}" alt="">
{% else %}
<i class="bi-person"></i>
{% endif %}
{% else %}
<i class="bi-person"></i>
{% endif %}
{% endwith %}
</div>
{# --- Метаданные (имя автора, дата, отдел) --- #}
<div class="feed-meta">
<div class="feed-author">
{% if post.author %}
<a href="{% url 'employees:employee_detail' post.author.pk %}" class="text-decoration-none">
{{ post.author.get_full_name|default:post.author.username }}
</a>
{% else %}Автор не указан{% endif %}
{% with a=post.author %}
{% firstof a.id post.author_id as author_id %}
{% if author_id %}
<a href="{% url 'employees:employee_detail' author_id %}" class="text-decoration-none">
{% if a %}
{% firstof a.full_name a.get_full_name as nm %}
{% if nm %}
{{ nm }}
{% else %}
{% if a.first_name or a.last_name %}
{{ a.first_name }}{% if a.last_name %} {{ a.last_name }}{% endif %}
{% else %}
{% firstof a.username a.email "Сотрудник" %}
{% endif %}
{% endif %}
{% else %}
Сотрудник #{{ author_id }}
{% endif %}
</a>
{% else %}
Автор не указан
{% endif %}
{% endwith %}
</div>
<div class="feed-sub">
{% if post.created_at %}
<time datetime="{{ post.created_at|date:'c' }}">{{ post.created_at|date:"d.m.Y H:i" }}</time>
{{ post.created_at_display }}
{% endif %}
{% if post.type == 'department' %}
{# имя отдела может прийти как department.name (ORM) или department_name (API) #}
{% firstof post.department.name post.department_name as dept_name %}
{% if dept_name %} · {{ dept_name }}{% endif %}
{% endif %}
{% if post.type == 'department' and post.department %} · {{ post.department.name }}{% endif %}
</div>
</div>
{% if post.pinned %}<span class="feed-pin"><i class="bi-pin-angle"></i> закреплено</span>{% endif %}
{% if post.pinned %}
<span class="feed-pin"><i class="bi-pin-angle"></i> закреплено</span>
{% endif %}
</header>
<div class="feed-body position-relative">
<h3 class="feed-title mb-1">
{% if post.pk %}
<a href="{% url 'feed:post_detail' post.pk %}" class="text-decoration-none stretched-link">
{% if post_id %}
<a href="{% url 'feed:post_detail' post_id %}" class="text-decoration-none stretched-link">
{{ post.title|default:"Без названия" }}
</a>
{% else %}
{{ post.title|default:"Без названия" }}
{% endif %}
</h3>
<p class="feed-text">{{ post.body|default_if_none:""|truncatewords:42 }}</p>
{% with body=post.body|default_if_none:"" %}
{% if body|wordcount > 42 %}
<div class="feed-text mb-1" id="excerpt-{{ post_id }}">
{{ body|truncatewords:42 }}
</div>
<div id="full-{{ post_id }}" class="collapse"
data-excerpt="#excerpt-{{ post_id }}"
data-btn="#more-{{ post_id }}">
<div class="feed-full mt-1">
{{ body|linebreaksbr }}
</div>
</div>
<button class="btn btn-link px-0 mt-1 small readmore-toggle"
id="more-{{ post_id }}"
type="button"
data-bs-toggle="collapse"
data-bs-target="#full-{{ post_id }}"
aria-controls="full-{{ post_id }}"
aria-expanded="false">
<span class="txt-more">Подробнее</span>
<span class="txt-less d-none">Скрыть</span>
</button>
{% else %}
<div class="feed-full">
{{ body|linebreaksbr }}
</div>
{% endif %}
{% endwith %}
</div>
{% if post.image %}
<div class="feed-img position-relative">
{% if post.pk %}<a href="{% url 'feed:post_detail' post.pk %}" class="stretched-link"></a>{% endif %}
<img src="{{ post.image.url }}" alt="{{ post.title|default:'Публикация' }}">
</div>
{% firstof post.image.url post.image as img_url %}
{% if img_url %}
<div class="feed-img position-relative">
{% if post_id %}<a href="{% url 'feed:post_detail' post_id %}" class="stretched-link"></a>{% endif %}
<img src="{{ img_url }}" alt="{{ post.title|default:'Публикация' }}">
</div>
{% endif %}
{% endif %}
<footer class="feed-actions">
{# Комментарии: свернуть/развернуть ТОЛЬКО блок ниже (по умолчанию скрыт) #}
{% if post.pk %}
{# Кнопка/ссылка комментариев #}
{% if post_id %}
{% if user.is_authenticated %}
<button class="btn btn-ghost d-inline-flex align-items-center gap-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#ccoll-{{ post.pk }}"
aria-controls="ccoll-{{ post.pk }}"
data-bs-target="#ccoll-{{ post_id }}"
aria-controls="ccoll-{{ post_id }}"
aria-expanded="false">
<i class="bi-chat-dots"></i>
<span class="txt-open">{{ post.comments_count|default:0 }}</span>
<span class="txt-close">Скрыть</span>
</button>
{% else %}
<a href="{% url 'feed:post_detail' post.pk %}#comments"
<a href="{% url 'feed:post_detail' post_id %}#comments"
class="btn btn-ghost d-inline-flex align-items-center gap-1">
<i class="bi-chat-dots"></i><span>{{ post.comments_count|default:0 }}</span>
</a>
@ -69,19 +141,24 @@
{% endif %}
{% if post.attachment %}
<a href="{{ post.attachment.url }}" class="btn btn-ghost d-inline-flex align-items-center gap-1">
<i class="bi-paperclip"></i><span>Вложение</span>
</a>
{% firstof post.attachment.url post.attachment as att_url %}
{% if att_url %}
<a href="{{ att_url }}" class="btn btn-ghost d-inline-flex align-items-center gap-1">
<i class="bi-paperclip"></i><span>Вложение</span>
</a>
{% endif %}
{% endif %}
<span class="ms-auto"></span>
{% if user.is_authenticated and post.pk %}
<form method="post" action="{% url 'feed:toggle_like' post.pk %}" class="ms-2">
{# Лайк: через внутренний view, который ходит в API #}
{% if user.is_authenticated and post_id %}
<form method="post" action="{% url 'feed:toggle_like' post_id %}" class="ms-2">
{% csrf_token %}
<button type="submit"
class="btn btn-ghost d-inline-flex align-items-center gap-1 {% if post.is_liked %}is-liked{% endif %}"
data-like-post="{{ post.pk }}"
data-like-url="{% url 'feed:toggle_like' post.pk %}"
data-like-post="{{ post_id }}"
data-like-url="{% url 'feed:toggle_like' post_id %}"
aria-pressed="{% if post.is_liked %}true{% else %}false{% endif %}">
<i class="bi {% if post.is_liked %}bi-heart-fill{% else %}bi-heart{% endif %}"></i>
<span data-like-count>{{ post.likes_count|default:0 }}</span>
@ -95,37 +172,62 @@
{% endif %}
</footer>
{# ===== СВАРАЧИВАЕМЫЙ БЛОК: всегда скрыт по умолчанию ===== #}
<div id="ccoll-{{ post.pk }}" class="collapse">
{# ===== СКЛАДНОЙ БЛОК КОММЕНТАРИЕВ ===== #}
<div id="ccoll-{{ post_id }}" class="collapse">
{% if post.comments_count %}
<div class="px-3 pt-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<a class="small text-primary text-decoration-none"
href="{% url 'feed:post_detail' post.pk %}#comments">
href="{% url 'feed:post_detail' post_id %}#comments">
Показать все комментарии записи ({{ post.comments_count }})
</a>
</div>
{# last_comment_list — есть только в старом ORM-пути; при API просто не покажется #}
{% with last=post.last_comment_list|first %}
{% if last %}
<div class="bg-body-tertiary border rounded p-2">
<div class="small mb-1">
<a href="{% url 'employees:employee_detail' last.author.pk %}"
{% firstof last.author.id last.author.pk as last_author_id %}
<a href="{% url 'employees:employee_detail' last_author_id %}"
class="text-decoration-none fw-semibold">
{{ last.author.get_full_name|default:last.author.username }}
{% firstof last.author.get_full_name last.author.username "Сотрудник" %}
</a>
<span class="text-secondary">·</span>
<time class="text-muted">{{ last.created_at|date:"d.m.Y H:i" }}</time>
<time class="text-muted">
{% firstof last.created_at|date:"d.m.Y H:i" last.created_at %}
</time>
</div>
<div class="small">{{ last.text|truncatechars:220 }}</div>
</div>
{% elif post.last_comment %}
{# НОВОЕ: из API #}
{% with lc=post.last_comment %}
<div class="bg-body-tertiary border rounded p-2">
<div class="small mb-1">
{% if lc.author.id %}
<a href="{% url 'employees:employee_detail' lc.author.id %}"
class="text-decoration-none fw-semibold">
{{ lc.author.full_name|default:"Сотрудник" }}
</a>
{% else %}
{{ lc.author.full_name|default:"Сотрудник" }}
{% endif %}
<span class="text-secondary">·</span>
<time class="text-muted">
{% firstof lc.created_at_display lc.created_at %}
</time>
</div>
<div class="small">{{ lc.text|truncatechars:220 }}</div>
</div>
{% endwith %}
{% endif %}
{% endwith %}
</div>
{% endif %}
{% if user.is_authenticated and post.pk %}
<form class="px-3 pb-3 pt-2" method="post" action="{% url 'feed:post_detail' post.pk %}">
{% if user.is_authenticated and post_id %}
<form class="px-3 pb-3 pt-2" method="post" action="{% url 'feed:post_detail' post_id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<div class="input-group">
@ -139,3 +241,34 @@
{% empty %}
<div class="alert alert-info shadow-sm">Пока нет новостей.</div>
{% endfor %}
<script>
(function(){
// когда открываем «Полный текст» — прячем краткий, меняем подпись кнопки
document.addEventListener('show.bs.collapse', function(e){
const exSel = e.target.dataset.excerpt;
const btnSel = e.target.dataset.btn;
if (exSel) document.querySelector(exSel)?.classList.add('d-none');
const btn = btnSel ? document.querySelector(btnSel) : null;
if (btn){
btn.querySelector('.txt-more')?.classList.add('d-none');
btn.querySelector('.txt-less')?.classList.remove('d-none');
btn.setAttribute('aria-expanded', 'true');
}
});
// при сворачивании — показываем краткий, меняем подпись обратно
document.addEventListener('hide.bs.collapse', function(e){
const exSel = e.target.dataset.excerpt;
const btnSel = e.target.dataset.btn;
if (exSel) document.querySelector(exSel)?.classList.remove('d-none');
const btn = btnSel ? document.querySelector(btnSel) : null;
if (btn){
btn.querySelector('.txt-more')?.classList.remove('d-none');
btn.querySelector('.txt-less')?.classList.add('d-none');
btn.setAttribute('aria-expanded', 'false');
}
});
})();
</script>

View File

@ -4,88 +4,282 @@
{% block title %}Лента{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center mb-4">
<div class="col-lg-9 col-xl-8">
<style>
/* ===== секция «новички» (контейнер) ===== */
.join-wrap{
padding:.9rem 1rem;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
border-radius: var(--feed-radius);
background: var(--bs-body-bg);
box-shadow: var(--feed-shadow);
}
<div class="feed-header">
<i class="bi-megaphone fs-2 text-primary"></i>
<h2 class="title mb-0">Лента</h2>
/* ===== горизонтальная лента ===== */
.join-scroller{
position: relative;
overflow-x: auto; /* горизонтальная прокрутка */
overflow-y: visible; /* нужно для инфо-блока */
scrollbar-width: none; /* Firefox скрыть полосы */
-ms-overflow-style: none; /* IE/старый Edge */
}
.join-scroller::-webkit-scrollbar{ display:none; } /* Chrome/Safari */
{# КНОПКА СОЗДАНИЯ ПУБЛИКАЦИИ ПО ПРАВАМ ДОСТУПА #}
{% if user.is_authenticated %}
{# Лента отдела — если в контексте передан department #}
{% if department %}
{% if user.is_staff or department.head_id == user.id %}
<a class="btn btn-primary ms-auto"
href="{% url 'feed:post_create' %}?department={{ department.pk }}">
<i class="bi-plus-circle me-1"></i> Новая публикация
</a>
{% endif %}
.join-rail{
display:flex; align-items:flex-start; gap:.9rem; /* start: инфо-блоки снизу */
padding:.25rem .25rem;
}
{# Лента сотрудника — если в контексте передан employee #}
{% elif employee %}
{% if user.is_staff or employee.pk == user.pk %}
<a class="btn btn-primary ms-auto"
href="{% url 'feed:post_create' %}?type=employee">
<i class="bi-plus-circle me-1"></i> Новая публикация
</a>
{% endif %}
/* мягкие «маски» по краям */
.join-scroller::before,
.join-scroller::after{
content:""; position:absolute; top:0; bottom:0; width:32px; pointer-events:none;
background: linear-gradient(to right, var(--bs-body-bg), transparent);
}
.join-scroller::after{ right:0; left:auto; transform: scaleX(-1); }
.join-scroller::before{ left:0; }
{# Лента компании (по умолчанию) #}
{% else %}
{% if user.is_staff %}
<a class="btn btn-primary ms-auto"
href="{% url 'feed:post_create' %}?type=company">
<i class="bi-plus-circle me-1"></i> Новая публикация
</a>
{% endif %}
@media (prefers-reduced-motion: reduce){
.join-scroller{ scroll-behavior: auto !important; }
}
/* ===== элемент ленты (ячейка) ===== */
.join-item{
--base: 56px; /* базовый размер круга */
--mult: 4; /* множитель при раскрытии */
--size: var(--base); /* текущий "активный" размер */
position: relative;
display:flex; flex-direction: column; align-items:center;
width: var(--size);
flex: 0 0 var(--size);
transition: width .22s ease, flex-basis .22s ease;
}
.join-item:hover,
.join-item:focus-within{
--size: calc(var(--base) * var(--mult));
z-index: 2; /* поверх соседей */
}
/* круг с фото */
.join-ava{
width: var(--size); height: var(--size);
border-radius:999px; overflow:hidden;
display:grid; place-items:center;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: var(--bs-tertiary-bg);
transition: width .22s ease, height .22s ease, box-shadow .22s ease, border-color .22s ease;
will-change: width, height;
}
.join-ava img{ width:100%; height:100%; object-fit:cover; display:block; background: var(--bs-tertiary-bg); }
.join-ava .bi{ font-size: 1.2rem; opacity:.65; }
.join-item:hover .join-ava,
.join-item:focus-within .join-ava{
box-shadow: var(--feed-shadow-hover);
border-color: color-mix(in srgb, var(--bs-border-color), var(--bs-primary) 30%);
}
/* инфо-блок ПОД кругом */
.join-info{
width: 100%;
overflow: hidden;
max-height: 0;
opacity: 0;
transform: translateY(-4px);
transition: max-height .24s ease, opacity .18s ease, transform .18s ease;
}
.join-item:hover .join-info,
.join-item:focus-within .join-info{
max-height: 320px; /* с запасом под длинный список навыков */
opacity: 1;
transform: translateY(0);
}
.join-info .name{ font-weight:800; line-height:1.2; margin-top:.5rem; text-align:center; }
.join-info .sub { font-size:.9rem; color: var(--bs-secondary-color); text-align:center; }
.join-info .skills-line{
font-size:.9rem; text-align:center; margin-top:.35rem;
word-break: break-word; white-space: normal;
}
.join-info .skills-line b{ font-weight:800; }
.join-info .link{ display:inline-block; margin-top:.35rem; font-size:.9rem; text-decoration:none; }
.join-info .link:hover{ text-decoration: underline; }
/* На маленьких экранах чуть меньше раскрываемся */
@media (max-width: 480px){
.join-item{ --mult: 3.2; }
}
</style>
<div class="container">
<div class="row justify-content-center mb-4">
<div class="col-lg-9 col-xl-8">
<div class="feed-header d-flex align-items-center gap-3">
<i class="bi-megaphone fs-2 text-primary"></i>
<h2 class="title mb-0">Лента</h2>
{% if user.is_authenticated and user.is_staff %}
<a class="btn btn-primary ms-auto" href="{% url 'feed:post_create' %}?type=company">
<i class="bi-plus-circle me-1"></i> Новая публикация
</a>
{% endif %}
{% endif %}
</div>
</div>
{% if new_employees %}
<div class="feed-new mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-secondary">Присоединились недавно</h6>
<a href="{% url 'employees:employees_list' %}" class="small text-primary">Все сотрудники</a>
</div>
<div class="d-flex flex-wrap gap-3">
{% for emp in new_employees %}
<div class="text-center" style="width:84px;">
<a href="{% url 'employees:employee_detail' emp.pk %}" class="text-decoration-none text-dark">
<div class="feed-ava mx-auto mb-1" style="width:48px;height:48px;">
{% if emp.avatar %}
<img src="{{ emp.avatar.url }}" alt="">
{% else %}
<i class="bi-person"></i>
{% endif %}
{% if new_employees %}
<div class="feed-new mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-secondary">Присоединились недавно</h6>
<a href="{% url 'employees:employees_list' %}" class="small text-primary text-decoration-none">Все сотрудники</a>
</div>
<div class="join-scroller js-join-scroller" aria-label="Новые сотрудники">
<div class="join-rail">
{% 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 %}
<div class="join-item">
<a href="{% url 'employees:employee_detail' emp_id %}"
class="join-ava text-decoration-none"
title="{{ emp_name }}">
{% if ava %}
{% if ava.url %}
<img src="{{ ava.url }}" alt="">
{% else %}
<img src="{{ ava }}" alt="">
{% endif %}
{% else %}
<i class="bi-person" aria-hidden="true"></i>
<span class="visually-hidden">{{ emp_name }}</span>
{% endif %}
</a>
<div class="join-info">
<div class="name">{{ emp_name }}</div>
{% if dept_name %}<div class="sub">{{ dept_name }}</div>{% endif %}
{# НАВЫКИ — печать всех, без итерации по строкам #}
{% if emp.skills and emp.skills.0.name %}
<div class="skills-line"><b>Навыки:</b>
{% for s in emp.skills %}{% if not forloop.first %}, {% endif %}{{ s.name }}{% endfor %}
</div>
{% endif %}
<div class="text-center">
<a class="link" href="{% url 'employees:employee_detail' emp_id %}">Профиль →</a>
</div>
</div>
</div>
<div class="small text-truncate">{{ emp.get_short_name|default:emp.first_name }}</div>
</a>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="feed-stream">
{% if page %}
{% include "feed/_feed_cards.html" with items=page.object_list %}
{% elif posts %}
{% include "feed/_feed_cards.html" with items=posts %}
{% else %}
<div class="alert alert-info shadow-sm mt-2">Пока нет новостей.</div>
{% endif %}
</div>
{% if page %}
<div class="mt-4">
{% bootstrap_pagination page %}
<div class="feed-stream">
{% if page %}
{% include "feed/_feed_cards.html" with items=page.object_list %}
{% elif posts %}
{% include "feed/_feed_cards.html" with items=posts %}
{% else %}
<div class="alert alert-info shadow-sm mt-2">Пока нет новостей.</div>
{% endif %}
</div>
{% endif %}
{% if page %}
<div class="mt-4">
{% bootstrap_pagination page %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
<script>
(function(){
const STEP_MS = 3000;
const SCROLL_BEHAVIOR = 'smooth';
function centerItemInView(wrap, item){
const r = item.getBoundingClientRect();
const w = wrap.getBoundingClientRect();
const delta = (r.left + r.width/2) - (w.left + w.width/2);
wrap.scrollTo({ left: wrap.scrollLeft + delta, behavior: SCROLL_BEHAVIOR });
}
function initAutoScroller(wrap){
const rail = wrap.querySelector('.join-rail');
if (!rail) return;
// если контент не переполняет — автоскролл не нужен
const overflowX = rail.scrollWidth > wrap.clientWidth + 4;
if (!overflowX) return;
// удвоим содержимое для бесшовного цикла
const children = Array.from(rail.children);
rail.append(...children.map(n => n.cloneNode(true)));
// шаг = ширина первой ячейки + gap
function computeStep(){
const first = rail.firstElementChild;
if (!first) return 0;
const rect = first.getBoundingClientRect();
const gap = parseFloat(getComputedStyle(rail).gap || '0') || 0;
return Math.round(rect.width + gap);
}
let step = computeStep();
const origCount = children.length;
let timer = null, paused = false;
function tick(){
if (paused) return;
const maxLeft = (step * origCount);
if (wrap.scrollLeft >= maxLeft - 2){ wrap.scrollLeft = 0; }
wrap.scrollBy({ left: step, behavior: SCROLL_BEHAVIOR });
}
function start(){ if (!timer) timer = setInterval(tick, STEP_MS); }
function stop(){ if (timer){ clearInterval(timer); timer = null; } }
// Пауза при наведении/фокусе на конкретной ячейке + центрирование
rail.querySelectorAll('.join-item').forEach(item=>{
item.addEventListener('mouseenter', ()=>{
paused = true;
centerItemInView(wrap, item);
});
item.addEventListener('mouseleave', ()=>{ paused = false; });
item.addEventListener('focusin', ()=>{
paused = true;
centerItemInView(wrap, item);
});
item.addEventListener('focusout', ()=>{ paused = false; });
});
// Пересчёт шага на ресайз
let rAF = null;
window.addEventListener('resize', ()=>{
cancelAnimationFrame(rAF);
rAF = requestAnimationFrame(()=>{ step = computeStep(); });
});
// Остановка при свёрнутой вкладке
document.addEventListener('visibilitychange', ()=>{
if (document.hidden) stop(); else start();
});
// Уважим reduced-motion
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
start();
}
document.addEventListener('DOMContentLoaded', ()=>{
document.querySelectorAll('.js-join-scroller').forEach(initAutoScroller);
});
})();
</script>

View File

@ -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 %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9 col-xl-8">
@ -14,40 +18,55 @@
<a href="{% url 'feed:feed_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К ленте
</a>
{% elif post.type == 'department' and post.department %}
<a href="{% url 'employees:department_detail' post.department.pk %}" class="btn btn-ghost">
{% elif post.type == 'department' and dept_id %}
<a href="{% url 'employees:department_detail' dept_id %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К отделу
</a>
{% else %}
<a href="{% url 'feed:employee_feed' post.author.pk %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К публикациям автора
</a>
{% endif %}
<span class="small text-secondary ms-auto">{{ post.created_at|date:"d.m.Y H:i" }}</span>
{# Пин #}
{% if post.type == 'company' and user.is_staff %}
<a href="{% url 'feed:pin_post' post.pk %}" class="btn btn-ghost">
<i class="bi-pin-angle"></i> {% if post.pinned %}Открепить{% else %}Закрепить{% endif %}
</a>
{% elif post.type == 'department' and post.department %}
{% if user.is_staff or post.department.head_id == user.id %}
<a href="{% url 'feed:pin_post' post.pk %}" class="btn btn-ghost">
<i class="bi-pin-angle"></i> {% if post.pinned %}Открепить{% else %}Закрепить{% endif %}
{% if author_id %}
<a href="{% url 'feed:employee_feed' author_id %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К публикациям автора
</a>
{% else %}
<a href="{% url 'feed:feed_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К ленте
</a>
{% endif %}
{% endif %}
<span class="small text-secondary ms-auto">
</span>
{# Пин — без скобок в условии #}
{% if post_id %}
{% if post.type == 'company' and user.is_staff %}
<a href="{% url 'feed:pin_post' post_id %}" class="btn btn-ghost">
<i class="bi-pin-angle"></i> {% if post.pinned %}Открепить{% else %}Закрепить{% endif %}
</a>
{% elif post.type == 'department' and dept_id %}
{% if user.is_staff %}
<a href="{% url 'feed:pin_post' post_id %}" class="btn btn-ghost">
<i class="bi-pin-angle"></i> {% if post.pinned %}Открепить{% else %}Закрепить{% endif %}
</a>
{% elif post.department and post.department.head_id == user.id %}
<a href="{% url 'feed:pin_post' post_id %}" class="btn btn-ghost">
<i class="bi-pin-angle"></i> {% if post.pinned %}Открепить{% else %}Закрепить{% endif %}
</a>
{% endif %}
{% endif %}
{% endif %}
{# Редактирование #}
{% if user.is_staff or user.id == post.author_id %}
<a href="{% url 'feed:post_update' post.pk %}" class="btn btn-ghost">
<i class="bi-pencil"></i> Редактировать
</a>
{% elif post.type == 'department' and post.department and post.department.head_id == user.id %}
<a href="{% url 'feed:post_update' post.pk %}" class="btn btn-ghost">
<i class="bi-pencil"></i> Редактировать
</a>
{% if post_id %}
{% if user.is_staff or user.id == post.author_id %}
<a href="{% url 'feed:post_update' post_id %}" class="btn btn-ghost">
<i class="bi-pencil"></i> Редактировать
</a>
{% elif post.type == 'department' and post.department and post.department.head_id == user.id %}
<a href="{% url 'feed:post_update' post_id %}" class="btn btn-ghost">
<i class="bi-pencil"></i> Редактировать
</a>
{% endif %}
{% endif %}
</div>
@ -55,19 +74,42 @@
<article class="feed-card" data-post-card>
<header class="feed-hd">
<div class="feed-ava">
{% if post.author and post.author.avatar %}
<img src="{{ post.author.avatar.url }}" alt="">
{% else %}<i class="bi-person"></i>{% endif %}
{% with a=post.author %}
{% if a %}
{% firstof a.avatar.url a.avatar_url as ava_url %}
{% if ava_url %}<img src="{{ ava_url }}" alt="">{% else %}<i class="bi-person"></i>{% endif %}
{% else %}
<i class="bi-person"></i>
{% endif %}
{% endwith %}
</div>
<div class="feed-meta">
<div class="feed-author">
<a href="{% url 'employees:employee_detail' post.author.pk %}" class="text-decoration-none">
{{ post.author.get_full_name|default:post.author.username }}
</a>
{% if author_id %}
<a href="{% url 'employees:employee_detail' author_id %}" class="text-decoration-none">
{% with a=post.author %}
{% if a %}
{% firstof a.full_name a.get_full_name as nm %}
{% if nm %}{{ nm }}{% else %}
{% if a.first_name or a.last_name %}
{{ a.first_name }}{% if a.last_name %} {{ a.last_name }}{% endif %}
{% else %}
{% firstof a.username a.email "Сотрудник" %}
{% endif %}
{% endif %}
{% else %}Сотрудник #{{ author_id }}{% endif %}
{% endwith %}
</a>
{% else %}Автор не указан{% endif %}
</div>
<div class="feed-sub">
<time datetime="{{ post.created_at|date:'c' }}">{{ post.created_at|date:"d.m.Y H:i" }}</time>
{% 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 %}
</div>
</div>
{% if post.pinned %}
@ -76,21 +118,27 @@
</header>
{% if post.image %}
<div class="feed-img">
<img src="{{ post.image.url }}" alt="{{ post.title|default:'Публикация' }}">
</div>
{% firstof post.image.url post.image as img_url %}
{% if img_url %}
<div class="feed-img">
<img src="{{ img_url }}" alt="{% firstof post.title 'Публикация' %}">
</div>
{% endif %}
{% endif %}
<div class="feed-body">
<h1 class="feed-title fs-4 mb-2">{{ post.title|default:"Без названия" }}</h1>
<h1 class="feed-title fs-4 mb-2">{% firstof post.title "Без названия" %}</h1>
<div class="fs-6 lh-base">{{ post.body|linebreaks }}</div>
{% if post.attachment %}
<div class="mt-3">
<a href="{{ post.attachment.url }}" target="_blank" class="btn btn-ghost">
<i class="bi-paperclip"></i> Вложение
</a>
</div>
{% firstof post.attachment.url post.attachment as att_url %}
{% if att_url %}
<div class="mt-3">
<a href="{{ att_url }}" target="_blank" class="btn btn-ghost">
<i class="bi-paperclip"></i> Вложение
</a>
</div>
{% endif %}
{% endif %}
</div>
@ -101,13 +149,13 @@
<span class="ms-auto"></span>
{% if user.is_authenticated %}
<form method="post" action="{% url 'feed:toggle_like' post.pk %}">
{% if user.is_authenticated and post_id %}
<form method="post" action="{% url 'feed:toggle_like' post_id %}">
{% csrf_token %}
<button type="submit"
class="btn btn-ghost {% if post.is_liked or is_liked %}is-liked{% endif %}"
data-like-post="{{ post.pk }}"
data-like-url="{% url 'feed:toggle_like' post.pk %}"
data-like-post="{{ post_id }}"
data-like-url="{% url 'feed:toggle_like' post_id %}"
aria-pressed="{% if post.is_liked or is_liked %}true{% else %}false{% endif %}">
<i class="bi {% if post.is_liked or is_liked %}bi-heart-fill{% else %}bi-heart{% endif %}"></i>
<span data-like-count>{{ post.likes_count|default:0 }}</span>
@ -132,39 +180,65 @@
{% if comments %}
<div class="feed-stream">
{% for comment in comments %}
<article id="c{{ comment.pk }}" class="feed-card">
{% for c in comments %}
{% firstof c.id c.pk as cid %}
{% firstof c.author.id c.author.pk c.author_id as c_author_id %}
<article id="c{{ cid }}" class="feed-card">
<header class="feed-hd">
<div class="feed-ava" style="width:36px;height:36px;">
{% if comment.author.avatar %}
<img src="{{ comment.author.avatar.url }}" alt="">
{% else %}<i class="bi-person"></i>{% endif %}
{% with a=c.author %}
{% if a %}
{% firstof a.avatar.url a.avatar_url as c_ava %}
{% if c_ava %}<img src="{{ c_ava }}" alt="">{% else %}<i class="bi-person"></i>{% endif %}
{% else %}
<i class="bi-person"></i>
{% endif %}
{% endwith %}
</div>
<div class="feed-meta">
<div class="feed-author">
<a href="{% url 'employees:employee_detail' comment.author.pk %}" class="text-decoration-none">
{{ comment.author.get_full_name|default:comment.author.username }}
</a>
{% if c_author_id %}
<a href="{% url 'employees:employee_detail' c_author_id %}" class="text-decoration-none">
{% with a=c.author %}
{% if a %}
{% firstof a.full_name a.get_full_name as nm %}
{% if nm %}{{ nm }}{% else %}
{% if a.first_name or a.last_name %}
{{ a.first_name }}{% if a.last_name %} {{ a.last_name }}{% endif %}
{% else %}
{% firstof a.username a.email "Сотрудник" %}
{% endif %}
{% endif %}
{% else %}Сотрудник #{{ c_author_id }}{% endif %}
{% endwith %}
</a>
{% else %}Автор{% endif %}
</div>
<div class="feed-sub">
<time datetime="{{ comment.created_at|date:'c' }}">{{ comment.created_at|date:"d.m.Y H:i" }}</time>
{% if c.created_at %}
<time datetime="{{ c.created_at }}">
{{ c.created_at_display }}
</time>
{% endif %}
</div>
</div>
{% if request.user.is_staff or request.user.id == comment.author_id %}
{% if request.user.is_staff or request.user.id == c.author_id %}
<div class="ms-auto d-flex gap-2">
<a href="{% url 'feed:comment_update' comment.pk %}" class="btn btn-ghost btn-sm">
<i class="bi-pencil"></i>
</a>
<a href="{% url 'feed:comment_delete' comment.pk %}" class="btn btn-ghost btn-sm text-danger">
<i class="bi-trash"></i>
</a>
{% if cid %}
<a href="{% url 'feed:comment_update' cid %}" class="btn btn-ghost btn-sm">
<i class="bi-pencil"></i>
</a>
<a href="{% url 'feed:comment_delete' cid %}" class="btn btn-ghost btn-sm text-danger">
<i class="bi-trash"></i>
</a>
{% endif %}
</div>
{% endif %}
</header>
<div class="feed-body">
<div class="fs-6">{{ comment.text|linebreaks }}</div>
<div class="fs-6">{{ c.text|linebreaks }}</div>
</div>
</article>
{% endfor %}
@ -175,7 +249,7 @@
</section>
<!-- Форма добавления комментария -->
{% if user.is_authenticated %}
{% if user.is_authenticated and post_id %}
<div class="feed-card mt-3">
<div class="feed-body">
<h6 class="mb-2">Оставить комментарий</h6>

View File

@ -95,7 +95,6 @@
</div>
{% endif %}
<!-- Справа — только профиль, БЕЗ стрелочки -->
<div class="ms-auto d-flex align-items-center">
{% if user.is_authenticated %}
<div class="dropdown">

View File

@ -1,67 +1,100 @@
{% load static req_tags %}
<style>
/* offcanvas без рамок/теней */
/* Offcanvas остаётся как есть (липкий ≥ lg) */
#sidebar{
--bs-offcanvas-border-color: transparent;
--bs-offcanvas-border-width: 0;
--bs-offcanvas-border-radius: 0;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
border: 0 !important; border-radius: 0 !important; box-shadow: none !important;
background: var(--bs-body-bg);
}
@media (min-width: 992px){
#sidebar.offcanvas-lg{
position: sticky;
top: var(--navbar-h, 60px);
position: sticky; top: var(--navbar-h, 60px);
height: calc(100dvh - var(--navbar-h, 60px));
align-self: start;
overflow: auto;
border-right: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
transform: none !important;
visibility: visible !important;
align-self: start; overflow: auto;
border-right: .5px solid var(--bs-border-color); /* тонкая разделительная грань */
transform: none !important; visibility: visible !important;
}
}
/* ——— плоское «соцсетевое» меню без подложек ——— */
/* ====== новый визуал навигации ====== */
.side-section{ margin: .6rem 0 1rem; }
.side-hd{
font-size:.78rem; font-weight:800; letter-spacing:.03em; text-transform:uppercase;
color: var(--bs-secondary-color);
padding: .25rem .5rem; margin: 0 0 .35rem .25rem;
}
.side-nav{ display:flex; flex-direction:column; gap:.15rem; }
.side-nav .nav-link{
display:flex; align-items:center; gap:.6rem;
padding:.45rem .25rem;
color: var(--bs-body-color);
text-decoration: none;
border: 0; border-radius: 0;
background: transparent !important;
box-shadow: inset 0 0 0 0 transparent; /* база для активной «полоски» */
transition: color .15s ease, transform .05s ease, box-shadow .15s ease;
line-height: 1.15;
position:relative; display:flex; align-items:center; gap:.6rem;
padding:.48rem .55rem; border-radius:12px;
color: var(--bs-body-color); text-decoration:none; border:0; background:transparent !important;
line-height:1.15;
transition: background .15s ease, color .15s ease, transform .05s ease, box-shadow .15s ease;
outline: none;
}
.side-nav .nav-link:hover{ color: var(--bs-primary); transform: translateX(1px); }
.side-nav .nav-link.active{
/* мягкий ховер */
.side-nav .nav-link:hover{
color: var(--bs-primary);
font-weight: 700;
/* тонкая внутренняя левая полоска, как у соцсетей */
box-shadow: inset 2px 0 0 var(--bs-primary);
background: color-mix(in srgb, var(--bs-primary) 10%, transparent);
}
/* видимая клавиатурная фокусировка */
.side-nav .nav-link:focus-visible{
box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), .25);
}
/* активная «пилюля» + тонкий индикатор слева */
.side-nav .nav-link.active{
color: var(--bs-primary); font-weight: 800;
background: color-mix(in srgb, var(--bs-primary) 16%, transparent);
}
.side-nav .nav-link.active::before{
content:""; position:absolute; left:-6px; top:8px; bottom:8px; width:3px; border-radius:2px;
background: var(--bs-primary);
}
.side-icon{
width:1.35rem; height:1.35rem; display:inline-grid; place-items:center;
}
.side-icon > i { font-size: 1.15rem; line-height:1; }
.side-icon{ width:1.35rem; height:1.35rem; display:inline-grid; place-items:center; flex:0 0 auto; }
.side-icon > i{ font-size:1.1rem; line-height:1; }
/* бейдж непрочитанных у «Чатов» (без подложек) */
.icon-wrap{ position:relative; display:inline-grid; place-items:center; }
.nav-unread{
position:absolute; top:-4px; right:-8px;
min-width: 16px; height:16px; padding:0 4px;
border-radius:9999px; font-size:10px; line-height:16px; font-weight:700;
background: var(--bs-danger); color:#fff;
border: 1px solid rgba(255,255,255,.6);
min-width:16px; height:16px; padding:0 4px; border-radius:9999px;
font-size:10px; line-height:16px; font-weight:700;
background: var(--bs-danger); color:#fff; border:1px solid rgba(255,255,255,.6);
}
[data-bs-theme="dark"] .nav-unread{ border-color: rgba(0,0,0,.25); }
.icon-wrap{ position:relative; display:inline-grid; place-items:center; }
/* «отключённый» пункт выглядит реально disabled */
.side-nav .nav-link.disabled{ opacity:.5; pointer-events:none; }
</style>
<style>
.btn-theme{
display:inline-flex; align-items:center; gap:.5rem;
min-height:36px; padding:.35rem .75rem;
border-radius:999px;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
border:1px solid var(--bs-border-color);
transition: transform .08s ease, background-color .25s ease, border-color .25s ease;
}
.btn-theme:hover{
transform: translateY(-1px);
background: color-mix(in srgb, var(--bs-tertiary-bg) 90%, var(--bs-primary) 10%);
border-color: color-mix(in srgb, var(--bs-border-color), var(--bs-primary) 30%);
}
.btn-theme:focus-visible{
outline: 0;
box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), .25);
}
.btn-theme .bi{ font-size:1rem; line-height:1; }
.btn-theme .mode-label{ font-weight:700; font-size:.85rem; opacity:.9; }
@media (max-width:576px){
.btn-theme .mode-label{ display:none; }
}
</style>
<div id="sidebar"
@ -75,51 +108,46 @@
</div>
<div class="offcanvas-body d-flex flex-column h-100 px-3 px-lg-2">
<nav class="side-nav nav flex-column gap-1">
<nav class="side-nav">
<a href="{% url 'feed:feed_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'feed_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'feed_list' %}aria-current="page"{% endif %}>
class="nav-link {% if request.resolver_match.url_name == 'feed_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'feed_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-megaphone"></i></span>
<span>Новости</span>
</a>
<a href="{% url 'employees:employees_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'employees_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'employees_list' %}aria-current="page"{% endif %}>
class="nav-link {% if request.resolver_match.url_name == 'employees_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'employees_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-people"></i></span>
<span>Сотрудники</span>
</a>
<a href="{% url 'employees:department_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'department_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'department_list' %}aria-current="page"{% endif %}>
class="nav-link {% if request.resolver_match.url_name == 'department_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'department_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-diagram-3"></i></span>
<span>Отделы</span>
</a>
<a href="{% url 'requests_app:my_requests' %}"
class="nav-link {% if request.resolver_match.url_name == 'my_requests' %}active{% endif %}"
{% if request.resolver_match.url_name == 'my_requests' %}aria-current="page"{% endif %}>
class="nav-link {% if request.resolver_match.url_name == 'my_requests' %}active{% endif %}"
{% if request.resolver_match.url_name == 'my_requests' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-journal-text"></i></span>
<span>Мои заявления</span>
</a>
{% if perms.requests_app.can_view_all_requests or user.headed_departments.exists %}
{% with url_name=request.resolver_match.url_name %}
<a href="{% url 'requests_app:all_requests' %}"
class="nav-link {% if url_name|in_list:'all_requests,request_process' %}active{% endif %}"
{% if url_name|in_list:'all_requests,request_process' %}aria-current="page"{% endif %}>
class="nav-link {% if url_name|in_list:'all_requests,request_process' %}active{% endif %}"
{% if url_name|in_list:'all_requests,request_process' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-clipboard-check"></i></span>
<span>Заявления сотрудников</span>
</a>
{% endwith %}
{% endif %}
{% with total=unread_total|default:0 %}
<a href="{% url 'communications:chat_list' %}"
class="nav-link {% if request.resolver_match.app_name == 'communications' %}active{% endif %}"
{% if request.resolver_match.app_name == 'communications' %}aria-current="page"{% endif %}
aria-label="Чаты{% if total|add:0 > 0 %}, непрочитанных {{ total }}{% endif %}">
class="nav-link {% if request.resolver_match.app_name == 'communications' %}active{% endif %}"
{% if request.resolver_match.app_name == 'communications' %}aria-current="page"{% endif %}
aria-label="Чаты{% if total|add:0 > 0 %}, непрочитанных {{ total }}{% endif %}">
<span class="side-icon icon-wrap">
<i class="bi-chat-dots"></i>
{% if total|add:0 > 0 %}
@ -131,29 +159,28 @@
<span>Чаты</span>
</a>
{% endwith %}
<a href="{% url 'documents:document_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'document_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'document_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-file-earmark-text"></i></span>
<span>Документы</span>
</a>
<a href="#" class="nav-link disabled" tabindex="-1" aria-disabled="true">
<span class="side-icon"><i class="bi-calendar-event"></i></span>
<span>Календарь</span>
</a>
<a href="{% url 'documents:document_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'document_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'document_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-file-earmark-text"></i></span>
<span>Документы</span>
</a>
</nav>
{# низ без разделительной линии #}
<div class="mt-auto pt-3 small">
<div class="d-flex gap-3 align-items-center" aria-label="Тема">
<button class="btn btn-link p-0 text-decoration-none" data-theme-value="light">Светлая</button>
<button class="btn btn-link p-0 text-decoration-none" data-theme-value="auto">Авто</button>
<button class="btn btn-link p-0 text-decoration-none" data-theme-value="dark">Тёмная</button>
</div>
<button type="button" class="btn-theme js-theme-btn" id="themeBtnSidebar"
aria-label="Сменить тему" title="Сменить тему">
<i class="bi bi-circle-half" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<script>
@ -181,3 +208,64 @@
});
})();
</script>
<script>
(function(){
const media = window.matchMedia('(prefers-color-scheme: dark)');
const root = document.documentElement;
const MODES = ['light','auto','dark'];
const ICONS = { light:'bi-sun-fill', auto:'bi-circle-half', dark:'bi-moon-stars' };
const LABEL = { light:'Светлая', auto:'Авто', dark:'Темная' };
// какие-то проекты любят хранить 'theme' в LS — используем тот же ключ
function getSavedMode(){ return localStorage.getItem('theme') || 'auto'; }
function setSavedMode(m){ try{ localStorage.setItem('theme', m); }catch(_){} }
function effective(mode){ return mode === 'auto' ? (media.matches ? 'dark' : 'light') : mode; }
function applyMode(mode, persist=true){
const eff = effective(mode);
root.setAttribute('data-bs-theme', eff);
if (persist) setSavedMode(mode);
updateButtons(mode);
}
function updateButtons(mode){
document.querySelectorAll('.js-theme-btn').forEach(btn=>{
const icon = btn.querySelector('.bi');
const lab = btn.querySelector('.mode-label');
// сброс иконок
if (icon){
icon.className = 'bi ' + (ICONS[mode] || ICONS.auto);
}
if (lab){ lab.textContent = LABEL[mode] || LABEL.auto; }
btn.dataset.mode = mode;
btn.title = `Тема: ${LABEL[mode]}. Нажмите, чтобы переключить`;
btn.setAttribute('aria-label', `Тема: ${LABEL[mode]}. Нажмите, чтобы переключить`);
});
}
function nextMode(cur){
const i = MODES.indexOf(cur); return MODES[(i >= 0 ? i : 0) + 1 === MODES.length ? 0 : i + 1];
}
document.addEventListener('DOMContentLoaded', ()=>{
const start = getSavedMode();
applyMode(start, false);
document.body.addEventListener('click', (e)=>{
const btn = e.target.closest('.js-theme-btn'); if (!btn) return;
e.preventDefault();
const cur = btn.dataset.mode || getSavedMode() || 'auto';
const nxt = nextMode(cur);
applyMode(nxt, true);
});
// если системная тема поменялась и у нас «Авто», нужно обновить
media.addEventListener('change', ()=>{ if ((localStorage.getItem('theme') || 'auto') === 'auto'){ applyMode('auto', false); } });
});
})();
</script>

View File

@ -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)

View File

@ -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):