feat(calendar): правый календарь с выбором источника и созданием событий (Bearer)
fix(layout): выровнял колонки к центральному контейнеру, убрал лишние отступы
fix(templates): убрана несуществующая тэг-функция токена; добавлен meta api-token
feat(chat/ui): полировка chat-root, скролла и кнопки «вниз»
style(feed): «Подробнее/Скрыть» — серые без подчёркивания
- добавлен эндпоинт DepartmentViewSet.my_departments для получения доступных отделов
(HR → все отделы, иначе только руководимые/активные)
- настроены permissions для нового экшена
- фронтенд: заменена логика дропдауна календаря
* загрузка списка отделов через /api/v1/departments/my-departments/
* автогенерация пунктов меню с именами отделов
* fallback на data-dept-id при пустом ответе
- обновлена подпись текущего календаря (кнопка и модалка «Создать событие»)
- сохранена логика работы с событиями (компания/отделы), переработан только источник данных для списка отделов
— Календарь (правая колонка)
• Добавлен дропдаун возле «Календарь» для выбора источника: «Компания» и «Мои отделы».
• «Мои отделы» подгружаются из GET /api/v1/employees/my-departments/ (JSON: [{id, name}]).
• Рядом с «События» добавлена кнопка «+», открывающая модал создания события.
• Создание события уходит в POST:
- /api/v1/calendar/company-events/
- /api/v1/calendar/departments/<id>/events/
Форматы: all-day (date) либо с временем (start/end ISO). Не через сессию.
• FullCalendar переключает источник событий в зависимости от выбранного календаря и
ходит за ними с параметрами ?start=YYYY-MM-DD&end=YYYY-MM-DD.
• Вертикальная «На этой неделе» рендерится из тех же данных (без лишних запросов,
если события уже в памяти FullCalendar).
• Мобильная версия — Offcanvas + тот же функционал.
— Авторизация / API
• Все запросы к API — через Authorization: Bearer <token>.
• В base.html добавлен мета-тег с токеном:
<meta name="api-token" content="{{ request.user.api_token|default:'' }}">
• Общий helper authFetch() отключает cookie/сессию и всегда проставляет заголовки.
• Это устраняет 401 при GET и готовит почву под POST (ранее наблюдался 405 без POST-ручек).
— Вёрстка / контейнер
• Левую и правую колонки «привязал» к центральному блоку (container-xxl), убраны
чрезмерные внутренние паддинги, чтобы центральный контент не сжимался.
• Уточнены вертикальные расстояния между карточками (gap / margin), карточки теперь
реально «прижимаются» по вертикали, где это нужно.
• В main контент тянется на всю доступную ширину внутри контейнера.
— Чаты
• Объяснён и оставлен padding у .chat-root (верх/низ под шапку и композер, включая safe-area).
• Кнопка «вниз» и стили скролла — без конфликтов с обёртками.
— Шаблоны
• Удалён несуществующий тэг `{% api_access_token %}` (исправляет TemplateSyntaxError).
• Вставлен рабочий meta name="api-token" вместо кастомного тега.
— Кнопки «Подробнее/Скрыть»
• Сделаны серыми и без подчёркивания (btn btn-link text-secondary text-decoration-none).
Затронутые файлы (основные):
• backend/templates/base.html
• backend/templates/includes/rightbar_calendar.html (новый/полностью переписан)
• backend/templates/feed/_feed_cards.html (кнопки «Подробнее/Скрыть»)
• backend/templates/includes/sidebar.html (мелкие правки отступов)
• стили в базовом шаблоне: правки контейнеров/отступов/гридов
• (JS в rightbar_calendar): authFetch, загрузка отделов, POST создания события, weekly strip
Примечания по бэку (ожидания фронта):
• GET /api/v1/employees/my-departments/ → 200 OK, JSON: [{ "id": <int>, "name": <str> }, …]
• GET /api/v1/calendar/company-events/?start=&end=
• GET /api/v1/calendar/departments/<id>/events/?start=&end=
• POST /api/v1/calendar/company-events/ (body: {title, date|start,end, allDay?, color?})
• POST /api/v1/calendar/departments/<id>/events/ (аналогично)
• Все ручки принимают Authorization: Bearer <JWT/токен>.
• Если раньше был 405 на POST — включить create() во вьюсетах и добавить в роутер.
175 lines
5.7 KiB
Python
175 lines
5.7 KiB
Python
# backend/api/v1/feed/serializers.py
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, Optional
|
||
|
||
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
|
||
from ..serializers import Base64ImageField
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
class AuthorMiniSerializer(serializers.ModelSerializer):
|
||
full_name = serializers.SerializerMethodField()
|
||
avatar = Base64ImageField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = ["id", "first_name", "last_name", "full_name", "avatar"]
|
||
|
||
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
|
||
|
||
|
||
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",
|
||
"department_id",
|
||
"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
|