- **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
185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
# backend/api/v1/feed/serializers.py
|
||
from __future__ import annotations
|
||
|
||
from django.contrib.auth import get_user_model
|
||
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()
|
||
|
||
|
||
class AuthorMiniSerializer(serializers.ModelSerializer):
|
||
full_name = serializers.SerializerMethodField()
|
||
avatar_url = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = ["id", "first_name", "last_name", "full_name", "avatar_url"]
|
||
|
||
def get_full_name(self, obj):
|
||
fn = (obj.first_name or "").strip()
|
||
ln = (obj.last_name or "").strip()
|
||
nm = f"{fn} {ln}".strip()
|
||
return nm
|
||
|
||
def get_avatar_url(self, obj):
|
||
# пробуем несколько распространённых имён поля/метода
|
||
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:
|
||
pass
|
||
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):
|
||
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", # 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
|
||
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
|
||
if t == TYPE_DEPARTMENT and not dept:
|
||
raise serializers.ValidationError(
|
||
{"department": "Нужно указать отдел для новостей отдела."}
|
||
)
|
||
if t != TYPE_DEPARTMENT and dept is not None:
|
||
raise serializers.ValidationError(
|
||
{"department": "Для этого типа публикаций отдел указывать нельзя."}
|
||
)
|
||
return attrs
|
||
|
||
|
||
class CommentSerializer(serializers.ModelSerializer):
|
||
post_id = serializers.IntegerField(read_only=True)
|
||
author_id = serializers.IntegerField(read_only=True)
|
||
author = AuthorMiniSerializer(read_only=True)
|
||
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",
|
||
"post_id",
|
||
"author_id",
|
||
"author",
|
||
"text",
|
||
"image",
|
||
"attachment",
|
||
"created_at",
|
||
"created_at_display",
|
||
]
|
||
extra_kwargs = {
|
||
"post": {"write_only": True},
|
||
"text": {"required": False, "allow_blank": True},
|
||
}
|
||
|
||
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": "Нужно указать текст, изображение или файл."}
|
||
)
|
||
return attrs
|
||
|
||
def create(self, validated_data):
|
||
# author выставляется во view
|
||
instance = Comment(**validated_data)
|
||
instance.save(skip_validation=True)
|
||
return instance
|
||
|
||
def update(self, instance, validated_data):
|
||
for k, v in validated_data.items():
|
||
setattr(instance, k, v)
|
||
instance.save(skip_validation=True)
|
||
return instance
|