From 6a76b231a636fd82c8c67614574a5f9bb794e40a Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Fri, 27 Feb 2026 19:02:15 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20-=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8=20procurement,=20documents,=20notifica?= =?UTF-8?q?tions,=20schedule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлены 5 новых моделей в watson search: * ProcurementRequest (заявки на закупку) * Equipment (оборудование) * Document (документы) * Notification (уведомления) * Event из django-scheduler (расписание) - Backend API: форматтеры для новых типов моделей - Frontend: добавлены иконки и названия для новых типов - Тесты: исправлены фикстуры, все 13 тестов проходят - Индексы: 3895 записей проиндексировано Итого: 12 моделей доступны для полнотекстового поиска --- backend/api/v1/search/views.py | 100 ++++++++- backend/search/search_indexes.py | 67 +++++- backend/tests/api/v1/test_search.py | 323 ++++++++++++++++++++++++++++ frontend/src/app/search/page.tsx | 12 +- frontend/src/types/api.ts | 2 +- 5 files changed, 496 insertions(+), 8 deletions(-) create mode 100644 backend/tests/api/v1/test_search.py diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index f5ebfad3..d664c789 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -31,6 +31,11 @@ def _get_model_name(obj: Any) -> str: "chat": "chat", "message": "message", "calendarevent": "event", + "event": "schedule_event", # django-scheduler Event + "procurementrequest": "procurement_request", + "equipment": "equipment", + "document": "document", + "notification": "notification", } return mapping.get(model_name, model_name) @@ -46,7 +51,7 @@ def _format_employee(obj: Any) -> Dict[str, Any]: "url": f"/employees/{obj.pk}/", "meta": { "email": obj.email, - "phone": obj.phone_number or "", + "phone": str(obj.phone_number) if obj.phone_number else "", } } @@ -126,7 +131,7 @@ def _format_message(obj: Any) -> Dict[str, Any]: def _format_event(obj: Any) -> Dict[str, Any]: - """Форматирует календарное событие для API.""" + """Форматирует календарное событие (CalendarEvent) для API.""" return { "model_name": "event", "object_id": obj.pk, @@ -139,6 +144,83 @@ def _format_event(obj: Any) -> Dict[str, Any]: } +def _format_schedule_event(obj: Any) -> Dict[str, Any]: + """Форматирует событие расписания (django-scheduler Event) для API.""" + return { + "model_name": "schedule_event", + "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() if hasattr(obj, "end") and obj.end else None, + } + } + + +def _format_procurement_request(obj: Any) -> Dict[str, Any]: + """Форматирует заявку на закупку для API.""" + return { + "model_name": "procurement_request", + "object_id": obj.pk, + "title": obj.title, + "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) if hasattr(obj, "requestor") else None, + "department": str(obj.department) if hasattr(obj, "department") else None, + } + } + + +def _format_equipment(obj: Any) -> Dict[str, Any]: + """Форматирует оборудование для API.""" + return { + "model_name": "equipment", + "object_id": obj.pk, + "title": obj.name, + "description": f"Инв. №: {obj.inventory_number}" + (f", Сер. №: {obj.serial_number}" if obj.serial_number else ""), + "url": f"/procurement/equipment/{obj.pk}/", + "meta": { + "inventory_number": obj.inventory_number, + "status": obj.status if hasattr(obj, "status") else None, + "location": obj.location if hasattr(obj, "location") else None, + } + } + + +def _format_document(obj: Any) -> Dict[str, Any]: + """Форматирует документ для API.""" + return { + "model_name": "document", + "object_id": obj.pk, + "title": obj.title, + "description": obj.description[:200] + "..." 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 else None, + "uploaded_at": obj.uploaded_at.isoformat() if hasattr(obj, "uploaded_at") else None, + } + } + + +def _format_notification(obj: Any) -> Dict[str, Any]: + """Форматирует уведомление для API.""" + 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/", + "meta": { + "is_read": obj.is_read if hasattr(obj, "is_read") else False, + "created_at": obj.created_at.isoformat() if hasattr(obj, "created_at") else None, + } + } + + def _format_result(obj: Any, model_name: str) -> Dict[str, Any]: """Форматирует результат поиска в зависимости от типа модели.""" formatters = { @@ -149,6 +231,11 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]: "chat": _format_chat, "message": _format_message, "event": _format_event, + "schedule_event": _format_schedule_event, + "procurement_request": _format_procurement_request, + "equipment": _format_equipment, + "document": _format_document, + "notification": _format_notification, } formatter = formatters.get(model_name) @@ -188,7 +275,7 @@ def search_api_view(request: DRFRequest) -> Response: "query": "поисковый запрос", "results": [ { - "model_name": "post|employee|department|request|chat|message|event", + "model_name": "post|employee|department|request|chat|message|event|procurement_request|equipment|document|notification", "object_id": 123, "title": "Заголовок", "description": "Описание", @@ -231,12 +318,17 @@ def search_api_view(request: DRFRequest) -> Response: obj = search_result.object model_name = _get_model_name(obj) - # Фильтрация заявлений по правам доступа + # Фильтрация по правам доступа if model_name == "request": is_hr = _is_hr(request.user) if not is_hr and hasattr(obj, 'employee') and obj.employee != request.user: # type: ignore[attr-defined] continue # Пропускаем чужие заявления + # Фильтрация уведомлений - показываем только свои + if model_name == "notification": + if hasattr(obj, 'recipient') and obj.recipient != request.user: # type: ignore[attr-defined] + continue # Пропускаем чужие уведомления + # Подсчитываем model_counts[model_name] = model_counts.get(model_name, 0) + 1 diff --git a/backend/search/search_indexes.py b/backend/search/search_indexes.py index d011b641..bf43cfac 100644 --- a/backend/search/search_indexes.py +++ b/backend/search/search_indexes.py @@ -81,11 +81,76 @@ if Message: store=("content", "created_at"), ) -# 7. Календарные события (Calendar) +# 7. Календарные события (Calendar App) CalendarEvent = get_model_safe("calendar_app", "CalendarEvent") if CalendarEvent: + from watson.search import SearchAdapter + + class CalendarEventAdapter(SearchAdapter): + """Кастомный адаптер для CalendarEvent с правильным URL.""" + + def get_url(self, obj): + """Возвращает URL календаря (общий календарь компании).""" + return "/calendar/" + register( CalendarEvent, + adapter_cls=CalendarEventAdapter, fields=("title", "description"), store=("title", "start_date"), ) + +# 8. События расписания (Django-Scheduler) +Event = get_model_safe("schedule", "Event") +if Event: + from watson.search import SearchAdapter + + class ScheduleEventAdapter(SearchAdapter): + """Кастомный адаптер для Event из django-scheduler.""" + + def get_url(self, obj): + """Возвращает URL календаря.""" + return "/calendar/" + + register( + Event, + adapter_cls=ScheduleEventAdapter, + fields=("title", "description"), + store=("title", "start"), + ) + +# 9. Заявки на закупку (Procurement) +ProcurementRequest = get_model_safe("procurement", "ProcurementRequest") +if ProcurementRequest: + register( + ProcurementRequest, + fields=("title", "description"), + store=("title", "status", "created_at"), + ) + +# 10. Оборудование (Procurement) +Equipment = get_model_safe("procurement", "Equipment") +if Equipment: + register( + Equipment, + fields=("name", "inventory_number", "serial_number", "location"), + store=("name", "inventory_number", "status"), + ) + +# 11. Документы (Documents) +Document = get_model_safe("documents", "Document") +if Document: + register( + Document, + fields=("title", "description"), + store=("title", "uploaded_at"), + ) + +# 12. Уведомления (Notifications) +Notification = get_model_safe("notifications", "Notification") +if Notification: + register( + Notification, + fields=("title", "message", "short_message"), + store=("title", "is_read", "created_at"), + ) diff --git a/backend/tests/api/v1/test_search.py b/backend/tests/api/v1/test_search.py new file mode 100644 index 00000000..13174733 --- /dev/null +++ b/backend/tests/api/v1/test_search.py @@ -0,0 +1,323 @@ +# backend/tests/api/v1/test_search.py +""" +Тесты для глобального поиска через django-watson. +""" +import itertools +import pytest +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APIClient + +from employees.models import Employee, Department, Position +from feed.models import Post +from requests_app.models import Request + + +_phone_counter = itertools.count(100_000_000) +_email_counter = itertools.count(1) + + +@pytest.fixture +def make_user(db): + """Фабрика пользователей.""" + User = get_user_model() + + def _make_user(email=None, password="pass12345", **extra): + if email is None: + email = f"user{next(_email_counter)}@example.com" + phone = extra.pop("phone_number", None) or f"+79{next(_phone_counter):09d}" + user = User.objects.create_user( + email=email, + phone_number=phone, + send_activation_email=False, + **extra + ) + if password: + user.set_password(password) + user.save() + return user + + return _make_user + + +@pytest.fixture +def regular_user(make_user): + """Обычный пользователь без спецправ.""" + return make_user(email="regular@example.com", first_name="Регуляр", last_name="Пользователь") + + +@pytest.fixture +def api_client(regular_user): + """Авторизованный DRF-клиент.""" + client = APIClient() + client.force_authenticate(user=regular_user) + return client + + +@pytest.fixture +def sample_data(db): + """Создаёт тестовые данные для поиска.""" + # Отделы + dept_it = Department.objects.create(name="IT отдел", description="Информационные технологии") + dept_hr = Department.objects.create(name="HR отдел", description="Управление персоналом") + + # Должности + pos_dev = Position.objects.create(name="Разработчик", description="Python/Django разработчик") + pos_manager = Position.objects.create(name="Менеджер", description="HR менеджер") + + # Сотрудники + emp1 = Employee.objects.create( + email="ivanov@test.com", + first_name="Иван", + last_name="Иванов", + patronymic="Иванович", + position=pos_dev, + phone_number="+79001234567" + ) + emp1.departments_links.create(department=dept_it) + + emp2 = Employee.objects.create( + email="petrov@test.com", + first_name="Пётр", + last_name="Петров", + patronymic="Петрович", + position=pos_manager, + phone_number="+79007654321" + ) + emp2.departments_links.create(department=dept_hr) + + # Посты + post1 = Post.objects.create( + author=emp1, + title="Новые технологии в разработке", + body="Сегодня мы внедрили новый фреймворк Django для нашего проекта" + ) + + post2 = Post.objects.create( + author=emp2, + title="Вакансии в HR отделе", + body="Требуется менеджер по подбору персонала" + ) + + # Заявления + req1 = Request.objects.create( + employee=emp1, + title="Заявка на отпуск", + comment="Прошу предоставить отпуск с 1 по 14 февраля", + type="vacation", + status="pending" + ) + + return { + 'departments': [dept_it, dept_hr], + 'employees': [emp1, emp2], + 'posts': [post1, post2], + 'requests': [req1], + } + + +@pytest.mark.django_db +class TestSearchAPI: + """Тесты для API /api/v1/search/""" + + def test_search_requires_authentication(self, client): + """Поиск требует аутентификации.""" + response = client.get('/api/v1/search/?q=test') + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_search_empty_query(self, api_client): + """Пустой запрос возвращает пустые результаты.""" + response = api_client.get('/api/v1/search/?q=') + assert response.status_code == status.HTTP_200_OK + assert response.data['query'] == '' + assert response.data['total'] == 0 + assert len(response.data['results']) == 0 + + def test_search_no_results(self, api_client, sample_data): + """Поиск без результатов.""" + # Пересоздаём индексы watson для тестовых данных + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=несуществующийтекст12345') + assert response.status_code == status.HTTP_200_OK + assert response.data['total'] == 0 + + def test_search_finds_employee_by_name(self, api_client, sample_data): + """Поиск находит сотрудника по имени.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=Иванов') + assert response.status_code == status.HTTP_200_OK + assert response.data['total'] > 0 + assert 'employee' in response.data['counts'] + + # Проверяем, что в результатах есть сотрудник + employees = [r for r in response.data['results'] if r['model_name'] == 'employee'] + assert len(employees) > 0 + assert 'Иванов' in employees[0]['title'] + + def test_search_finds_employee_by_email(self, api_client, sample_data): + """Поиск находит сотрудника по email.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=ivanov@test.com') + assert response.status_code == status.HTTP_200_OK + + employees = [r for r in response.data['results'] if r['model_name'] == 'employee'] + assert len(employees) > 0 + assert 'ivanov@test.com' in employees[0]['meta']['email'].lower() + + def test_search_finds_department(self, api_client, sample_data): + """Поиск находит отдел.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=IT') + assert response.status_code == status.HTTP_200_OK + + departments = [r for r in response.data['results'] if r['model_name'] == 'department'] + assert len(departments) > 0 + assert 'IT' in departments[0]['title'] + + def test_search_finds_post(self, api_client, sample_data): + """Поиск находит пост.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=Django') + assert response.status_code == status.HTTP_200_OK + + posts = [r for r in response.data['results'] if r['model_name'] == 'post'] + assert len(posts) > 0 + assert 'Django' in posts[0]['description'] or 'Django' in posts[0]['title'] + + def test_search_finds_request(self, api_client, sample_data): + """Поиск находит заявление.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + # Авторизуемся как автор заявления + emp = sample_data['employees'][0] + api_client.force_authenticate(user=emp) + + response = api_client.get('/api/v1/search/?q=отпуск') + assert response.status_code == status.HTTP_200_OK + + requests = [r for r in response.data['results'] if r['model_name'] == 'request'] + assert len(requests) > 0 + assert 'отпуск' in requests[0]['title'].lower() + + def test_search_limit_parameter(self, api_client, sample_data): + """Параметр limit ограничивает количество результатов на тип.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + # Создадим больше 2 постов + emp = sample_data['employees'][0] + for i in range(5): + Post.objects.create( + author=emp, + title=f"Тестовый пост {i}", + body="Django разработка" + ) + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=Django&limit=2') + assert response.status_code == status.HTTP_200_OK + + posts = [r for r in response.data['results'] if r['model_name'] == 'post'] + assert len(posts) <= 2 + + def test_search_response_structure(self, api_client, sample_data): + """Проверка структуры ответа API.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=Иванов') + assert response.status_code == status.HTTP_200_OK + + # Проверяем ключи верхнего уровня + assert 'query' in response.data + assert 'results' in response.data + assert 'counts' in response.data + assert 'total' in response.data + + # Проверяем структуру результата + if len(response.data['results']) > 0: + result = response.data['results'][0] + assert 'model_name' in result + assert 'object_id' in result + assert 'title' in result + assert 'url' in result + + def test_search_request_permissions(self, api_client, sample_data): + """Пользователи видят только свои заявления.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + emp1 = sample_data['employees'][0] + emp2 = sample_data['employees'][1] + + # Создаём заявление от emp1 + Request.objects.create( + employee=emp1, + title="Личная заявка Иванова", + comment="Конфиденциально", + type="vacation", + status="pending" + ) + call_command('buildwatson', verbosity=0) + + # Авторизуемся как emp2 + api_client.force_authenticate(user=emp2) + + response = api_client.get('/api/v1/search/?q=Иванова') + assert response.status_code == status.HTTP_200_OK + + # emp2 не должен видеть заявку emp1 + requests = [r for r in response.data['results'] if r['model_name'] == 'request'] + for req in requests: + # Если есть заявления в результатах, они не должны быть от emp1 + if 'Личная заявка' in req['title']: + pytest.fail("Пользователь видит чужое заявление") + + def test_search_cyrillic_query(self, api_client, sample_data): + """Поиск по кириллице работает корректно.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=технологии') + assert response.status_code == status.HTTP_200_OK + + # Должны найти пост "Новые технологии в разработке" + posts = [r for r in response.data['results'] if r['model_name'] == 'post'] + assert len(posts) > 0 + + +@pytest.mark.django_db +class TestSearchIndexing: + """Тесты индексации watson.""" + + def test_watson_index_auto_updates(self, api_client, sample_data): + """Индекс watson обновляется автоматически при изменении данных.""" + from django.core.management import call_command + call_command('buildwatson', verbosity=0) + + # Создаём нового сотрудника + new_emp = Employee.objects.create( + email="sidorov@test.com", + first_name="Сидор", + last_name="Сидоров", + patronymic="Сидорович", + ) + + # watson должен автоматически добавить в индекс + response = api_client.get('/api/v1/search/?q=Сидоров') + assert response.status_code == status.HTTP_200_OK + + employees = [r for r in response.data['results'] if r['model_name'] == 'employee'] + assert len(employees) > 0 + assert 'Сидоров' in employees[0]['title'] diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx index c64c2c70..9e89cccb 100644 --- a/frontend/src/app/search/page.tsx +++ b/frontend/src/app/search/page.tsx @@ -6,7 +6,7 @@ import { useSearchParams } from "next/navigation"; import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import type { SearchResponse, SearchResult, SearchModelType } from "@/types/api"; -import { Search, User, Building, FileText, MessageSquare, MessageCircle, Calendar, Inbox } from "lucide-react"; +import { Search, User, Building, FileText, MessageSquare, MessageCircle, Calendar, Inbox, ShoppingCart, Package, Bell } from "lucide-react"; // Иконки для разных типов результатов const modelIcons: Record> = { @@ -18,6 +18,10 @@ const modelIcons: Record = { request: "Заявления", chat: "Чаты", message: "Сообщения", - event: "События", + event: "События календаря", + schedule_event: "Расписание", + procurement_request: "Заявки на закупку", + equipment: "Оборудование", + notification: "Уведомления", }; export default function SearchPage() { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 9229f800..18b8b3f0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -281,7 +281,7 @@ export interface RegisterData { } // Search types (django-watson) -export type SearchModelType = 'post' | 'employee' | 'department' | 'request' | 'chat' | 'message' | 'event'; +export type SearchModelType = 'post' | 'employee' | 'department' | 'request' | 'chat' | 'message' | 'event' | 'schedule_event' | 'procurement_request' | 'equipment' | 'document' | 'notification'; export interface SearchResult { model_name: SearchModelType;