From 94627882201fd6e039185329cf8661723d87fa53 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 19 Aug 2026 15:23:05 +0300 Subject: [PATCH 1/4] Expand search indexing and visibility metadata --- backend/api/v1/search/serializers.py | 17 +- backend/api/v1/search/views.py | 225 +++++++++- backend/search/search_indexes.py | 44 ++ backend/tests/api/v1/test_search.py | 85 ++++ frontend/src/app/search/page.tsx | 402 +++++++++++------- frontend/src/components/BigCalendar.tsx | 6 +- .../src/components/calendar/CalendarCard.tsx | 6 +- frontend/src/contexts/CalendarContext.tsx | 12 +- frontend/src/lib/api/client.ts | 4 +- frontend/src/types/api.ts | 9 +- 10 files changed, 622 insertions(+), 188 deletions(-) diff --git a/backend/api/v1/search/serializers.py b/backend/api/v1/search/serializers.py index 47fee2e6..06a6d0db 100644 --- a/backend/api/v1/search/serializers.py +++ b/backend/api/v1/search/serializers.py @@ -17,13 +17,21 @@ class SearchResultSerializer(serializers.Serializer): "chat, message, event" ) ) - object_id = serializers.IntegerField(help_text="ID объекта") + object_id = serializers.JSONField( + help_text="ID объекта (число или UUID)" + ) title = serializers.CharField( help_text="Заголовок/имя объекта", allow_blank=True ) description = serializers.CharField( help_text="Описание/превью контента", allow_blank=True, required=False ) + image = serializers.CharField( + help_text="Изображение или аватар результата", + allow_blank=True, + required=False, + allow_null=True, + ) meta = serializers.DictField( help_text="Дополнительные метаданные", required=False, allow_null=True ) @@ -38,4 +46,11 @@ class SearchResponseSerializer(serializers.Serializer): help_text="Количество результатов по типам", child=serializers.IntegerField(), ) + hidden_counts = serializers.DictField( + help_text="Количество недоступных пользователю результатов по типам", + child=serializers.IntegerField(), + ) total = serializers.IntegerField(help_text="Общее количество результатов") + hidden_total = serializers.IntegerField( + help_text="Общее количество недоступных пользователю результатов" + ) diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index 4424c406..7450d9b7 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -7,23 +7,38 @@ from __future__ import annotations from typing import Any, Dict +from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request as DRFRequest from rest_framework.response import Response -from django.utils.html import escape from watson.search import search as watson_search from api.v1.documents.permissions import user_can_view_document from api.v1.procurement.permissions import user_can_view_procurement_request from communications.utils import user_can_access_chat from scheduling.services import user_can_view_event +from tasks.access import user_can_access_task_board from .serializers import SearchResponseSerializer +def _file_url(value: Any) -> str: + """Безопасно возвращает относительный URL файла, если он существует.""" + if not value: + return "" + try: + return value.url + except (AttributeError, ValueError): + return "" + + +def _user_avatar(value: Any) -> str: + return _file_url(getattr(value, "avatar", None)) if value else "" + + def _get_model_name(obj: Any) -> str: """Определяет тип модели для результата поиска.""" model = type(obj) @@ -42,11 +57,63 @@ def _get_model_name(obj: Any) -> str: "equipment": "equipment", "document": "document", "notification": "notification", + "taskboard": "task_board", + "task": "task", + "product": "product", + "guest": "guest", } return mapping.get(model_name, model_name) +def _search_queryset_for_model(model: Any): + """Подгружает связи, нужные форматтерам и проверкам доступа.""" + queryset = model._default_manager.all() + model_name = model.__name__.lower() + select_related = { + "employee": ("position",), + "post": ("author",), + "request": ("employee",), + "message": ("author", "chat"), + "procurementrequest": ("requestor", "department"), + "document": ("uploaded_by",), + "task": ("board", "assignee"), + }.get(model_name, ()) + if select_related: + queryset = queryset.select_related(*select_related) + if model_name == "product": + queryset = queryset.prefetch_related("images") + return queryset + + +def _materialize_search_objects(search_results: Any) -> list[Any]: + """Загружает полиморфные результаты пакетами, сохраняя порядок Watson.""" + entries = list(search_results.values_list("content_type_id", "object_id")) + content_types = ContentType.objects.in_bulk({entry[0] for entry in entries}) + object_maps: Dict[int, Dict[str, Any]] = {} + + for content_type_id, content_type in content_types.items(): + model = content_type.model_class() + if model is None: + object_maps[content_type_id] = {} + continue + ids = [ + object_id + for entry_type_id, object_id in entries + if entry_type_id == content_type_id + ] + objects = _search_queryset_for_model(model).in_bulk(ids) + object_maps[content_type_id] = { + str(object_id): objects.get(model._meta.pk.to_python(object_id)) + for object_id in ids + } + + return [ + object_maps.get(content_type_id, {}).get(str(object_id)) + for content_type_id, object_id in entries + ] + + def _format_employee(obj: Any) -> Dict[str, Any]: """Форматирует сотрудника для API.""" return { @@ -58,6 +125,7 @@ def _format_employee(obj: Any) -> Dict[str, Any]: "description": getattr(obj.position, "name", "") if hasattr(obj, "position") else "", + "image": _user_avatar(obj), "meta": { "email": obj.email, "phone": str(obj.phone_number) if obj.phone_number else "", @@ -87,6 +155,7 @@ def _format_post(obj: Any) -> Dict[str, Any]: "description": obj.body[:200] + "..." if len(obj.body) > 200 else obj.body, + "image": _user_avatar(getattr(obj, "author", None)), "meta": { "author": str(obj.author) if hasattr(obj, "author") else None, "created_at": obj.created_at.isoformat() @@ -105,6 +174,7 @@ def _format_request(obj: Any) -> Dict[str, Any]: "description": obj.comment[:200] + "..." if obj.comment and len(obj.comment) > 200 else (obj.comment or ""), + "image": _user_avatar(getattr(obj, "employee", None)), "meta": { "status": obj.status, "type": obj.type, @@ -120,6 +190,7 @@ def _format_chat(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.name or f"Чат #{obj.pk}", "description": obj.description or "", + "image": _file_url(getattr(obj, "avatar", None)), "meta": { "type": obj.type if hasattr(obj, "type") else None, }, @@ -137,6 +208,7 @@ def _format_message(obj: Any) -> Dict[str, Any]: "description": obj.content[:200] + "..." if len(obj.content) > 200 else obj.content, + "image": _user_avatar(getattr(obj, "author", None)), "meta": { "author": str(obj.author) if hasattr(obj, "author") else None, "chat": str(obj.chat) if hasattr(obj, "chat") else None, @@ -185,6 +257,7 @@ def _format_procurement_request(obj: Any) -> Dict[str, Any]: "description": obj.description[:200] + "..." if len(obj.description) > 200 else obj.description, + "image": _user_avatar(getattr(obj, "requestor", None)), "meta": { "status": obj.status if hasattr(obj, "status") else None, "requestor": str(obj.requestor) @@ -227,6 +300,7 @@ def _format_document(obj: Any) -> Dict[str, Any]: if obj.description and len(obj.description) > 200 else (obj.description or "") ), + "image": _user_avatar(getattr(obj, "uploaded_by", None)), "meta": { "uploaded_by": str(obj.uploaded_by) if hasattr(obj, "uploaded_by") and obj.uploaded_by @@ -262,6 +336,7 @@ def _format_notification(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": _get_notification_search_title(obj), "description": message[:150] + "..." if len(message) > 150 else message, + "image": _user_avatar(getattr(obj, "actor", None)), "meta": { "is_read": not getattr(obj, "unread", True), "created_at": obj.timestamp.isoformat() @@ -271,6 +346,73 @@ def _format_notification(obj: Any) -> Dict[str, Any]: } +def _format_task_board(obj: Any) -> Dict[str, Any]: + return { + "model_name": "task_board", + "object_id": obj.pk, + "title": obj.name, + "description": obj.description or "", + "image": _file_url(getattr(obj, "avatar", None)), + "meta": { + "access": obj.get_access_scope_display(), + }, + } + + +def _format_task(obj: Any) -> Dict[str, Any]: + return { + "model_name": "task", + "object_id": obj.pk, + "title": obj.title, + "description": obj.description[:200] + "..." + if len(obj.description) > 200 + else obj.description, + "image": _user_avatar(getattr(obj, "assignee", None)), + "meta": { + "board": str(obj.board), + "board_id": obj.board_id, + "priority": obj.get_priority_display(), + }, + } + + +def _format_product(obj: Any) -> Dict[str, Any]: + images = list(obj.images.all()) + image = next((item for item in images if item.is_primary), None) + image = image or (images[0] if images else None) + return { + "model_name": "product", + "object_id": str(obj.pk), + "title": obj.name, + "description": obj.description[:200] + "..." + if len(obj.description) > 200 + else obj.description, + "image": _file_url(getattr(image, "thumbnail", None)) + or _file_url(getattr(image, "file", None)), + "meta": { + "sku": obj.sku, + "barcode": obj.barcode, + "kind": obj.get_kind_display(), + }, + } + + +def _format_guest(obj: Any) -> Dict[str, Any]: + return { + "model_name": "guest", + "object_id": obj.pk, + "title": obj.full_name, + "description": " · ".join( + value for value in [obj.organization, obj.position] if value + ), + "image": _file_url(getattr(obj, "avatar", None)), + "meta": { + "email": obj.email, + "phone": obj.phone, + }, + } + + def _format_result(obj: Any, model_name: str) -> Dict[str, Any]: """Форматирует результат поиска в зависимости от типа модели.""" formatters = { @@ -286,6 +428,10 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]: "equipment": _format_equipment, "document": _format_document, "notification": _format_notification, + "task_board": _format_task_board, + "task": _format_task, + "product": _format_product, + "guest": _format_guest, } formatter = formatters.get(model_name) @@ -298,6 +444,7 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]: "object_id": obj.pk, "title": str(obj), "description": "", + "image": "", "meta": {}, } @@ -309,24 +456,51 @@ def _is_hr(user: Any) -> bool: ) -def _can_view_search_result(user: Any, obj: Any, model_name: str) -> bool: +def _can_view_search_result( + user: Any, + obj: Any, + model_name: str, + access_cache: Dict[tuple[str, int], bool] | None = None, +) -> bool: """Не позволить глобальному индексу обходить права доменных API.""" if model_name == "request": return _is_hr(user) or getattr(obj, "employee_id", None) == user.id if model_name == "notification": return getattr(obj, "recipient_id", None) == user.id if model_name == "chat": - return user_can_access_chat(obj, user) + cache_key = ("chat", obj.pk) + if access_cache is not None and cache_key in access_cache: + return access_cache[cache_key] + allowed = user_can_access_chat(obj, user) + if access_cache is not None: + access_cache[cache_key] = allowed + return allowed if model_name == "message": - return not getattr(obj, "is_deleted", False) and user_can_access_chat( - getattr(obj, "chat", None), user - ) + if getattr(obj, "is_deleted", False): + return False + chat = getattr(obj, "chat", None) + cache_key = ("chat", getattr(obj, "chat_id", 0)) + if access_cache is not None and cache_key in access_cache: + return access_cache[cache_key] + allowed = user_can_access_chat(chat, user) + if access_cache is not None: + access_cache[cache_key] = allowed + return allowed if model_name == "document": return user_can_view_document(user, obj) if model_name == "schedule_event": return user_can_view_event(user, obj) if model_name == "procurement_request": return user_can_view_procurement_request(user, obj) + if model_name == "task_board": + return not getattr(obj, "is_archived", False) and user_can_access_task_board( + user, obj + ) + if model_name == "task": + board = getattr(obj, "board", None) + return not getattr(board, "is_archived", False) and user_can_access_task_board( + user, board + ) return True @@ -370,11 +544,16 @@ def search_api_view(request: DRFRequest) -> Response: "employee": 3, ... }, - "total": 15 + "hidden_counts": { + "request": 2, + ... + }, + "total": 15, + "hidden_total": 2 } """ raw_q = request.query_params.get("q", "") or "" - query = escape(raw_q.strip()) + query = raw_q.strip() try: limit = int(request.query_params.get("limit", 10)) except (ValueError, TypeError): @@ -383,29 +562,39 @@ def search_api_view(request: DRFRequest) -> Response: if not query: return Response( - {"query": "", "results": [], "counts": {}, "total": 0}, + { + "query": "", + "results": [], + "counts": {}, + "hidden_counts": {}, + "total": 0, + "hidden_total": 0, + }, status=status.HTTP_200_OK, ) - # Ограничиваем объём кандидатов до object-level проверок. Иначе один общий - # термин может заставить API материализовать весь полнотекстовый индекс. - candidate_limit = max(200, min(limit * 20, 1000)) - search_results = watson_search(query)[:candidate_limit] + # Проверяем весь набор совпадений: раннее обрезание общего индекса приводило + # к тому, что многочисленные недоступные записи вытесняли доступные из выдачи. + search_results = watson_search(query) + search_objects = _materialize_search_objects(search_results) # Группируем и форматируем результаты results = [] model_counts: Dict[str, int] = {} + hidden_counts: Dict[str, int] = {} + access_cache: Dict[tuple[str, int], bool] = {} - for search_result in search_results: - obj = search_result.object - + for obj in search_objects: # Пропускаем удаленные объекты if obj is None: continue model_name = _get_model_name(obj) - if not _can_view_search_result(request.user, obj, model_name): + if not _can_view_search_result( + request.user, obj, model_name, access_cache + ): + hidden_counts[model_name] = hidden_counts.get(model_name, 0) + 1 continue # Подсчитываем @@ -421,7 +610,9 @@ def search_api_view(request: DRFRequest) -> Response: "query": query, "results": results, "counts": model_counts, + "hidden_counts": hidden_counts, "total": sum(model_counts.values()), + "hidden_total": sum(hidden_counts.values()), } # Валидируем через сериализатор (опционально, для проверки структуры) diff --git a/backend/search/search_indexes.py b/backend/search/search_indexes.py index affcb504..81b648b2 100644 --- a/backend/search/search_indexes.py +++ b/backend/search/search_indexes.py @@ -176,3 +176,47 @@ if Notification: fields=("verb", "description"), # v2 поля store=("verb", "unread", "timestamp"), ) + +# 13. Доски задач +TaskBoard = get_model_safe("tasks", "TaskBoard") +if TaskBoard: + register( + TaskBoard, + fields=("name", "description"), + store=("name", "updated_at"), + ) + +# 14. Задачи +Task = get_model_safe("tasks", "Task") +if Task: + register( + Task, + fields=("title", "description"), + store=("title", "updated_at"), + ) + +# 15. Товары, услуги и комплекты +Product = get_model_safe("commerce", "Product") +if Product: + register( + Product, + fields=("name", "sku", "barcode", "description"), + store=("name", "sku", "updated_at"), + ) + +# 16. Гости +Guest = get_model_safe("guests", "Guest") +if Guest: + register( + Guest, + fields=( + "last_name", + "first_name", + "patronymic", + "email", + "phone", + "organization", + "position", + ), + store=("last_name", "first_name", "patronymic", "email"), + ) diff --git a/backend/tests/api/v1/test_search.py b/backend/tests/api/v1/test_search.py index ff929493..079cd9ec 100644 --- a/backend/tests/api/v1/test_search.py +++ b/backend/tests/api/v1/test_search.py @@ -16,11 +16,13 @@ from schedule.models import Calendar, CalendarRelation, Event from api.v1.search.views import _can_view_search_result from communications.models import Chat, Message +from commerce.models import Product from documents.models import Document from employees.models import Employee, Department, Position from feed.models import Post from notifications.models import Notification from requests_app.models import Request +from tasks.models import TaskBoard, TaskBoardAccessScope _phone_counter = itertools.count(100_000_000) @@ -258,7 +260,9 @@ class TestSearchAPI: assert 'query' in response.data assert 'results' in response.data assert 'counts' in response.data + assert 'hidden_counts' in response.data assert 'total' in response.data + assert 'hidden_total' in response.data # Проверяем структуру результата if len(response.data['results']) > 0: @@ -341,6 +345,11 @@ class TestSearchAPI: assert hidden_models.isdisjoint( {"chat", "message", "schedule_event"} ) + assert hidden_response.data["hidden_counts"]["chat"] == 1 + assert hidden_response.data["hidden_counts"]["message"] == 1 + assert hidden_response.data["hidden_counts"]["document"] == 1 + assert hidden_response.data["hidden_counts"]["schedule_event"] == 1 + assert hidden_response.data["hidden_total"] >= 4 chat.participants.add(regular_user) document.recipients.add(regular_user) @@ -357,6 +366,82 @@ class TestSearchAPI: } assert _can_view_search_result(regular_user, document, "document") assert {"chat", "message", "schedule_event"} <= visible_models + assert visible_response.data["hidden_counts"].get("chat", 0) == 0 + assert visible_response.data["hidden_counts"].get("message", 0) == 0 + assert visible_response.data["hidden_counts"].get("document", 0) == 0 + assert visible_response.data["hidden_counts"].get("schedule_event", 0) == 0 + + def test_search_returns_employee_avatar(self, api_client, sample_data): + """Результат сотрудника содержит фото для карточки поиска.""" + employee = sample_data["employees"][0] + employee.avatar = "users/avatars/search-test.jpg" + employee.save(update_fields=["avatar"]) + call_command("buildwatson", verbosity=0) + + response = api_client.get("/api/v1/search/?q=Иванов") + + assert response.status_code == status.HTTP_200_OK + employees = [ + item + for item in response.data["results"] + if item["model_name"] == "employee" + ] + assert employees + assert employees[0]["image"].endswith("/users/avatars/search-test.jpg") + + def test_search_includes_products(self, api_client): + """Глобальный поиск охватывает каталог товаров.""" + product = Product.objects.create( + sku="SEARCH-SKU-001", + name="Уникальный поисковый товар", + barcode="4600000000001", + ) + call_command("buildwatson", verbosity=0) + + response = api_client.get("/api/v1/search/?q=товар") + + assert response.status_code == status.HTTP_200_OK + products = [ + item + for item in response.data["results"] + if item["model_name"] == "product" + ] + matching_product = next( + item for item in products if item["object_id"] == str(product.id) + ) + assert matching_product["meta"]["sku"] == "SEARCH-SKU-001" + + def test_search_counts_inaccessible_task_boards( + self, api_client, regular_user, make_user + ): + """Закрытые доски считаются, но не раскрываются в результатах.""" + owner = make_user(email="search-board-owner@example.com") + board = TaskBoard.objects.create( + name="Секретная поисковая доска", + created_by=owner, + access_scope=TaskBoardAccessScope.PRIVATE, + ) + call_command("buildwatson", verbosity=0) + + hidden_response = api_client.get("/api/v1/search/?q=поисковая") + + assert hidden_response.status_code == status.HTTP_200_OK + assert hidden_response.data["hidden_counts"]["task_board"] == 1 + assert not any( + item["model_name"] == "task_board" + for item in hidden_response.data["results"] + ) + + board.access_scope = TaskBoardAccessScope.ALL + board.save(update_fields=["access_scope"]) + visible_response = api_client.get("/api/v1/search/?q=поисковая") + + assert visible_response.status_code == status.HTTP_200_OK + assert any( + item["model_name"] == "task_board" + and item["object_id"] == board.id + for item in visible_response.data["results"] + ) def test_search_cyrillic_query(self, api_client, sample_data): """Поиск по кириллице работает корректно.""" diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx index 86913dbd..b966a400 100644 --- a/frontend/src/app/search/page.tsx +++ b/frontend/src/app/search/page.tsx @@ -1,50 +1,53 @@ "use client"; -import { useEffect, useState, Suspense } from "react"; +import { Suspense, useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { + Bell, + Building, + Calendar, + ChevronRight, + FileText, + Inbox, + LockKeyhole, + MessageCircle, + MessageSquare, + Package, + PanelsTopLeft, + Search, + ShoppingCart, + SquareCheckBig, + User, + UserRoundCheck, + type LucideIcon, +} from "lucide-react"; import { AppShell } from "../../components/AppShell"; +import { RequestAvatar } from "@/components/requests/RequestAvatar"; +import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { apiClient } from "@/lib/api"; -import type { SearchResponse, SearchResult, SearchModelType } from "@/types/api"; -import { Search, User, Building, FileText, MessageSquare, MessageCircle, Calendar, Inbox, ShoppingCart, Package, Bell } from "lucide-react"; +import type { SearchModelType, SearchResponse, SearchResult } from "@/types/api"; -function buildSearchHref(result: SearchResult): string { - switch (result.model_name) { - case "employee": - return `/users/${result.object_id}`; - case "department": - return `/departments/${result.object_id}`; - case "post": - return `/?post=${result.object_id}`; - case "request": - return `/requests?request=${result.object_id}`; - case "chat": - return `/messages/${result.object_id}`; - case "message": { - const chatId = typeof result.meta?.chat_id === "number" - ? result.meta.chat_id - : Number(result.meta?.chat_id || 0); - return chatId ? `/messages/${chatId}?message=${result.object_id}` : "/messages"; - } - case "event": - return "/calendar"; - case "schedule_event": - return `/calendar?event=${result.object_id}`; - case "procurement_request": - return "/procurement"; - case "equipment": - return "/equipment"; - case "document": - return `/documents?document=${result.object_id}`; - case "notification": - return "/notifications"; - default: - return "/search"; - } -} +const modelOrder: SearchModelType[] = [ + "employee", + "department", + "post", + "document", + "request", + "chat", + "message", + "event", + "schedule_event", + "procurement_request", + "equipment", + "product", + "task_board", + "task", + "guest", + "notification", +]; -// Иконки для разных типов результатов -const modelIcons: Record> = { +const modelIcons: Record = { employee: User, department: Building, post: Inbox, @@ -56,14 +59,17 @@ const modelIcons: Record = { employee: "Сотрудники", department: "Отделы", - post: "Посты", + post: "Новости", document: "Документы", request: "Заявления", chat: "Чаты", @@ -72,169 +78,245 @@ const modelNames: Record = { schedule_event: "Расписание", procurement_request: "Заявки на закупку", equipment: "Оборудование", + product: "Товары", + task_board: "Доски", + task: "Задачи", + guest: "Гости", notification: "Уведомления", }; +const hiddenMetaKeys = new Set([ + "id", + "chat_id", + "created_at", + "uploaded_at", + "start", + "end", + "start_date", + "is_read", +]); + +function buildSearchHref(result: SearchResult): string { + switch (result.model_name) { + case "employee": return `/users/${result.object_id}`; + case "department": return `/departments/${result.object_id}`; + case "post": return `/?post=${result.object_id}`; + case "request": return `/requests?request=${result.object_id}`; + case "chat": return `/messages/${result.object_id}`; + case "message": { + const chatId = typeof result.meta?.chat_id === "number" + ? result.meta.chat_id + : Number(result.meta?.chat_id || 0); + return chatId ? `/messages/${chatId}?message=${result.object_id}` : "/messages"; + } + case "event": return "/calendar"; + case "schedule_event": return `/calendar?event=${result.object_id}`; + case "procurement_request": return "/procurement"; + case "equipment": return "/equipment"; + case "product": return `/products/${result.object_id}`; + case "task_board": return `/tasks?board=${result.object_id}`; + case "task": { + const boardId = result.meta?.board_id; + return boardId ? `/tasks?board=${boardId}&task=${result.object_id}` : "/tasks"; + } + case "guest": return `/guests?guest=${result.object_id}`; + case "document": return `/documents?document=${result.object_id}`; + case "notification": return "/notifications"; + default: return "/search"; + } +} + +function getResultInitials(title: string): string { + const parts = title.trim().split(/\s+/).filter(Boolean); + return parts.slice(0, 2).map((part) => part[0]).join("").toLocaleUpperCase("ru-RU") || "?"; +} + +function getMetaValues(meta?: Record): string[] { + if (!meta) return []; + return Object.entries(meta) + .filter(([key, value]) => { + if (hiddenMetaKeys.has(key) || value === null || value === undefined || value === "") return false; + return typeof value === "string" || typeof value === "number"; + }) + .map(([, value]) => String(value)) + .slice(0, 3); +} + +function SearchResultVisual({ result }: { result: SearchResult }) { + const Icon = modelIcons[result.model_name]; + if (result.image) { + return ; + } + return ( + + + ); +} + +function LoadingPanel({ label }: { label: string }) { + return ( +
+
+

{label}

+
+ ); +} + export default function SearchPage() { return ( - -
-
-
-

Загрузка...

-
-
- - }> +
}>
); } function SearchPageContent() { + const pathname = usePathname(); + const router = useRouter(); const searchParams = useSearchParams(); const queryRaw = searchParams.get("q") || ""; const query = queryRaw.trim(); - + const requestedCategory = searchParams.get("type"); const [searchResponse, setSearchResponse] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { - if (!query) { - setSearchResponse(null); - setError(null); - setLoading(false); - return; - } - + if (!query) return; let cancelled = false; - async function runSearch() { try { setLoading(true); setError(null); - - const response: SearchResponse = await apiClient.search(queryRaw, 10); - - if (cancelled) return; - - setSearchResponse(response); + const response = await apiClient.search(queryRaw, 50); + if (!cancelled) setSearchResponse(response); } catch (err) { if (!cancelled) { console.error("Ошибка поиска:", err); setError("Не удалось выполнить поиск"); } } finally { - if (!cancelled) { - setLoading(false); - } + if (!cancelled) setLoading(false); } } - - runSearch(); - - return () => { - cancelled = true; - }; + void runSearch(); + return () => { cancelled = true; }; }, [query, queryRaw]); - // Группируем результаты по типам - const resultsByType: Partial> = {}; - if (searchResponse) { - searchResponse.results.forEach((result) => { - if (!resultsByType[result.model_name]) { - resultsByType[result.model_name] = []; - } - resultsByType[result.model_name]!.push(result); + const resultsByType = useMemo(() => { + const grouped: Partial> = {}; + searchResponse?.results.forEach((result) => { + (grouped[result.model_name] ??= []).push(result); }); - } + return grouped; + }, [searchResponse]); + + const availableCategories = useMemo(() => modelOrder.filter((modelType) => ( + (searchResponse?.counts[modelType] || 0) + (searchResponse?.hidden_counts[modelType] || 0) > 0 + )), [searchResponse]); + const activeCategory: "all" | SearchModelType = requestedCategory + && availableCategories.includes(requestedCategory as SearchModelType) + ? requestedCategory as SearchModelType + : "all"; + const displayedCategories = activeCategory === "all" ? availableCategories : [activeCategory]; + const hasAnyMatches = Boolean(searchResponse && (searchResponse.total > 0 || searchResponse.hidden_total > 0)); + + const selectCategory = (category: "all" | SearchModelType) => { + const params = new URLSearchParams(searchParams.toString()); + if (category === "all") params.delete("type"); + else params.set("type", category); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }; return (
-
- -

- {query ? `Результаты по запросу: "${queryRaw}"` : "Введите запрос в поиске сверху"} -

+
+ +
+

+ {query ? <>Результаты по запросу: «{queryRaw}» : "Введите запрос в поиске сверху"} +

+ {query && searchResponse ? ( +

+ Доступно: {searchResponse.total} + {searchResponse.hidden_total > 0 ? <> · Нет доступа: {searchResponse.hidden_total} : null} +

+ ) : null} +
- {query && searchResponse && ( -
- Найдено результатов: {searchResponse.total} -
- )} - - {loading ? ( -
-
-

Поиск...

-
- ) : error ? ( -
{error}
- ) : !query ? ( -
- Начните вводить запрос в строке поиска -
- ) : searchResponse?.total === 0 ? ( -
- Ничего не найдено по запросу "{queryRaw}" -
- ) : ( -
- {(Object.keys(resultsByType) as SearchModelType[]).map((modelType) => { - const results = resultsByType[modelType]!; + {query && !loading && !error && hasAnyMatches ? ( + + + {availableCategories.map((modelType) => { const Icon = modelIcons[modelType]; - const categoryName = modelNames[modelType]; - return ( -
-
- -

- {categoryName} ({results.length}) -

-
-
- {results.map((result) => ( - -
-
-

{result.title}

- {result.description && ( -

{result.description}

- )} - {result.meta && Object.keys(result.meta).length > 0 && ( -
- {Object.entries(result.meta).map(([key, value]) => { - if (!value || key === 'id') return null; - return ( - - {String(value)} - - ); - })} -
- )} -
-
- - ))} -
-
+ ); })} -
- )} + + ) : null} + +
+ {!query ? ( +
Начните вводить запрос в строке поиска
+ ) : loading ? : error ? ( +
{error}
+ ) : !hasAnyMatches ? ( +
Ничего не найдено по запросу «{queryRaw}»
+ ) : ( +
+ {displayedCategories.map((modelType) => { + const results = resultsByType[modelType] || []; + const Icon = modelIcons[modelType]; + const visibleCount = searchResponse?.counts[modelType] || 0; + const hiddenCount = searchResponse?.hidden_counts[modelType] || 0; + return ( +
+
+
+
+ {results.map((result) => { + const metaValues = getMetaValues(result.meta); + return ( + + +
+

{result.title}

+ {result.description ?

{result.description}

: null} + {metaValues.length > 0 ? ( +
+ {metaValues.map((value, index) => {value})} +
+ ) : null} +
+
+
+ {results.length < visibleCount ? `Показано ${results.length} из ${visibleCount}` : `${visibleCount} доступно`} + +
+
+ ); + })} +
+ )} +
); diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index a74f8eae..9bf6a94a 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -639,9 +639,10 @@ export function BigCalendar() { + ) : null} + {loadMoreErrors[modelType] ? ( +

{loadMoreErrors[modelType]}

+ ) : null}
); diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 8ee6657e..5daa4c2a 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { createPortal, flushSync } from "react-dom"; @@ -97,6 +98,7 @@ import { useUser } from "@/contexts/UserContext"; import { useTaskBoardScroll } from "@/hooks/useTaskBoardScroll"; import { useTaskBoardRealtime } from "@/hooks/useTaskBoardRealtime"; import { apiClient } from "@/lib/api"; +import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image"; import type { MediaPreview } from "@/lib/media"; import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared"; import { buildTaskBoardLayout, taskPlacementKey } from "@/lib/tasks/boardLayout"; @@ -135,6 +137,10 @@ import type { User, } from "@/types/api"; +const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { + ssr: false, +}); + type TaskFormState = { id: number | null; title: string; @@ -3741,6 +3747,8 @@ function TasksPageContent() { const [cardMenuTaskId, setCardMenuTaskId] = useState(null); const [columnMenuId, setColumnMenuId] = useState(null); const [boardModalOpen, setBoardModalOpen] = useState(false); + const [boardAvatarCropperImage, setBoardAvatarCropperImage] = useState(null); + const [boardAvatarFileName, setBoardAvatarFileName] = useState("board-avatar.jpg"); const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false); const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false); const [automationModalOpen, setAutomationModalOpen] = useState(false); @@ -5226,6 +5234,8 @@ function TasksPageContent() { const openCreateBoard = useCallback(() => { setBoardForm(emptyBoardForm); + setBoardAvatarCropperImage(null); + setBoardAvatarFileName("board-avatar.jpg"); setBoardMemberSearch(""); setBoardDepartmentSearch(""); setBoardActionsMenuOpen(false); @@ -5278,6 +5288,8 @@ function TasksPageContent() { setBoardMemberSearch(""); setBoardDepartmentSearch(""); setBoardActionsMenuOpen(false); + setBoardAvatarCropperImage(null); + setBoardAvatarFileName("board-avatar.jpg"); setBoardModalOpen(true); }, [board]); @@ -5285,6 +5297,8 @@ function TasksPageContent() { if (saving) return; setBoardModalOpen(false); setBoardForm(emptyBoardForm); + setBoardAvatarCropperImage(null); + setBoardAvatarFileName("board-avatar.jpg"); setBoardMemberSearch(""); setBoardDepartmentSearch(""); }, [saving]); @@ -5376,16 +5390,29 @@ function TasksPageContent() { const reader = new FileReader(); reader.onloadend = () => { - setBoardForm((current) => ({ - ...current, - avatar_file: file, - avatar_preview: typeof reader.result === "string" ? reader.result : null, - avatar_removed: false, - })); + if (typeof reader.result !== "string") return; + setBoardAvatarFileName(buildCroppedImageFileName(file.name, "board-avatar")); + setBoardAvatarCropperImage(reader.result); }; reader.readAsDataURL(file); }, []); + const handleBoardAvatarCropComplete = useCallback((croppedImage: string) => { + try { + const croppedFile = imageDataUrlToFile(croppedImage, boardAvatarFileName); + setBoardForm((current) => ({ + ...current, + avatar_file: croppedFile, + avatar_preview: croppedImage, + avatar_removed: false, + })); + setBoardAvatarCropperImage(null); + } catch (cropError) { + console.error("Не удалось обработать аватар доски:", cropError); + setError("Не удалось обработать изображение"); + } + }, [boardAvatarFileName]); + const removeBoardAvatar = useCallback(() => { setBoardForm((current) => ({ ...current, @@ -8155,7 +8182,8 @@ function TasksPageContent() { onClose={closeBoardModal} title={boardForm.id ? "Редактировать доску" : "Новая доска"} size="lg" - closeOnClickOutside + closeOnEsc={!boardAvatarCropperImage} + closeOnClickOutside={!boardAvatarCropperImage} footer={(
{/* Контролы масштабирования */} diff --git a/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx b/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx index 2cb299f6..b9eee220 100644 --- a/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx +++ b/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, useEffect } from "react"; +import { createPortal } from "react-dom"; import { Document, Page, pdfjs } from "react-pdf"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; @@ -152,8 +153,14 @@ export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFVie return undefined; }, [fitMode, containerWidth]); - return ( -
+ return createPortal( +
{/* Шапка */}
@@ -361,6 +368,7 @@ export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFVie
-
+ , + document.body, ); } diff --git a/frontend/src/components/messages/ChatSettingsModal.tsx b/frontend/src/components/messages/ChatSettingsModal.tsx index 3473c59a..661e4dae 100644 --- a/frontend/src/components/messages/ChatSettingsModal.tsx +++ b/frontend/src/components/messages/ChatSettingsModal.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import dynamic from "next/dynamic"; import { useEffect, useState, type ChangeEvent } from "react"; import { Pencil, Pin } from "lucide-react"; @@ -8,11 +9,16 @@ import EditChatModal from "@/components/messages/EditChatModal"; import ChatNotificationPreferences from "@/components/messages/ChatNotificationPreferences"; import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; +import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image"; import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils"; import { getChatTypeLabel } from "@/lib/messages/chatSettingsUtils"; import { resolveMediaUrl } from "@/lib/url"; import type { Chat, ChatUserSettings } from "@/types/api"; +const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { + ssr: false, +}); + type ChatSettingsModalProps = { chatId: number; currentUserId?: number; @@ -37,6 +43,8 @@ export default function ChatSettingsModal({ const [editDescription, setEditDescription] = useState(""); const [editAvatarFile, setEditAvatarFile] = useState(null); const [editAvatarPreview, setEditAvatarPreview] = useState(null); + const [editAvatarCropperImage, setEditAvatarCropperImage] = useState(null); + const [editAvatarFileName, setEditAvatarFileName] = useState("chat-avatar.jpg"); useEffect(() => { if (!open) return; @@ -94,6 +102,8 @@ export default function ChatSettingsModal({ setEditDescription(chat.description || ""); setEditAvatarFile(null); setEditAvatarPreview(null); + setEditAvatarCropperImage(null); + setEditAvatarFileName("chat-avatar.jpg"); setEditOpen(true); }; @@ -101,15 +111,31 @@ export default function ChatSettingsModal({ setEditOpen(false); setEditAvatarFile(null); setEditAvatarPreview(null); + setEditAvatarCropperImage(null); }; const handleAvatarChange = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file || !file.type.startsWith("image/")) return; - setEditAvatarFile(file); const reader = new FileReader(); - reader.onloadend = () => setEditAvatarPreview(reader.result as string); + reader.onloadend = () => { + if (typeof reader.result !== "string") return; + setEditAvatarFileName(buildCroppedImageFileName(file.name, "chat-avatar")); + setEditAvatarCropperImage(reader.result); + }; reader.readAsDataURL(file); + event.target.value = ""; + }; + + const handleAvatarCropComplete = (croppedImage: string) => { + try { + setEditAvatarFile(imageDataUrlToFile(croppedImage, editAvatarFileName)); + setEditAvatarPreview(croppedImage); + setEditAvatarCropperImage(null); + } catch (cropError) { + console.error("Не удалось обработать аватар чата:", cropError); + setError("Не удалось обработать изображение"); + } }; const saveEdit = async () => { @@ -146,7 +172,8 @@ export default function ChatSettingsModal({ onClose={onClose} title="Настройки чата" size="md" - closeOnClickOutside + closeOnEsc={!editOpen && !editAvatarCropperImage} + closeOnClickOutside={!editOpen && !editAvatarCropperImage} > {loading ? (
@@ -220,6 +247,7 @@ export default function ChatSettingsModal({ editName={editName} editDescription={editDescription} editAvatarPreview={editAvatarPreview} + avatarCropperOpen={Boolean(editAvatarCropperImage)} actionLoading={actionLoading} onClose={closeEdit} onNameChange={setEditName} @@ -227,6 +255,15 @@ export default function ChatSettingsModal({ onAvatarChange={handleAvatarChange} onSave={() => void saveEdit()} /> + {editAvatarCropperImage ? ( + setEditAvatarCropperImage(null)} + /> + ) : null} ); } diff --git a/frontend/src/components/messages/CreateChatModal.tsx b/frontend/src/components/messages/CreateChatModal.tsx index 0925a99d..0c2258ff 100644 --- a/frontend/src/components/messages/CreateChatModal.tsx +++ b/frontend/src/components/messages/CreateChatModal.tsx @@ -1,13 +1,19 @@ "use client"; import Image from "next/image"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { useState, type ChangeEvent } from "react"; import { Globe, ImagePlus, Radio, Trash2, Users } from "lucide-react"; import { apiClient } from "@/lib/api"; +import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image"; import { Modal } from "@/components/ui"; +const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { + ssr: false, +}); + type CreateChatModalProps = { isOpen: boolean; onClose: () => void; @@ -48,6 +54,8 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp const [description, setDescription] = useState(""); const [avatar, setAvatar] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarCropperImage, setAvatarCropperImage] = useState(null); + const [avatarCropperFileName, setAvatarCropperFileName] = useState("chat-avatar.jpg"); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); @@ -57,6 +65,8 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp setDescription(""); setAvatar(null); setAvatarPreview(null); + setAvatarCropperImage(null); + setAvatarCropperFileName("chat-avatar.jpg"); setError(null); }; @@ -106,19 +116,36 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp } setError(null); - setAvatar(file); const reader = new FileReader(); - reader.onloadend = () => setAvatarPreview(reader.result as string); + reader.onloadend = () => { + if (typeof reader.result !== "string") return; + setAvatarCropperFileName(buildCroppedImageFileName(file.name, "chat-avatar")); + setAvatarCropperImage(reader.result); + }; reader.readAsDataURL(file); + event.target.value = ""; + }; + + const handleAvatarCropComplete = (croppedImage: string) => { + try { + setAvatar(imageDataUrlToFile(croppedImage, avatarCropperFileName)); + setAvatarPreview(croppedImage); + setAvatarCropperImage(null); + } catch (cropError) { + console.error("Не удалось обработать аватар чата:", cropError); + setError("Не удалось обработать изображение"); + } }; return ( - + @@ -252,6 +279,16 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
- + + {avatarCropperImage ? ( + setAvatarCropperImage(null)} + /> + ) : null} + ); } diff --git a/frontend/src/components/messages/EditChatModal.tsx b/frontend/src/components/messages/EditChatModal.tsx index af10e008..1d17c75a 100644 --- a/frontend/src/components/messages/EditChatModal.tsx +++ b/frontend/src/components/messages/EditChatModal.tsx @@ -15,6 +15,7 @@ type EditChatModalProps = { editName: string; editDescription: string; editAvatarPreview: string | null; + avatarCropperOpen?: boolean; actionLoading: string | null; onClose: () => void; onNameChange: (value: string) => void; @@ -30,6 +31,7 @@ export default function EditChatModal({ editName, editDescription, editAvatarPreview, + avatarCropperOpen = false, actionLoading, onClose, onNameChange, @@ -59,7 +61,15 @@ export default function EditChatModal({ ); return ( - + {chat && (
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 2720c093..c6aad18b 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -1,10 +1,14 @@ "use client"; -import { useEffect, useId, useRef, type ReactNode } from "react"; +import { useEffect, useId, useRef, useSyncExternalStore, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { X } from "lucide-react"; let openModalCount = 0; const openModalStack: string[] = []; +const subscribeToBrowserReady = () => () => {}; +const getBrowserSnapshot = () => true; +const getServerSnapshot = () => false; export interface ModalProps { isOpen: boolean; @@ -43,6 +47,11 @@ export function Modal({ const modalRef = useRef(null); const contentRef = useRef(null); const titleId = useId(); + const browserReady = useSyncExternalStore( + subscribeToBrowserReady, + getBrowserSnapshot, + getServerSnapshot, + ); useEffect(() => { if (!isOpen) return; @@ -75,7 +84,7 @@ export function Modal({ }, [isOpen, closeOnEsc, onClose, titleId]); useEffect(() => { - if (!isOpen || !contentRef.current) return; + if (!isOpen || !browserReady || !contentRef.current) return; const focusableElements = contentRef.current.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' @@ -106,9 +115,9 @@ export function Modal({ document.addEventListener("keydown", handleTab); return () => document.removeEventListener("keydown", handleTab); - }, [isOpen, titleId]); + }, [isOpen, titleId, browserReady]); - if (!isOpen) return null; + if (!isOpen || !browserReady) return null; const handleBackdropClick = (e: React.MouseEvent) => { if ( @@ -132,7 +141,7 @@ export function Modal({ const showHeader = !noHeader && (title || showCloseButton); - return ( + return createPortal(
}
-
+
, + document.body, ); } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 10c26a72..aba895b2 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -6,6 +6,7 @@ import type { LoginResponse, QrLoginRequestCreateResult, QrLoginRequestStatusResult, + SearchModelType, SearchResponse, } from "@/types/api"; @@ -350,10 +351,16 @@ export class ApiClientBase { return this.request('/api/v1/employees/me/'); } - async search(query: string, limit?: number): Promise { + async search(query: string, options: { + limit?: number; + offset?: number; + modelType?: SearchModelType; + } = {}): Promise { const queryParams = new URLSearchParams(); queryParams.append('q', query); - if (limit) queryParams.append('limit', limit.toString()); + if (options.limit) queryParams.append('limit', options.limit.toString()); + if (options.offset) queryParams.append('offset', options.offset.toString()); + if (options.modelType) queryParams.append('type', options.modelType); return this.request(`/api/v1/search/?${queryParams.toString()}`); } diff --git a/frontend/src/lib/image.ts b/frontend/src/lib/image.ts new file mode 100644 index 00000000..779eed07 --- /dev/null +++ b/frontend/src/lib/image.ts @@ -0,0 +1,17 @@ +export function buildCroppedImageFileName(fileName: string, fallback = "avatar") { + const baseName = fileName.trim().replace(/\.[^.]+$/, "") || fallback; + return `${baseName}.jpg`; +} + +export function imageDataUrlToFile(dataUrl: string, fileName: string) { + const [header, base64Data] = dataUrl.split(","); + if (!base64Data) throw new Error("Invalid image data URL"); + + const mime = header.match(/^data:(.*?);base64$/)?.[1] || "image/jpeg"; + const binary = window.atob(base64Data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], fileName, { type: mime }); +} From 63378d6480bd97e503ed1dcc4c824b77fadd3246 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 19 Aug 2026 16:40:35 +0300 Subject: [PATCH 3/4] =?UTF-8?q?Retry=20transient=20=D0=9C=D0=BE=D0=B9?= =?UTF-8?q?=D0=A1=D0=BA=D0=BB=D0=B0=D0=B4=20GET=20requests=20and=20improve?= =?UTF-8?q?=20error=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/commerce/integrations/moysklad.py | 121 +++++++++++++++++---- backend/tests/commerce/test_connections.py | 70 ++++++++++++ 2 files changed, 168 insertions(+), 23 deletions(-) diff --git a/backend/commerce/integrations/moysklad.py b/backend/commerce/integrations/moysklad.py index 25cd6cd0..a3ac8d8b 100644 --- a/backend/commerce/integrations/moysklad.py +++ b/backend/commerce/integrations/moysklad.py @@ -1,6 +1,8 @@ from collections.abc import Iterator from dataclasses import dataclass +import logging import re +import time from typing import Any from urllib.parse import urlparse @@ -8,6 +10,9 @@ import httpx from django.conf import settings +logger = logging.getLogger(__name__) + + class MoySkladError(RuntimeError): pass @@ -26,6 +31,9 @@ class MoySkladClient: DEFAULT_BASE_URL = "https://api.moysklad.ru/api/remap/1.2" MAX_IMAGE_BYTES = 25 * 1024 * 1024 ASSORTMENT_ENTITY_TYPES = ("product", "variant", "bundle", "service") + GET_RETRY_DELAYS = (2.0, 5.0, 10.0) + CONNECT_TIMEOUT_SECONDS = 30.0 + READ_TIMEOUT_SECONDS = 90.0 def __init__(self, *, auth_type: str, credentials: dict[str, str]): self.base_url = ( @@ -62,7 +70,12 @@ class MoySkladClient: base_url=self.base_url, headers=headers, auth=auth, - timeout=httpx.Timeout(30.0), + timeout=httpx.Timeout( + connect=self.CONNECT_TIMEOUT_SECONDS, + read=self.READ_TIMEOUT_SECONDS, + write=self.CONNECT_TIMEOUT_SECONDS, + pool=self.CONNECT_TIMEOUT_SECONDS, + ), follow_redirects=False, ) @@ -76,32 +89,94 @@ class MoySkladClient: self.close() def _get(self, path: str, *, params: dict[str, Any] | None = None) -> dict: - try: - response = self.client.get(path.lstrip("/"), params=params) - response.raise_for_status() - payload = response.json() - except httpx.HTTPStatusError as exc: - status = exc.response.status_code - detail = self._error_detail(exc.response) - if status in {401, 403}: - message = "МойСклад отклонил реквизиты или у пользователя нет прав API." - elif status == 400: - message = "МойСклад не принял запрос. Проверьте access token и права пользователя." - elif status == 429: - message = "МойСклад временно ограничил частоту запросов." - else: - message = f"МойСклад вернул ошибку HTTP {status}." - if detail: - message = f"{message} Ответ сервиса: {detail}" - raise MoySkladError(message) from exc - except (httpx.HTTPError, ValueError) as exc: - raise MoySkladError( - "Не удалось получить корректный ответ от МойСклад." - ) from exc + endpoint = path.lstrip("/") + retryable_errors = ( + httpx.ReadTimeout, + httpx.ConnectTimeout, + httpx.ReadError, + ) + total_attempts = len(self.GET_RETRY_DELAYS) + 1 + payload: Any = None + + for attempt in range(1, total_attempts + 1): + try: + response = self.client.get(endpoint, params=params) + response.raise_for_status() + payload = response.json() + break + except retryable_errors as exc: + log_context = { + "event": "commerce.moysklad.get_retry", + "endpoint": endpoint, + "request_offset": (params or {}).get("offset"), + "request_limit": (params or {}).get("limit"), + "attempt": attempt, + "max_attempts": total_attempts, + "error_type": type(exc).__name__, + "failure_reason": str(exc)[:500], + } + if attempt >= total_attempts: + logger.error( + "MoySklad GET failed after retries: %s", + type(exc).__name__, + extra={ + **log_context, + "event": "commerce.moysklad.get_failed", + "retries_exhausted": True, + }, + exc_info=True, + ) + raise MoySkladError( + "МойСклад не ответил после повторных попыток." + ) from exc + + retry_delay = self.GET_RETRY_DELAYS[attempt - 1] + logger.warning( + "MoySklad GET transient failure %s; retrying in %.1f seconds", + type(exc).__name__, + retry_delay, + extra={**log_context, "retry_in_seconds": retry_delay}, + ) + time.sleep(retry_delay) + except httpx.HTTPStatusError as exc: + raise MoySkladError(self._http_status_message(exc.response)) from exc + except (httpx.HTTPError, ValueError) as exc: + logger.error( + "MoySklad GET failed without retry: %s", + type(exc).__name__, + extra={ + "event": "commerce.moysklad.get_failed", + "endpoint": endpoint, + "request_offset": (params or {}).get("offset"), + "request_limit": (params or {}).get("limit"), + "error_type": type(exc).__name__, + "failure_reason": str(exc)[:500], + }, + exc_info=True, + ) + raise MoySkladError( + "Не удалось получить корректный ответ от МойСклад." + ) from exc if not isinstance(payload, dict): raise MoySkladError("МойСклад вернул ответ неожиданного формата.") return payload + @classmethod + def _http_status_message(cls, response: httpx.Response) -> str: + status = response.status_code + if status in {401, 403}: + message = "МойСклад отклонил реквизиты или у пользователя нет прав API." + elif status == 400: + message = ( + "МойСклад не принял запрос. Проверьте access token и права пользователя." + ) + elif status == 429: + message = "МойСклад временно ограничил частоту запросов." + else: + message = f"МойСклад вернул ошибку HTTP {status}." + detail = cls._error_detail(response) + return f"{message} Ответ сервиса: {detail}" if detail else message + def product_images( self, entity_type: str, diff --git a/backend/tests/commerce/test_connections.py b/backend/tests/commerce/test_connections.py index 48d2119a..4457b55e 100644 --- a/backend/tests/commerce/test_connections.py +++ b/backend/tests/commerce/test_connections.py @@ -119,10 +119,80 @@ def test_moysklad_client_uses_vendor_accept_header(): client = MoySkladClient(auth_type="token", credentials={"token": "test"}) try: assert client.client.headers["Accept"] == "application/json;charset=utf-8" + assert client.client.timeout.read == 90.0 finally: client.close() +def test_moysklad_client_retries_transient_get_errors_on_same_page(settings): + settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2" + attempts = [] + failures = [httpx.ReadTimeout, httpx.ConnectTimeout, httpx.ReadError] + + def handler(request): + attempts.append(str(request.url)) + if failures: + error_type = failures.pop(0) + raise error_type("temporary upstream failure", request=request) + return httpx.Response(200, json={"rows": []}, request=request) + + client = MoySkladClient(auth_type="token", credentials={"token": "test"}) + client.client.close() + client.client = httpx.Client( + base_url=client.base_url, + transport=httpx.MockTransport(handler), + ) + with patch("commerce.integrations.moysklad.time.sleep") as sleep: + try: + payload = client._get( + "/entity/demand", + params={"limit": 100, "offset": 1600}, + ) + finally: + client.close() + + assert payload == {"rows": []} + assert len(attempts) == 4 + assert len(set(attempts)) == 1 + assert "offset=1600" in attempts[0] + assert [call.args[0] for call in sleep.call_args_list] == [2.0, 5.0, 10.0] + + +def test_moysklad_client_logs_original_error_before_wrapping( + settings, + caplog, +): + settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2" + + def handler(request): + raise httpx.ReadTimeout("upstream headers timeout", request=request) + + client = MoySkladClient(auth_type="token", credentials={"token": "test"}) + client.client.close() + client.client = httpx.Client( + base_url=client.base_url, + transport=httpx.MockTransport(handler), + ) + with ( + patch("commerce.integrations.moysklad.time.sleep"), + pytest.raises(MoySkladError) as raised, + ): + try: + client._get( + "/entity/demand", + params={"limit": 100, "offset": 1600}, + ) + finally: + client.close() + + assert isinstance(raised.value.__cause__, httpx.ReadTimeout) + assert any( + getattr(record, "error_type", "") == "ReadTimeout" + and getattr(record, "request_offset", None) == 1600 + for record in caplog.records + ) + + def test_moysklad_client_reads_variant_images_and_downloads_file(settings): settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2" image_content = _jpeg_bytes() From f6904ca2dd195ed7c86781bda1fb4cca2d84652b Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 19 Aug 2026 17:01:43 +0300 Subject: [PATCH 4/4] Improve task link pill priority display --- .../src/components/tasks/TaskLinkPill.tsx | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/tasks/TaskLinkPill.tsx b/frontend/src/components/tasks/TaskLinkPill.tsx index 49b988a3..60752708 100644 --- a/frontend/src/components/tasks/TaskLinkPill.tsx +++ b/frontend/src/components/tasks/TaskLinkPill.tsx @@ -41,6 +41,13 @@ const priorityLabels: Record = { critical: "Критический", }; +const priorityTitleShiftClasses: Record = { + low: "group-hover/task-pill:translate-x-10 group-focus-visible/task-pill:translate-x-10", + medium: "group-hover/task-pill:translate-x-12 group-focus-visible/task-pill:translate-x-12", + high: "group-hover/task-pill:translate-x-12 group-focus-visible/task-pill:translate-x-12", + critical: "group-hover/task-pill:translate-x-16 group-focus-visible/task-pill:translate-x-16", +}; + function normalizeHexColor(value?: string | null): string { const color = String(value || "").trim(); if (/^#[0-9a-f]{6}$/i.test(color)) return color; @@ -77,11 +84,10 @@ export default function TaskLinkPill({ const priority = task.priority || "medium"; const priorityColor = priorityColors[priority]; const priorityLabel = task.priority_display || priorityLabels[priority]; + const priorityTitleShiftClass = priorityTitleShiftClasses[priority]; const textColor = getReadableTextColor(columnColor); const isOnAccent = tone === "onAccent"; - const title = `${task.board_name ? `${task.board_name}: ` : ""}${task.title}${ - task.priority_display ? `, ${task.priority_display}` : "" - }`; + const title = `${task.board_name ? `${task.board_name}: ` : ""}${task.title}, ${priorityLabel}`; const style: CSSProperties = { backgroundColor: columnColor, boxShadow: isOnAccent ? "inset 0 1px 0 rgba(255, 255, 255, 0.18)" : undefined, @@ -89,26 +95,29 @@ export default function TaskLinkPill({ }; const href = `/tasks?board=${task.board_id}&task=${task.id}`; const canOpen = task.can_open !== false; - const pillClassName = `group/task-pill inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-medium shadow-sm transition ${ + const pillClassName = `group/task-pill inline-flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden rounded-full px-2 py-1 text-[11px] font-medium shadow-sm transition ${ canOpen ? "hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45" : "cursor-pointer hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/45" } ${className}`; const content = ( <> -