Ключевые изменения
- **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:
parent
2fd3b1e562
commit
c78d1e5fea
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
# backend\employees\constants.py
|
||||
GENDER_CHOICES = (
|
||||
(1, 'Мужской'),
|
||||
(2, 'Женский'),
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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) ----------
|
||||
|
||||
@ -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)
|
||||
|
||||
42
backend/feed/forms_front.py
Normal file
42
backend/feed/forms_front.py
Normal 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
|
||||
19
backend/feed/urls_front.py
Normal file
19
backend/feed/urls_front.py
Normal 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
611
backend/feed/views_front.py
Normal 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)
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
|
||||
@ -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 %}
|
||||
Тут можно подставить реальные варианты, если передать их из вьюхи.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -95,7 +95,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Справа — только профиль, БЕЗ стрелочки -->
|
||||
<div class="ms-auto d-flex align-items-center">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="dropdown">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user