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() во вьюсетах и добавить в роутер.
83 lines
2.8 KiB
Python
83 lines
2.8 KiB
Python
import base64
|
||
import binascii
|
||
from io import BytesIO
|
||
|
||
from django.core.files.base import ContentFile
|
||
from PIL import Image
|
||
from rest_framework import serializers
|
||
|
||
|
||
class Base64ImageField(serializers.ImageField):
|
||
"""
|
||
Двухстороннее поле:
|
||
- input: принимает строку base64 или data URI (data:image/...;base64,xxxx)
|
||
- output: возвращает data URI (data:image/...;base64,xxxx)
|
||
Совместимо с Python 3.13 (без imghdr).
|
||
"""
|
||
|
||
default_ext = "jpg"
|
||
|
||
def _detect_ext_mime(self, content: bytes) -> tuple[str, str]:
|
||
"""Определяем расширение и MIME через Pillow."""
|
||
try:
|
||
im = Image.open(BytesIO(content))
|
||
fmt = (im.format or "").upper()
|
||
ext_map = {
|
||
"JPEG": "jpg",
|
||
"JPG": "jpg",
|
||
"PNG": "png",
|
||
"WEBP": "webp",
|
||
"GIF": "gif",
|
||
"BMP": "bmp",
|
||
"TIFF": "tiff",
|
||
"ICO": "ico",
|
||
}
|
||
ext = ext_map.get(fmt, self.default_ext)
|
||
mime = Image.MIME.get(fmt, "image/jpeg")
|
||
return ext, mime
|
||
except Exception:
|
||
return self.default_ext, "image/jpeg"
|
||
|
||
def to_internal_value(self, data):
|
||
# Принимаем None/пустое
|
||
if data in (None, "", b""):
|
||
return super().to_internal_value(data)
|
||
|
||
if isinstance(data, str):
|
||
# Срезаем префикс data:image/...;base64, если есть
|
||
if data.startswith("data:image"):
|
||
try:
|
||
_, data = data.split(";base64,", 1)
|
||
except ValueError:
|
||
raise serializers.ValidationError("Некорректный формат data URI.")
|
||
try:
|
||
decoded = base64.b64decode(data)
|
||
except (TypeError, ValueError, binascii.Error):
|
||
raise serializers.ValidationError("Невалидный base64.")
|
||
|
||
ext, _ = self._detect_ext_mime(decoded)
|
||
file = ContentFile(decoded, name=f"upload.{ext}")
|
||
return super().to_internal_value(file)
|
||
|
||
return super().to_internal_value(data)
|
||
|
||
def to_representation(self, value):
|
||
if not value:
|
||
return ""
|
||
# Получаем байты
|
||
try:
|
||
file_obj = getattr(value, "file", None) or value
|
||
content = file_obj.read()
|
||
if hasattr(file_obj, "seek"):
|
||
file_obj.seek(0)
|
||
except Exception:
|
||
try:
|
||
with open(value.path, "rb") as f:
|
||
content = f.read()
|
||
except Exception:
|
||
return ""
|
||
|
||
b64 = base64.b64encode(content).decode("ascii")
|
||
_, mime = self._detect_ext_mime(content)
|
||
return f"data:{mime};base64,{b64}"
|