Refine search routing and deep links

This commit is contained in:
lizerk 2026-04-03 20:30:29 +03:00
parent a7abde279e
commit 9b23f480c2
11 changed files with 234 additions and 48 deletions

View File

@ -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
)

View File

@ -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": {...}
},
...

View File

@ -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] + '...'

View File

@ -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:

View File

@ -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<Document[]>([]);
const [folders, setFolders] = useState<FolderNode[]>([]);
@ -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() {
<DocumentDetailModal
document={selectedDocument}
isOpen={!!selectedDocument}
onClose={() => setSelectedDocument(null)}
onClose={closeSelectedDocument}
onUpdate={() => {
loadDocuments();
if (selectedDocument) {

View File

@ -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<string | null>(null);
const [nextSearchOffset, setNextSearchOffset] = useState<number | null>(null);
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(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) {

View File

@ -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<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<article key={post.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<article id={`post-${post.id}`} key={post.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<header className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="relative h-10 w-10">
@ -787,7 +814,7 @@ export default function Home() {
<Modal
isOpen={commentsOpen && !!activePost}
onClose={() => setCommentsOpen(false)}
onClose={closeCommentsModal}
title="Комментарии"
size="lg"
noPadding

View File

@ -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<SearchModelType, React.ComponentType<{ size?: number; className?: string }>> = {
employee: User,
@ -166,7 +201,7 @@ function SearchPageContent() {
{results.map((result) => (
<Link
key={`${result.model_name}-${result.object_id}`}
href={result.url}
href={buildSearchHref(result)}
className="block rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50/50 hover:shadow-sm"
>
<div className="flex items-start justify-between gap-2">

View File

@ -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<C
};
export function BigCalendar() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { calendars, selectedCalendarId, setSelectedCalendarId, reloadCalendars } = useCalendar();
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
@ -139,6 +143,14 @@ export function BigCalendar() {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(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}

View File

@ -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<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submitted') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
deleteRequest: (requestId: number): Promise<void> => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),

View File

@ -676,7 +676,6 @@ export interface SearchResult {
object_id: number;
title: string;
description?: string;
url: string;
meta?: Record<string, any>;
}