diff --git a/backend/api.zip b/backend/api.zip new file mode 100644 index 00000000..98db1c9e Binary files /dev/null and b/backend/api.zip differ 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 @@
-