diff --git a/backend/api/v1/search/serializers.py b/backend/api/v1/search/serializers.py index 5ce1d924..47fee2e6 100644 --- a/backend/api/v1/search/serializers.py +++ b/backend/api/v1/search/serializers.py @@ -24,7 +24,6 @@ class SearchResultSerializer(serializers.Serializer): description = serializers.CharField( help_text="Описание/превью контента", allow_blank=True, required=False ) - url = serializers.CharField(help_text="Ссылка на объект", allow_blank=True) meta = serializers.DictField( help_text="Дополнительные метаданные", required=False, allow_null=True ) diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index 03ae6108..c177fcf8 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -53,7 +53,6 @@ def _format_employee(obj: Any) -> Dict[str, Any]: "description": getattr(obj.position, "name", "") if hasattr(obj, "position") else "", - "url": f"/employees/{obj.pk}/", "meta": { "email": obj.email, "phone": str(obj.phone_number) if obj.phone_number else "", @@ -68,7 +67,6 @@ def _format_department(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.name, "description": obj.description or "", - "url": f"/departments/{obj.pk}/", "meta": { "head": str(obj.head) if hasattr(obj, "head") and obj.head else None }, @@ -84,7 +82,6 @@ def _format_post(obj: Any) -> Dict[str, Any]: "description": obj.body[:200] + "..." if len(obj.body) > 200 else obj.body, - "url": f"/feed/{obj.pk}/", "meta": { "author": str(obj.author) if hasattr(obj, "author") else None, "created_at": obj.created_at.isoformat() @@ -103,7 +100,6 @@ def _format_request(obj: Any) -> Dict[str, Any]: "description": obj.comment[:200] + "..." if obj.comment and len(obj.comment) > 200 else (obj.comment or ""), - "url": f"/requests/{obj.pk}/", "meta": { "status": obj.status, "type": obj.type, @@ -119,7 +115,6 @@ def _format_chat(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.name or f"Чат #{obj.pk}", "description": obj.description or "", - "url": f"/messages?chat={obj.pk}", "meta": { "type": obj.type if hasattr(obj, "type") else None, }, @@ -137,14 +132,10 @@ def _format_message(obj: Any) -> Dict[str, Any]: "description": obj.content[:200] + "..." if len(obj.content) > 200 else obj.content, - "url": ( - f"/messages?chat={ - obj.chat.pk if hasattr(obj, 'chat') else '' - }#msg-{obj.pk}" - ), "meta": { "author": str(obj.author) if hasattr(obj, "author") else None, "chat": str(obj.chat) if hasattr(obj, "chat") else None, + "chat_id": obj.chat.pk if hasattr(obj, "chat") else None, }, } @@ -156,7 +147,6 @@ def _format_event(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.title, "description": obj.description or "", - "url": f"/calendar?event={obj.pk}", "meta": { "start_date": obj.start_date.isoformat() if hasattr(obj, "start_date") @@ -172,7 +162,6 @@ def _format_schedule_event(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.title, "description": obj.description or "", - "url": "/calendar/", "meta": { "start": obj.start.isoformat() if hasattr(obj, "start") else None, "end": obj.end.isoformat() @@ -191,7 +180,6 @@ def _format_procurement_request(obj: Any) -> Dict[str, Any]: "description": obj.description[:200] + "..." if len(obj.description) > 200 else obj.description, - "url": f"/procurement/requests/{obj.pk}/", "meta": { "status": obj.status if hasattr(obj, "status") else None, "requestor": str(obj.requestor) @@ -215,7 +203,6 @@ def _format_equipment(obj: Any) -> Dict[str, Any]: if obj.serial_number else f"Инв. №: {obj.inventory_number}" ), - "url": f"/procurement/equipment/{obj.pk}/", "meta": { "inventory_number": obj.inventory_number, "status": obj.status if hasattr(obj, "status") else None, @@ -235,7 +222,6 @@ def _format_document(obj: Any) -> Dict[str, Any]: if obj.description and len(obj.description) > 200 else (obj.description or "") ), - "url": f"/documents/{obj.pk}/", "meta": { "uploaded_by": str(obj.uploaded_by) if hasattr(obj, "uploaded_by") and obj.uploaded_by @@ -247,23 +233,34 @@ def _format_document(obj: Any) -> Dict[str, Any]: } +def _get_notification_search_title(obj: Any) -> str: + """Строит заголовок уведомления для поиска без legacy-зависимостей.""" + data = getattr(obj, "data", None) or {} + if isinstance(data, dict) and data.get("title"): + return str(data["title"]) + + verb = getattr(obj, "verb", "") or "notification" + return str(verb).replace("_", " ").title() + + +def _get_notification_search_message(obj: Any) -> str: + """Возвращает текст уведомления для результатов поиска.""" + return getattr(obj, "description", "") or "" + + def _format_notification(obj: Any) -> Dict[str, Any]: """Форматирует уведомление для API.""" + message = _get_notification_search_message(obj) + return { "model_name": "notification", "object_id": obj.pk, - "title": obj.title, - "description": obj.short_message - or ( - obj.message[:150] + "..." if len(obj.message) > 150 else obj.message - ), - "url": obj.action_url - if hasattr(obj, "action_url") and obj.action_url - else "/notifications/", + "title": _get_notification_search_title(obj), + "description": message[:150] + "..." if len(message) > 150 else message, "meta": { - "is_read": obj.is_read if hasattr(obj, "is_read") else False, - "created_at": obj.created_at.isoformat() - if hasattr(obj, "created_at") + "is_read": not getattr(obj, "unread", True), + "created_at": obj.timestamp.isoformat() + if hasattr(obj, "timestamp") and obj.timestamp else None, }, } @@ -296,7 +293,6 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]: "object_id": obj.pk, "title": str(obj), "description": "", - "url": "#", "meta": {}, } @@ -339,7 +335,6 @@ def search_api_view(request: DRFRequest) -> Response: "object_id": 123, "title": "Заголовок", "description": "Описание", - "url": "/path/to/object/", "meta": {...} }, ... diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index c35b787a..a434b453 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -1,6 +1,7 @@ """ API endpoints для системы уведомлений v2.0 + Новая архитектура: - Notification с GenericForeignKey (actor/action_object/target) - verb вместо category/type @@ -35,16 +36,6 @@ from .serializers import ( ) -def _get_notification_title(notification): - """Получить заголовок уведомления из data или сгенерировать fallback""" - # Приоритет: title из data (задается при создании уведомления) - if notification.data and 'title' in notification.data: - return notification.data['title'] - - # Fallback: генерируем простой заголовок из verb - return notification.verb.replace('_', ' ').title() - - def _format_time_or_none(value): if value is None: return None @@ -126,7 +117,11 @@ def get_notifications(request): { 'id': n.id, # Frontend ожидает title, message, is_read, created_at - 'title': _get_notification_title(n), + 'title': ( + n.data['title'] + if n.data and 'title' in n.data + else n.verb.replace('_', ' ').title() + ), 'message': n.description, 'short_message': ( n.description[:100] + '...' diff --git a/backend/tests/api/v1/test_search.py b/backend/tests/api/v1/test_search.py index 13174733..713da9d4 100644 --- a/backend/tests/api/v1/test_search.py +++ b/backend/tests/api/v1/test_search.py @@ -10,6 +10,7 @@ from rest_framework.test import APIClient from employees.models import Employee, Department, Position from feed.models import Post +from notifications.models import Notification from requests_app.models import Request @@ -251,7 +252,7 @@ class TestSearchAPI: assert 'model_name' in result assert 'object_id' in result assert 'title' in result - assert 'url' in result + assert 'url' not in result def test_search_request_permissions(self, api_client, sample_data): """Пользователи видят только свои заявления.""" @@ -296,6 +297,30 @@ class TestSearchAPI: posts = [r for r in response.data['results'] if r['model_name'] == 'post'] assert len(posts) > 0 + def test_search_formats_notification_v2_without_500(self, api_client, regular_user): + """Поиск по уведомлениям v2 не падает на legacy-ожиданиях формата.""" + from django.core.management import call_command + + Notification.objects.create( + recipient=regular_user, + verb="system_notice", + description="Это обычный текст уведомления для поиска", + data={"title": "Важное уведомление"}, + action_url="/documents/5/", + ) + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=обычный') + + assert response.status_code == status.HTTP_200_OK + notifications = [ + r for r in response.data['results'] if r['model_name'] == 'notification' + ] + assert len(notifications) > 0 + assert notifications[0]['title'] == 'Важное уведомление' + assert 'обычный текст уведомления' in notifications[0]['description'].lower() + assert 'url' not in notifications[0] + @pytest.mark.django_db class TestSearchIndexing: diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 55550bb6..1fc36772 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -4,6 +4,7 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Document } from "@/types/api"; import { Search, @@ -62,6 +63,9 @@ function formatDate(value?: string): string { } export default function DocumentsPage() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); // State const [documents, setDocuments] = useState([]); const [folders, setFolders] = useState([]); @@ -95,6 +99,19 @@ export default function DocumentsPage() { // Bulk selection const selection = useDocumentSelection(documents); + const linkedDocumentId = Number(searchParams.get("document") || ""); + + const clearDocumentParam = () => { + if (!searchParams.get("document")) return; + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("document"); + router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); + }; + + const closeSelectedDocument = () => { + setSelectedDocument(null); + clearDocumentParam(); + }; const loadDocuments = async () => { try { @@ -143,6 +160,26 @@ export default function DocumentsPage() { loadTags(); }, []); + useEffect(() => { + if (!linkedDocumentId || selectedDocument?.id === linkedDocumentId) return; + + let cancelled = false; + + apiClient.getDocument(linkedDocumentId) + .then((document) => { + if (!cancelled) { + setSelectedDocument(document); + } + }) + .catch((error) => { + console.error("Ошибка deep-link документа:", error); + }); + + return () => { + cancelled = true; + }; + }, [linkedDocumentId, selectedDocument?.id]); + const filteredDocuments = useMemo(() => { const q = search.trim().toLowerCase(); @@ -813,7 +850,7 @@ export default function DocumentsPage() { setSelectedDocument(null)} + onClose={closeSelectedDocument} onUpdate={() => { loadDocuments(); if (selectedDocument) { diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 74dd5cb4..acc0cf4a 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; -import { useParams } from "next/navigation"; +import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; import { MessageCircle, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; @@ -62,6 +62,9 @@ function formatDateInputValue(date: Date): string { export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const chatId = Number(params.chatId); const { user, loading: userLoading } = useUser(); const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null); @@ -154,6 +157,7 @@ export default function MessageDialogPage() { const [searchError, setSearchError] = useState(null); const [nextSearchOffset, setNextSearchOffset] = useState(null); const [highlightedMessageId, setHighlightedMessageId] = useState(null); + const linkedMessageId = Number(searchParams.get("message") || ""); // Sync settings from chat useEffect(() => { @@ -657,6 +661,36 @@ export default function MessageDialogPage() { }, 2200); }, []); + const clearMessageParam = useCallback(() => { + if (!searchParams.get("message")) return; + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("message"); + router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); + }, [pathname, router, searchParams]); + + useEffect(() => { + if (!linkedMessageId) { + return; + } + + let cancelled = false; + + void (async () => { + const jumped = await cm.jumpToMessage(linkedMessageId); + if (cancelled) { + return; + } + if (jumped) { + highlightFoundMessage(linkedMessageId); + } + clearMessageParam(); + })(); + + return () => { + cancelled = true; + }; + }, [clearMessageParam, cm.jumpToMessage, highlightFoundMessage, linkedMessageId]); + const runSearch = useCallback(async (query: string, options?: { append?: boolean; offset?: number }) => { const normalizedQuery = query.trim(); if (!chatId || normalizedQuery.length < 2) { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 7b345da6..0d7a9071 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -6,6 +6,7 @@ import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; import { useEffect, useRef, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Comment, Post } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; @@ -19,6 +20,9 @@ type LikeUser = { export default function Home() { const { user } = useUser(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -77,6 +81,19 @@ export default function Home() { const auth = user?.auth; const authPerms = auth?.permissions || []; const authByApp = auth?.permissions_by_app || {}; + const linkedPostId = Number(searchParams.get("post") || ""); + + const clearPostParam = () => { + if (!searchParams.get("post")) return; + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("post"); + router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); + }; + + const closeCommentsModal = () => { + setCommentsOpen(false); + clearPostParam(); + }; const hasPermission = (perm: string) => { if (!perm) return false; @@ -171,7 +188,7 @@ export default function Home() { if (!commentsOpen && !createPostOpen) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { - setCommentsOpen(false); + closeCommentsModal(); } }; @@ -296,6 +313,16 @@ export default function Home() { await loadComments(post.id); }; + useEffect(() => { + if (!linkedPostId || posts.length === 0) return; + if (commentsOpen && activePost?.id === linkedPostId) return; + + const targetPost = posts.find((post) => post.id === linkedPostId); + if (!targetPost) return; + + void openComments(targetPost); + }, [activePost?.id, commentsOpen, linkedPostId, posts]); + const handleCreateComment = async () => { if (!activePost) return; const text = newComment.trim(); @@ -550,7 +577,7 @@ export default function Home() { : `${Math.floor(diffHours / 24)} дн. назад`; return ( -
+
@@ -787,7 +814,7 @@ export default function Home() { setCommentsOpen(false)} + onClose={closeCommentsModal} title="Комментарии" size="lg" noPadding diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx index 063b68b3..6e53d094 100644 --- a/frontend/src/app/search/page.tsx +++ b/frontend/src/app/search/page.tsx @@ -8,6 +8,41 @@ 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"; +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 modelIcons: Record> = { employee: User, @@ -166,7 +201,7 @@ function SearchPageContent() { {results.map((result) => (
diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index fb59ef2f..010a51d6 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -4,6 +4,7 @@ import { Calendar, dateFnsLocalizer, View, ToolbarProps, NavigateAction } from " import { format, parse, startOfWeek, getDay } from "date-fns"; import { ru } from "date-fns/locale"; import { useState, useEffect, useCallback } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { apiClient } from "@/lib/api"; import { useCalendar } from "@/contexts/CalendarContext"; import { X, Plus, Trash2, Settings, ChevronLeft, ChevronRight } from "lucide-react"; @@ -122,6 +123,9 @@ const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps([]); const [loading, setLoading] = useState(false); @@ -139,6 +143,14 @@ export function BigCalendar() { const [selectedDate, setSelectedDate] = useState(null); const [showEventDetailsModal, setShowEventDetailsModal] = useState(false); const [viewingEvent, setViewingEvent] = useState(null); + const linkedEventId = Number(searchParams.get("event") || ""); + + const clearEventParam = useCallback(() => { + if (!searchParams.get("event")) return; + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("event"); + router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); + }, [pathname, router, searchParams]); // Загрузка событий const loadEvents = useCallback(async () => { @@ -220,6 +232,32 @@ export function BigCalendar() { loadEvents(); }, [loadEvents]); + useEffect(() => { + if (!linkedEventId || viewingEvent?.id === linkedEventId) return; + + let cancelled = false; + + apiClient.getEvent(linkedEventId) + .then((event) => { + if (cancelled) return; + + setViewingEvent({ + ...event, + start: new Date(event.start), + end: new Date(event.end), + }); + setCurrentDate(new Date(event.start)); + setShowEventDetailsModal(true); + }) + .catch((error) => { + console.error("Ошибка deep-link события календаря:", error); + }); + + return () => { + cancelled = true; + }; + }, [linkedEventId, viewingEvent?.id]); + // Обработчики календаря const handleSelectSlot = useCallback( ({ start }: { start: Date; end: Date }) => { @@ -464,6 +502,7 @@ export function BigCalendar() { onClose={() => { setShowEventDetailsModal(false); setViewingEvent(null); + clearEventParam(); }} event={viewingEvent} onEdit={handleEditFromDetails} diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts index e7ecad85..04665eda 100644 --- a/frontend/src/lib/api/requests.ts +++ b/frontend/src/lib/api/requests.ts @@ -29,6 +29,7 @@ export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) { const qs = qp.toString(); return request(`/api/v1/requests/${qs ? '?' + qs : ''}`); }, + getRequest: (requestId: number) => request(`/api/v1/requests/${requestId}/`), createRequest: (data: Record, saveAs?: 'draft' | 'submitted') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs), updateRequest: (requestId: number, data: Record, saveAs?: 'draft' | 'submitted') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs), deleteRequest: (requestId: number): Promise => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }), diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ace05f0d..f094630b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -676,7 +676,6 @@ export interface SearchResult { object_id: number; title: string; description?: string; - url: string; meta?: Record; }