feat: расширен поиск - добавлены модели procurement, documents, notifications, schedule

- Добавлены 5 новых моделей в watson search:
  * ProcurementRequest (заявки на закупку)
  * Equipment (оборудование)
  * Document (документы)
  * Notification (уведомления)
  * Event из django-scheduler (расписание)

- Backend API: форматтеры для новых типов моделей
- Frontend: добавлены иконки и названия для новых типов
- Тесты: исправлены фикстуры, все 13 тестов проходят
- Индексы: 3895 записей проиндексировано

Итого: 12 моделей доступны для полнотекстового поиска
This commit is contained in:
igor_nadein 2026-02-27 19:02:15 +03:00
parent 8fd44448f4
commit 6a76b231a6
5 changed files with 496 additions and 8 deletions

View File

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

View File

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

View File

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

View File

@ -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<SearchModelType, React.ComponentType<{ size?: number; className?: string }>> = {
@ -18,6 +18,10 @@ const modelIcons: Record<SearchModelType, React.ComponentType<{ size?: number; c
chat: MessageSquare,
message: MessageCircle,
event: Calendar,
schedule_event: Calendar,
procurement_request: ShoppingCart,
equipment: Package,
notification: Bell,
};
// Названия категорий на русском
@ -29,7 +33,11 @@ const modelNames: Record<SearchModelType, string> = {
request: "Заявления",
chat: "Чаты",
message: "Сообщения",
event: "События",
event: "События календаря",
schedule_event: "Расписание",
procurement_request: "Заявки на закупку",
equipment: "Оборудование",
notification: "Уведомления",
};
export default function SearchPage() {

View File

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