From 0b8c2a4d4301825e15d7f04573f589bed09bae60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 8 Sep 2025 17:19:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D0=B0=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20API=20/my-departments=20feat(calenda?= =?UTF-8?q?r):=20=D0=BF=D1=80=D0=B0=D0=B2=D1=8B=D0=B9=20=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8C=20=D1=81=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BE=D0=BC=20=D0=B8=D1=81=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=B8=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B9=20(Bearer)=20fix(layout):=20=D0=B2=D1=8B=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BD=D1=8F=D0=BB=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BA=20=D1=86=D0=B5=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=D1=83=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D1=83,=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=82=D1=81=D1=82=D1=83=D0=BF=D1=8B=20fix(templates):=20=D1=83?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BD=D0=B0=20=D0=BD=D0=B5=D1=81=D1=83=D1=89?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B0=D1=8F=20=D1=82?= =?UTF-8?q?=D1=8D=D0=B3-=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0;=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20meta=20api-token=20feat(chat/u?= =?UTF-8?q?i):=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?chat-root,=20=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=C2=AB=D0=B2?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=C2=BB=20style(feed):=20=C2=AB=D0=9F=D0=BE?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B1=D0=BD=D0=B5=D0=B5/=D0=A1=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=C2=BB=20=E2=80=94=20=D1=81=D0=B5=D1=80=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BE=D0=B4=D1=87=D1=91?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлен эндпоинт 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//events/ Форматы: all-day (date) либо с временем (start/end ISO). Не через сессию. • FullCalendar переключает источник событий в зависимости от выбранного календаря и ходит за ними с параметрами ?start=YYYY-MM-DD&end=YYYY-MM-DD. • Вертикальная «На этой неделе» рендерится из тех же данных (без лишних запросов, если события уже в памяти FullCalendar). • Мобильная версия — Offcanvas + тот же функционал. — Авторизация / API • Все запросы к API — через Authorization: Bearer . • В base.html добавлен мета-тег с токеном: • Общий 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": , "name": }, …] • GET /api/v1/calendar/company-events/?start=&end= • GET /api/v1/calendar/departments//events/?start=&end= • POST /api/v1/calendar/company-events/ (body: {title, date|start,end, allDay?, color?}) • POST /api/v1/calendar/departments//events/ (аналогично) • Все ручки принимают Authorization: Bearer . • Если раньше был 405 на POST — включить create() во вьюсетах и добавить в роутер. --- backend/api/templatetags/__init__.py | 0 backend/api/templatetags/api_tags.py | 16 + backend/api/tests.py | 3 - .../api/v1/calendar/{viewsets.py => views.py} | 0 backend/api/v1/employees/serializers.py | 91 +--- backend/api/v1/employees/views.py | 37 +- backend/api/v1/feed/serializers.py | 24 +- backend/api/v1/serializers.py | 82 ++++ backend/api/v1/urls.py | 18 +- backend/employees/views_front.py | 28 +- backend/templates/base.html | 464 +++++++++--------- .../templates/communications/chat_detail.html | 17 +- .../templates/communications/chat_list.html | 4 +- .../employees/department_detail.html | 4 +- .../templates/employees/department_list.html | 4 +- .../templates/employees/employee_detail.html | 19 +- .../templates/employees/employees_list.html | 4 +- backend/templates/feed/_feed_cards.html | 77 +-- backend/templates/feed/feed_list.html | 26 +- backend/templates/feed/post_detail.html | 4 +- .../templates/includes/rightbar_calendar.html | 401 ++++++++++----- backend/templates/includes/sidebar.html | 2 +- .../templates/requests_app/all_requests.html | 4 +- .../templates/requests_app/my_requests.html | 4 +- .../requests_app/request_detail.html | 4 +- .../requests_app/request_process.html | 4 +- 26 files changed, 796 insertions(+), 545 deletions(-) create mode 100644 backend/api/templatetags/__init__.py create mode 100644 backend/api/templatetags/api_tags.py delete mode 100644 backend/api/tests.py rename backend/api/v1/calendar/{viewsets.py => views.py} (100%) create mode 100644 backend/api/v1/serializers.py diff --git a/backend/api/templatetags/__init__.py b/backend/api/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/templatetags/api_tags.py b/backend/api/templatetags/api_tags.py new file mode 100644 index 00000000..1dcdc9b5 --- /dev/null +++ b/backend/api/templatetags/api_tags.py @@ -0,0 +1,16 @@ +# backend/api/templatetags/api_tags.py +from django import template +from api.client import SESSION_KEY_ACCESS # 'api.jwt_access' + +register = template.Library() + +@register.simple_tag +def api_access_token(request): + """ + Возвращает access-токен из сессии (если есть) для использования на фронте. + Безопасность: это тот же токен, которым ты и так ходишь на API. + """ + try: + return request.session.get(SESSION_KEY_ACCESS, "") or "" + except Exception: + return "" diff --git a/backend/api/tests.py b/backend/api/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/api/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/api/v1/calendar/viewsets.py b/backend/api/v1/calendar/views.py similarity index 100% rename from backend/api/v1/calendar/viewsets.py rename to backend/api/v1/calendar/views.py diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 307358d7..db2dd96e 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -1,103 +1,24 @@ -import base64 -import binascii from collections import defaultdict -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 employees.constants import ACTION_CHOICES from employees.models import ( Department, + DepartmentPermission, DepartmentRole, EmployeeAction, EmployeeDepartment, Position, Skill, - DepartmentPermission, ) - -from PIL import Image from rest_framework import serializers +from ..serializers import Base64ImageField + Employee = get_user_model() -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}" - - class EmailSerializer(serializers.Serializer): email = serializers.EmailField() @@ -701,3 +622,9 @@ class RemoveMemberInput(serializers.Serializer): ) attrs["employee_id"] = emp return attrs + + +class DepartmentBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Department + fields = ("id", "name") \ No newline at end of file diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index a68ea6b4..2c668587 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -38,7 +38,7 @@ from employees.models import ( Skill, ) from phonenumbers import PhoneNumberFormat -from rest_framework import filters, status, viewsets +from rest_framework import filters, generics, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import ( AllowAny, @@ -48,6 +48,7 @@ from rest_framework.permissions import ( ) from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework_simplejwt.authentication import JWTAuthentication from ..permissions import ( ASSIGN_ROLE_PERM, @@ -60,6 +61,7 @@ from ..permissions import ( ) from .serializers import ( AddMemberInput, + DepartmentBriefSerializer, DepartmentRoleSerializer, DepartmentSerializer, EmailSerializer, @@ -927,6 +929,39 @@ class DepartmentViewSet(viewsets.ModelViewSet): status=status.HTTP_200_OK, ) + @action(detail=False, methods=["get"], url_path="my-departments") + def my_departments(self, request) -> Response: + """Вернёт список отделов, доступных текущему пользователю. + + Логика: + Только отделы, где пользователь является руководителем + или имеет активную связь в ``EmployeeDepartment`` (is_active=True). + + Args: + request: Текущий HTTP-запрос DRF. + + Returns: + Response: JSON-массив отделов в формате ``DepartmentSerializer`` (many=True), + отсортированный по названию и id. + + Raises: + NotAuthenticated: Если пользователь не авторизован (обрабатывается классом + разрешений в ``get_permissions``). + """ + user = request.user + qs = self.get_queryset() + + active_link_exists = EmployeeDepartment.objects.filter( + department_id=OuterRef("pk"), + employee_id=user.id, + is_active=True, + ) + user_qs = qs.filter(Q(head_id=user.id) | Exists(active_link_exists)).distinct() + + user_qs = user_qs.order_by("name", "id") + data = DepartmentBriefSerializer(user_qs, many=True).data + return Response(data) + class EmployeeViewSet(viewsets.ModelViewSet): """ diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py index 9416997c..87bb3a7b 100644 --- a/backend/api/v1/feed/serializers.py +++ b/backend/api/v1/feed/serializers.py @@ -1,22 +1,25 @@ # 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_url = serializers.SerializerMethodField() + avatar = Base64ImageField(read_only=True) class Meta: model = Employee - fields = ["id", "first_name", "last_name", "full_name", "avatar_url"] + fields = ["id", "first_name", "last_name", "full_name", "avatar"] def get_full_name(self, obj): fn = (obj.first_name or "").strip() @@ -24,19 +27,6 @@ class AuthorMiniSerializer(serializers.ModelSerializer): 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) @@ -70,8 +60,8 @@ class PostListSerializer(serializers.ModelSerializer): fields = [ "id", "type", - "department", # write/read PK - "department_id", # read-only alias + "department", + "department_id", "title", "body", "image", diff --git a/backend/api/v1/serializers.py b/backend/api/v1/serializers.py new file mode 100644 index 00000000..d31b81a1 --- /dev/null +++ b/backend/api/v1/serializers.py @@ -0,0 +1,82 @@ +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}" diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 9c1ccf51..1e11a954 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -1,18 +1,20 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .calendar.viewsets import CompanyEventsViewSet, DepartmentEventsViewSet +from .calendar.views import CompanyEventsViewSet, DepartmentEventsViewSet from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet, EmployeeActionViewSet, EmployeeViewSet, - PositionViewSet, RegisterAPIView, - ResendEmailAPIView, SkillViewSet, - VerifyEmailAPIView, GroupViewSet) + GroupViewSet, PositionViewSet, + RegisterAPIView, ResendEmailAPIView, + SkillViewSet, VerifyEmailAPIView) from .feed.views import CommentViewSet, PostViewSet app_name = "v1" router = DefaultRouter() -router.register(r"calendar/company-events", CompanyEventsViewSet, basename="company-events") +router.register( + r"calendar/company-events", CompanyEventsViewSet, basename="company-events" +) router.register(r"departments", DepartmentViewSet, basename="departments") router.register(r"employees", EmployeeViewSet, basename="employees") router.register(r"employee-actions", EmployeeActionViewSet, basename="employee-actions") @@ -27,6 +29,10 @@ urlpatterns = [ path("auth/register/", RegisterAPIView.as_view(), name="register"), path("auth/resend-email/", ResendEmailAPIView.as_view(), name="resend-email"), path("auth/verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"), - path("calendar/departments//events/", DepartmentEventsViewSet.as_view({"get": "list"}), name="department-events",), + path( + "calendar/departments//events/", + DepartmentEventsViewSet.as_view({"get": "list"}), + name="department-events", + ), path("", include(router.urls)), ] diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 8a92e6aa..022bd788 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -79,6 +79,32 @@ def _fetch_employee_posts(api, employee_id: int): return _extract_results(data) +def _fetch_department_posts(api, department_id: int) -> list[dict]: + """Возвращает публикации отдела (type=department), отсортированные по пину и дате. + + Args: + api: Инициализированный API-клиент (get_api_client(request)). + department_id (int): ID отдела. + + Returns: + list[dict]: Список публикаций для шаблона. + + Raises: + RuntimeError: Если ответ API не JSON (редко, мы перехватываем безопасно). + ValueError: Если department_id некорректный (не int). + """ + # Пробуем явный фильтр по типу и отделу; fallback — только department + for url in ( + f"v1/posts/?type=department&department={int(department_id)}&ordering=-pinned,-created_at", + f"v1/posts/?department={int(department_id)}&ordering=-pinned,-created_at", + ): + r = api.get(url) + if r.ok: + data = r.json or [] + return _extract_results(data) + return [] + + def _json_body(request: HttpRequest) -> Dict[str, Any]: """Безопасно парсим JSON; если не JSON — возвращаем пустой dict.""" try: @@ -106,7 +132,6 @@ def _api_ok_or_error(resp) -> Tuple[bool, Dict[str, Any], int]: return resp.ok, data, getattr(resp, "status", 500) - # ========================= # Сотрудники # ========================= @@ -536,6 +561,7 @@ def department_detail(request, pk: int): "can_manage": bool(perms.get("can_manage")), "can_change_head": bool(perms.get("can_change_head")), "can_assign_roles": bool(perms.get("can_assign_roles")), + "posts": _fetch_department_posts(api, pk) } ) return render(request, "employees/department_detail.html", ctx) diff --git a/backend/templates/base.html b/backend/templates/base.html index a7cfc7c0..8d60d516 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -1,4 +1,4 @@ -{% load static %} +{% load static api_tags %} @@ -7,6 +7,10 @@ + {% if user.is_authenticated %} + + {% endif %} + {% block title %}EUSRR{% endblock %} @@ -19,8 +23,8 @@ + /* Акцентированный фокус для клавиатуры */ + .btn-icon:focus-visible{ + outline: 2px solid color-mix(in srgb, currentColor 60%, transparent); + outline-offset: 2px; + } + .container-xxl { max-width: 1820px; padding-inline: var(--bs-gutter-x,.75rem); } + {% block extra_css %}{% endblock %} @@ -446,18 +454,20 @@ {% include "includes/navbar.html" %} -
- {% if user.is_authenticated %} - {% include "includes/sidebar.html" %} - {% endif %} +
+
+ {% if user.is_authenticated %} + {% include "includes/sidebar.html" %} + {% endif %} -
- {% block content %}{% endblock %} -
+
+ {% block content %}{% endblock %} +
- {% if user.is_authenticated %} - {% include "includes/rightbar_calendar.html" %} - {% endif %} + {% if user.is_authenticated %} + {% include "includes/rightbar_calendar.html" %} + {% endif %} +
diff --git a/backend/templates/communications/chat_detail.html b/backend/templates/communications/chat_detail.html index f1663fa7..5ad8f8f9 100644 --- a/backend/templates/communications/chat_detail.html +++ b/backend/templates/communications/chat_detail.html @@ -8,21 +8,12 @@ main.main{ padding-bottom:0 !important; } /* ===== Контейнер страницы чата ===== */ - .chat-root{ - --chat-top-gap: 20px; - --chat-bottom-gap: 48px; - height: calc(100dvh - var(--navbar-h)); - padding: var(--chat-top-gap) 0 calc(var(--chat-bottom-gap) + env(safe-area-inset-bottom)); - box-sizing: border-box; - overflow: hidden; - display: flex; flex-direction: column; - background: var(--bs-body-bg); - } + @supports(height: 100svh){ .chat-root{ height: calc(100svh - var(--navbar-h)); } } .chat-root > .row{ height:100%; min-height:0; } - .chat-col{ display:flex; flex-direction:column; height:100%; min-height:0; } + .chat-col{ display:flex; flex-direction:column; height:93%; min-height:0; } .feed-header{ flex:0 0 auto; } /* ===== Карточка чата ===== */ @@ -129,8 +120,8 @@ {% block content %}
-
-
+
+
diff --git a/backend/templates/communications/chat_list.html b/backend/templates/communications/chat_list.html index 3e80fe66..f646450c 100644 --- a/backend/templates/communications/chat_list.html +++ b/backend/templates/communications/chat_list.html @@ -40,8 +40,8 @@ {% block content %}
-
-
+
+
{# Заголовок + ПОИСК (iOS-стиль) #}
diff --git a/backend/templates/employees/department_detail.html b/backend/templates/employees/department_detail.html index 8b04d2fb..64be2a33 100644 --- a/backend/templates/employees/department_detail.html +++ b/backend/templates/employees/department_detail.html @@ -224,9 +224,9 @@
Публикации отдела
-
+
{% if posts %} - {% include "feed/_feed_cards.html" with items=posts only %} + {% include "feed/_feed_cards.html" with items=posts %} {% else %}
Публикаций пока нет.
{% endif %} diff --git a/backend/templates/employees/department_list.html b/backend/templates/employees/department_list.html index ac0d50b2..9e70a3fe 100644 --- a/backend/templates/employees/department_list.html +++ b/backend/templates/employees/department_list.html @@ -23,8 +23,8 @@ {% block content %}
-
-
+
+
diff --git a/backend/templates/employees/employee_detail.html b/backend/templates/employees/employee_detail.html index 21860c12..fb0a0a37 100644 --- a/backend/templates/employees/employee_detail.html +++ b/backend/templates/employees/employee_detail.html @@ -406,16 +406,21 @@
{% endif %} -
-
-
Активность
+
+
+ +
+
Активность
+
+
+
{% if posts %} -
- {% include "feed/_feed_cards.html" with items=posts only %} -
+ {% include "feed/_feed_cards.html" with items=posts %} + {% else %} +
Публикаций пока нет.
{% endif %}
-
+
diff --git a/backend/templates/employees/employees_list.html b/backend/templates/employees/employees_list.html index 0b613eb8..259fe1ec 100644 --- a/backend/templates/employees/employees_list.html +++ b/backend/templates/employees/employees_list.html @@ -109,8 +109,8 @@ {% block content %}
-
-
+
+
diff --git a/backend/templates/feed/_feed_cards.html b/backend/templates/feed/_feed_cards.html index 8e73eec6..fdbe066a 100644 --- a/backend/templates/feed/_feed_cards.html +++ b/backend/templates/feed/_feed_cards.html @@ -1,13 +1,13 @@ {% for post in items %} {% firstof post.id post.pk as post_id %} -
+
- + {# --- Аватар автора --- #}
{% with a=post.author %} {% if a %} - {% firstof a.avatar.url a.avatar_url as ava_url %} + {% firstof a.avatar as ava_url %} {% if ava_url %} {% else %} @@ -20,6 +20,7 @@
{# --- Метаданные (имя автора, дата, отдел) --- #} +
{% with a=post.author %} @@ -46,7 +47,7 @@ {% endif %} {% endwith %}
- +
{% if post.created_at %} {{ post.created_at_display }} @@ -63,8 +64,16 @@ {% endif %}
- -
+ {% if post.image %} + {% firstof post.image.url post.image as img_url %} + {% if img_url %} +
+ {% if post_id %}{% endif %} + {{ post.title|default:'Публикация' }} +
+ {% endif %} + {% endif %} + + +
{% else %}
{{ body|linebreaksbr }} @@ -106,23 +125,11 @@ {% endif %} {% endwith %}
- - - {% if post.image %} - {% firstof post.image.url post.image as img_url %} - {% if img_url %} -
- {% if post_id %}{% endif %} - {{ post.title|default:'Публикация' }} -
- {% endif %} - {% endif %} - -
+
+ +
+ События + +
+
@@ -180,12 +163,8 @@ -
+
Календарь
@@ -201,24 +180,84 @@
+{# Модал создания события #} + + \ No newline at end of file + diff --git a/backend/templates/includes/sidebar.html b/backend/templates/includes/sidebar.html index 6203d0cd..9c273f39 100644 --- a/backend/templates/includes/sidebar.html +++ b/backend/templates/includes/sidebar.html @@ -107,7 +107,7 @@
-