Merge feature/replace-search-with-django-watson into develop

Расширение поиска: добавлены модели procurement, documents, notifications, schedule.
Всего 12 моделей доступны для полнотекстового поиска через django-watson.
This commit is contained in:
igor_nadein 2026-02-27 19:02:45 +03:00
commit 5806811d88
14 changed files with 1107 additions and 530 deletions

View File

@ -0,0 +1,2 @@
# backend/api/v1/search/__init__.py
"""API для глобального поиска через django-watson."""

View File

@ -0,0 +1,41 @@
# backend/api/v1/search/serializers.py
"""
Сериализаторы для API поиска через django-watson.
"""
from __future__ import annotations
from rest_framework import serializers
from typing import Any
class SearchResultSerializer(serializers.Serializer):
"""Сериализатор для результата поиска watson."""
model_name = serializers.CharField(
help_text="Тип модели: post, employee, department, request, chat, message, event"
)
object_id = serializers.IntegerField(help_text="ID объекта")
title = serializers.CharField(help_text="Заголовок/имя объекта", allow_blank=True)
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
)
class SearchResponseSerializer(serializers.Serializer):
"""Сериализатор для ответа API поиска."""
query = serializers.CharField(help_text="Поисковый запрос")
results = SearchResultSerializer(many=True)
counts = serializers.DictField(
help_text="Количество результатов по типам",
child=serializers.IntegerField()
)
total = serializers.IntegerField(help_text="Общее количество результатов")

View File

@ -0,0 +1,354 @@
# backend/api/v1/search/views.py
"""
API views для глобального поиска через django-watson.
"""
from __future__ import annotations
from typing import Any, Dict, List
from collections import Counter
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 .serializers import SearchResponseSerializer
def _get_model_name(obj: Any) -> str:
"""Определяет тип модели для результата поиска."""
model = type(obj)
model_name = model.__name__.lower()
mapping = {
"post": "post",
"employee": "employee",
"department": "department",
"request": "request",
"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)
def _format_employee(obj: Any) -> Dict[str, Any]:
"""Форматирует сотрудника для API."""
return {
"model_name": "employee",
"object_id": obj.pk,
"title": f"{obj.last_name} {obj.first_name} {obj.patronymic or ''}".strip(),
"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 "",
}
}
def _format_department(obj: Any) -> Dict[str, Any]:
"""Форматирует отдел для API."""
return {
"model_name": "department",
"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
}
}
def _format_post(obj: Any) -> Dict[str, Any]:
"""Форматирует пост для API."""
return {
"model_name": "post",
"object_id": obj.pk,
"title": obj.title,
"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() if hasattr(obj, "created_at") else None,
}
}
def _format_request(obj: Any) -> Dict[str, Any]:
"""Форматирует заявление для API."""
return {
"model_name": "request",
"object_id": obj.pk,
"title": obj.title or f"{obj.get_type_display()} - {obj.employee}",
"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,
"employee": str(obj.employee) if hasattr(obj, "employee") else None,
}
}
def _format_chat(obj: Any) -> Dict[str, Any]:
"""Форматирует чат для API."""
return {
"model_name": "chat",
"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,
}
}
def _format_message(obj: Any) -> Dict[str, Any]:
"""Форматирует сообщение для API."""
return {
"model_name": "message",
"object_id": obj.pk,
"title": f"Сообщение от {obj.author}" if hasattr(obj, "author") else f"Сообщение #{obj.pk}",
"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,
}
}
def _format_event(obj: Any) -> Dict[str, Any]:
"""Форматирует календарное событие (CalendarEvent) для API."""
return {
"model_name": "event",
"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") else None,
}
}
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 = {
"employee": _format_employee,
"department": _format_department,
"post": _format_post,
"request": _format_request,
"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)
if formatter:
return formatter(obj)
# Фоллбэк для неизвестных типов
return {
"model_name": model_name,
"object_id": obj.pk,
"title": str(obj),
"description": "",
"url": "#",
"meta": {}
}
def _is_hr(user: Any) -> bool:
"""Проверяет права на просмотр всех заявлений."""
return user.has_perm("requests_app.can_view_all_requests") or user.has_perm(
"requests_app.can_process_requests"
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def search_api_view(request: DRFRequest) -> Response:
"""
API для глобального поиска через django-watson.
Query Parameters:
- q (str): Поисковый запрос
- limit (int): Максимальное количество результатов на тип (default: 10)
Returns:
{
"query": "поисковый запрос",
"results": [
{
"model_name": "post|employee|department|request|chat|message|event|procurement_request|equipment|document|notification",
"object_id": 123,
"title": "Заголовок",
"description": "Описание",
"url": "/path/to/object/",
"meta": {...}
},
...
],
"counts": {
"post": 5,
"employee": 3,
...
},
"total": 15
}
"""
raw_q = request.query_params.get("q", "") or ""
query = escape(raw_q.strip())
limit = int(request.query_params.get("limit", 10))
if not query:
return Response(
{
"query": "",
"results": [],
"counts": {},
"total": 0
},
status=status.HTTP_200_OK
)
# Выполняем поиск через watson
search_results = watson_search(query)
# Группируем и форматируем результаты
results = []
model_counts: Dict[str, int] = {}
for search_result in search_results:
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
# Ограничиваем количество на тип
if model_counts[model_name] <= limit:
formatted = _format_result(obj, model_name)
results.append(formatted)
# Готовим ответ
response_data = {
"query": query,
"results": results,
"counts": model_counts,
"total": sum(model_counts.values())
}
# Валидируем через сериализатор (опционально, для проверки структуры)
serializer = SearchResponseSerializer(data=response_data)
if serializer.is_valid():
return Response(serializer.validated_data, status=status.HTTP_200_OK)
# Если валидация не прошла, все равно возвращаем результат (для отладки)
return Response(response_data, status=status.HTTP_200_OK)

View File

@ -27,6 +27,7 @@ from .employees.views import (
)
from .feed.views import CommentViewSet, PostViewSet
from .requests_app.views import RequestViewSet
from .search.views import search_api_view
app_name = "v1"
@ -83,6 +84,8 @@ urlpatterns = [
path("notifications/", include("api.v1.notifications.urls")),
# Procurement API
path("procurement/", include("api.v1.procurement.urls")),
# Search API
path("search/", search_api_view, name="search"),
# Router URLs (включая все ViewSets)
path("", include(router.urls)),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-02-27 15:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0040_merge_20260227_1639'),
]
operations = [
migrations.AlterField(
model_name='departmentpermission',
name='code',
field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям'), ('view_request', 'Просмотр заявлений отдела'), ('can_process_requests', 'Рассмотрение заявлений отдела'), ('manage_department_equipment', 'Управлять оборудованием отдела')], max_length=64, unique=True),
),
]

View File

@ -51,6 +51,7 @@ INSTALLED_APPS = [
"realtime.apps.RealtimeConfig", # WebSocket consumers для real-time
"communications.apps.CommunicationsConfig",
"notifications.apps.NotificationsConfig",
"watson", # django-watson для полнотекстового поиска
"search.apps.SearchConfig",
"bots",
"finance.apps.FinanceConfig",

View File

@ -42,6 +42,7 @@ django-registration==5.2.1
django-scheduler==0.12.0
django-simple-history==3.10.1
django-timezone-field==7.2.1
django-watson==1.6.3
django-widget-tweaks==1.5.0
djangorestframework==3.16.0
djangorestframework_simplejwt==5.5.1

View File

@ -4,3 +4,7 @@ from django.apps import AppConfig
class SearchConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'search'
def ready(self):
"""Импортируем регистрации watson при запуске приложения."""
import search.search_indexes # noqa: F401

View File

@ -0,0 +1,156 @@
# backend/search/search_indexes.py
"""
Регистрация моделей для полнотекстового поиска через django-watson.
Этот файл автоматически загружается при запуске Django если следовать
стандартной структуре watson.
"""
from __future__ import annotations
from watson.search import register
from django.apps import apps
def get_model_safe(app_label: str, model_name: str):
"""Безопасное получение модели (если приложение установлено)."""
try:
return apps.get_model(app_label, model_name)
except LookupError:
return None
# ==================== РЕГИСТРАЦИЯ МОДЕЛЕЙ ====================
# 1. Посты (Feed)
Post = get_model_safe("feed", "Post")
if Post:
register(
Post,
fields=("title", "body"),
store=("title", "created_at"),
)
# 2. Сотрудники (Employees)
Employee = get_model_safe("employees", "Employee")
if Employee:
register(
Employee,
fields=(
"last_name",
"first_name",
"patronymic",
"email",
"phone_number",
),
store=("last_name", "first_name", "patronymic", "email"),
)
# 3. Отделы (Departments)
Department = get_model_safe("employees", "Department")
if Department:
register(
Department,
fields=("name", "description"),
store=("name", "description"),
)
# 4. Заявления (Requests)
Request = get_model_safe("requests_app", "Request")
if Request:
register(
Request,
fields=("title", "comment"),
store=("title", "status", "created_at"),
)
# 5. Чаты (Communications)
Chat = get_model_safe("communications", "Chat")
if Chat:
register(
Chat,
fields=("name", "description"),
store=("name",),
)
# 6. Сообщения чатов (Communications)
Message = get_model_safe("communications", "Message")
if Message:
register(
Message,
fields=("content",),
store=("content", "created_at"),
)
# 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

@ -1,14 +1,20 @@
# backend/search/views.py
"""
Полнотекстовый поиск через django-watson.
Заменяет предыдущую реализацию с ручными Q-объектами на
автоматическую индексацию watson.
"""
from __future__ import annotations
from typing import Any, Dict, List, Literal, TypedDict, Optional, Tuple
from typing import Any, Dict, List, Literal, TypedDict
from collections import Counter
from django.contrib.auth.decorators import login_required
from django.core.exceptions import FieldError
from django.db.models import Q, QuerySet
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.utils.html import escape
from watson.search import search as watson_search
from feed.models import Post
from employees.models import Employee, Department
@ -49,348 +55,30 @@ def _is_hr(user: Employee) -> bool:
"requests_app.can_process_requests"
)
def _try_extend_q(model_cls: type, base_q: Q, lookups: List[Tuple[str, Any]]) -> Q:
"""Пытается добавить условия в Q, аккуратно игнорируя несуществующие поля.
Для каждого пути из lookups пытаемся выполнить пустой фильтр, чтобы проверить валидность.
Неудачные пути (FieldError) игнорируем.
def _get_model_name(obj: Any) -> str:
"""Определяет тип модели для SearchItem.
Args:
model_cls (type): Класс модели, к которой будет применён Q.
base_q (Q): Исходное условие.
lookups (List[Tuple[str, Any]]): Список пар (путь_к_полю, значение).
obj: Объект модели из результатов поиска.
Returns:
Q: Итоговое условие с добавленными (валидными) частями.
str: Название типа модели (post, employee, department, etc.)
"""
q = Q(base_q)
from django.db.models import QuerySet as _QS # локальный импорт для mypy
qs: _QS = model_cls.objects.all()
for path, value in lookups:
try:
# проверяем валидность пути: попытаемся составить запрос
_ = qs.filter(**{path: value}).values("pk")[:1]
q |= Q(**{path: value})
except FieldError:
continue
return q
def _order_by_first_valid(qs: QuerySet, fields: List[str]) -> QuerySet:
"""Сортирует QS по первому валидному полю, иначе возвращает как есть.
Args:
qs (QuerySet): Исходный QS.
fields (List[str]): Список полей приоритета сортировки.
Returns:
QuerySet: Отсортированный QS или оригинал.
"""
for f in fields:
try:
test = qs.order_by(f)
# прогрев запроса для раннего исключения FieldError
_ = test.values("pk")[:0]
return test
except FieldError:
continue
return qs
# ----------------------------- ОСНОВНЫЕ ПОИСКИ -----------------------------
def _search_posts(query: str) -> QuerySet[Post]:
"""Поиск по постам (title/body).
Args:
query (str): Строка запроса.
Returns:
QuerySet[Post]: Отсортированный QS.
"""
if not query:
return Post.objects.none()
return (
Post.objects.filter(Q(title__icontains=query) | Q(body__icontains=query))
.only("id", "title", "body", "created_at")
.order_by("-created_at")
)
def _search_employees(query: str) -> QuerySet[Employee]:
"""Поиск по сотрудникам (ФИО/контакты + принадлежность к отделам + навыки).
Ищем:
- ФИО, email, телефон
- Принадлежность к отделам (по названию отдела) через EmployeeDepartment
- Навыки (по названию навыка)
Args:
query (str): Строка запроса.
Returns:
QuerySet[Employee]: Отсортированный QS без дублей.
Raises:
ValueError: Если query пустая строка после trim (обрабатывается возвратом .none()).
"""
if not query:
return Employee.objects.none()
fio_contacts = (
Q(last_name__icontains=query)
| Q(first_name__icontains=query)
| Q(patronymic__icontains=query)
| Q(email__icontains=query)
| Q(phone_number__icontains=query)
)
# 🔹 ВАЖНО: у вас связь через EmployeeDepartment → departments_links
dept_lookups = [
("departments_links__department__name__icontains", query),
]
# Навыки — прямая M2M
skill_lookups = [
("skills__name__icontains", query),
]
q = _try_extend_q(Employee, fio_contacts, dept_lookups)
q = _try_extend_q(Employee, q, skill_lookups)
return (
Employee.objects.filter(q)
.distinct()
.select_related("position")
.only(
"id",
"last_name",
"first_name",
"patronymic",
"email",
"phone_number",
"position__name",
)
.order_by("last_name", "first_name")
)
def _search_departments(query: str) -> QuerySet[Department]:
"""Поиск по отделам (название/описание/руководитель + сотрудники отдела).
Ищем:
- name / description
- head (ФИО)
- сотрудники отдела (ФИО/e-mail/телефон) — через Department.employeedepartment → employee
Args:
query (str): Строка запроса.
Returns:
QuerySet[Department]: Отсортированный QS без дублей.
"""
if not query:
return Department.objects.none()
base = Q(name__icontains=query) | Q(description__icontains=query)
base |= (
Q(head__last_name__icontains=query)
| Q(head__first_name__icontains=query)
| Q(head__patronymic__icontains=query)
)
# 🔹 ВАЖНО: правильный reverse related_name — "employeedepartment"
member_fields = [
"last_name__icontains",
"first_name__icontains",
"patronymic__icontains",
"email__icontains",
"phone_number__icontains",
]
member_lookups = [
(f"employeedepartment__employee__{f}", query) for f in member_fields
]
q = _try_extend_q(Department, base, member_lookups)
return (
Department.objects.filter(q)
.distinct()
.select_related("head")
.only("id", "name", "description", "head")
.order_by("name")
)
def _search_events(query: str) -> QuerySet[Any]:
"""Поиск по календарным событиям (ваша модель: calendar_app.CalendarEvent).
Args:
query (str): Строка запроса.
Returns:
QuerySet[Any]: QS CalendarEvent или пустой QS при отсутствии приложения.
"""
try:
from calendar_app.models import CalendarEvent as EventModel # type: ignore
except Exception:
return Post.objects.none()
if not query:
return EventModel.objects.none()
q = Q(title__icontains=query) | Q(description__icontains=query)
q |= Q(department__name__icontains=query)
# других связей (owner/participants) в вашей модели нет
qs = EventModel.objects.filter(q).distinct()
# Поля дат у вас: start_date/start_time (+ end_*). Сортируем по началу.
qs = qs.order_by("-start_date", "-start_time", "-id")
try:
qs = qs.select_related("department")
except Exception:
pass
return qs
def _search_requests(query: str, user: Employee, is_hr: bool) -> QuerySet[Request]:
"""Поиск по заявлениям с учётом прав.
Args:
query (str): Строка запроса.
user (Employee): Текущий пользователь.
is_hr (bool): Признак расширенных прав.
Returns:
QuerySet[Request]: Отсортированный QS.
Raises:
ValueError: Если user не передан.
"""
if not user:
raise ValueError("user is required")
base = (
Request.objects.filter(
Q(title__icontains=query)
| Q(employee__last_name__icontains=query)
| Q(employee__first_name__icontains=query)
| Q(employee__patronymic__icontains=query)
| Q(department__name__icontains=query)
)
if query
else Request.objects.none()
)
if not is_hr:
base = base.filter(employee=user)
# Если используем select_related по FK — перечисляем их в only(...)
return (
base.select_related("employee", "department", "approver")
.only(
"id",
"title",
"status",
"employee",
"department",
"approver",
"created_at",
)
.order_by("-created_at")
)
def _search_chats(query: str) -> QuerySet[Any]:
"""Поиск по чатам (название/участники/отдел).
Args:
query (str): Строка запроса.
Returns:
QuerySet[Any]: QS Chat или пустой QS, если приложение чатов отсутствует.
Notes:
Безопасно обрабатывает разные схемы (name/department/participants).
"""
try:
from communications.models import Chat # type: ignore
except Exception:
return Post.objects.none() # тип «пустой» QS (любой модели)
if not query:
return Chat.objects.none()
q = Q()
# Попробуем разные пути без падения
q = _try_extend_q(
Chat,
q,
[
("name__icontains", query),
("title__icontains", query),
("department__name__icontains", query),
("participants__last_name__icontains", query),
("participants__first_name__icontains", query),
("participants__patronymic__icontains", query),
("participants__email__icontains", query),
],
)
qs = Chat.objects.filter(q).distinct()
qs = _order_by_first_valid(qs, ["-updated_at", "-last_message_at", "-id"])
# мягкая оптимизация
try:
qs = qs.select_related("department")
except FieldError:
pass
try:
qs = qs.prefetch_related("participants")
except FieldError:
pass
return qs
def _search_messages(query: str) -> QuerySet[Any]:
"""Поиск по сообщениям чатов (контент/автор/чат).
Args:
query (str): Строка запроса.
Returns:
QuerySet[Any]: QS Message или пустой QS, если приложение чатов отсутствует.
"""
try:
from communications.models import Message # type: ignore
except Exception:
return Post.objects.none()
if not query:
return Message.objects.none()
q = Q(content__icontains=query)
q = _try_extend_q(
Message,
q,
[
("author__last_name__icontains", query),
("author__first_name__icontains", query),
("author__patronymic__icontains", query),
("chat__name__icontains", query),
("chat__title__icontains", query),
("chat__department__name__icontains", query),
],
)
qs = Message.objects.filter(q).distinct()
qs = _order_by_first_valid(qs, ["-created_at", "-id"])
try:
qs = qs.select_related("author", "chat")
except FieldError:
pass
return qs
model = type(obj)
model_name = model.__name__.lower()
# Маппинг моделей на типы для SearchItem
mapping = {
"post": "post",
"employee": "employee",
"department": "department",
"request": "request",
"chat": "chat",
"message": "message",
"calendarevent": "event",
}
return mapping.get(model_name, model_name)
# -------------------------------- ВЬЮХА --------------------------------
@ -398,7 +86,7 @@ def _search_messages(query: str) -> QuerySet[Any]:
@login_required
def search_view(request: HttpRequest) -> HttpResponse:
"""Единый поиск по системе (посты, сотрудники, отделы, заявления, чаты, сообщения, события).
"""Единый поиск по системе через django-watson.
Args:
request (HttpRequest): Запрос (`GET["q"]`).
@ -428,51 +116,38 @@ def search_view(request: HttpRequest) -> HttpResponse:
}
if query:
# --- Посты
post_qs = _search_posts(query)
counts["post"] = post_qs.count()
items.extend({"model_name": "post", "object": p} for p in post_qs[:10])
# --- Сотрудники (ФИО/контакты + отделы + навыки)
emp_qs = _search_employees(query)
counts["employee"] = emp_qs.count()
items.extend({"model_name": "employee", "object": e} for e in emp_qs[:10])
# --- Отделы (в т.ч. по сотрудникам)
dept_qs = _search_departments(query)
counts["department"] = dept_qs.count()
items.extend({"model_name": "department", "object": d} for d in dept_qs[:10])
# --- Заявления (учёт прав)
is_hr = _is_hr(request.user) # type: ignore[arg-type]
req_qs = _search_requests(query, request.user, is_hr) # type: ignore[arg-type]
counts["request"] = req_qs.count()
items.extend({"model_name": "request", "object": r} for r in req_qs[:10])
# --- Чаты
chat_qs = _search_chats(query)
# Если приложение отсутствует — chat_qs будет пустым QS другой модели, count()=0
try:
counts["chat"] = chat_qs.count()
except Exception:
counts["chat"] = 0
items.extend({"model_name": "chat", "object": c} for c in chat_qs[:10])
# --- Сообщения
msg_qs = _search_messages(query)
try:
counts["message"] = msg_qs.count()
except Exception:
counts["message"] = 0
items.extend({"model_name": "message", "object": m} for m in msg_qs[:10])
# --- Календарные события
evt_qs = _search_events(query)
try:
counts["event"] = evt_qs.count()
except Exception:
counts["event"] = 0
items.extend({"model_name": "event", "object": ev} for ev in evt_qs[:10])
# Выполняем поиск через watson
search_results = watson_search(query)
# Группируем по типам моделей
by_model: Dict[str, List[Any]] = {}
for result in search_results:
obj = result.object
model_name = _get_model_name(obj)
# Фильтрация заявлений по правам доступа
if model_name == "request":
is_hr = _is_hr(request.user) # type: ignore[arg-type]
# Проверяем, что объект - это действительно Request с полем employee
if not is_hr and hasattr(obj, 'employee') and obj.employee != request.user: # type: ignore[attr-defined]
continue # Пропускаем чужие заявления
if model_name not in by_model:
by_model[model_name] = []
by_model[model_name].append(obj)
# Формируем итоговый список с ограничением 10 элементов на тип
for model_name in ["post", "employee", "department", "request",
"chat", "message", "event"]:
objects = by_model.get(model_name, [])
counts[model_name] = len(objects)
# Берем первые 10 для отображения
for obj in objects[:10]:
items.append({
"model_name": model_name, # type: ignore[typeddict-item]
"object": obj
})
ctx = {
"query": query,

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

@ -1,47 +1,57 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { Chat, Department, Document, Post, Request, User } from "@/types/api";
import { Search } from "lucide-react";
import type { SearchResponse, SearchResult, SearchModelType } from "@/types/api";
import { Search, User, Building, FileText, MessageSquare, MessageCircle, Calendar, Inbox, ShoppingCart, Package, Bell } from "lucide-react";
type SearchState = {
employees: User[];
departments: Department[];
documents: Document[];
requests: Request[];
chats: Chat[];
posts: Post[];
// Иконки для разных типов результатов
const modelIcons: Record<SearchModelType, React.ComponentType<{ size?: number; className?: string }>> = {
employee: User,
department: Building,
post: Inbox,
document: FileText,
request: FileText,
chat: MessageSquare,
message: MessageCircle,
event: Calendar,
schedule_event: Calendar,
procurement_request: ShoppingCart,
equipment: Package,
notification: Bell,
};
const emptyState: SearchState = {
employees: [],
departments: [],
documents: [],
requests: [],
chats: [],
posts: [],
// Названия категорий на русском
const modelNames: Record<SearchModelType, string> = {
employee: "Сотрудники",
department: "Отделы",
post: "Посты",
document: "Документы",
request: "Заявления",
chat: "Чаты",
message: "Сообщения",
event: "События календаря",
schedule_event: "Расписание",
procurement_request: "Заявки на закупку",
equipment: "Оборудование",
notification: "Уведомления",
};
function contains(value: string | undefined, query: string): boolean {
return (value || "").toLowerCase().includes(query);
}
export default function SearchPage() {
const searchParams = useSearchParams();
const queryRaw = searchParams.get("q") || "";
const query = queryRaw.trim().toLowerCase();
const query = queryRaw.trim();
const [results, setResults] = useState<SearchState>(emptyState);
const [searchResponse, setSearchResponse] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query) {
setResults(emptyState);
setSearchResponse(null);
setError(null);
setLoading(false);
return;
@ -54,36 +64,11 @@ export default function SearchPage() {
setLoading(true);
setError(null);
const [employees, departments, documents, requests, chats, posts] = await Promise.all([
apiClient.getEmployees({ search: queryRaw, limit: 20 }),
apiClient.getDepartments({ search: queryRaw, limit: 20 }),
apiClient.getDocuments({ search: queryRaw, limit: 20 }),
apiClient.getRequests({ search: queryRaw, limit: 20 }),
apiClient.getChats({ search: queryRaw, limit: 20 }),
apiClient.getPosts({ search: queryRaw, limit: 20 }),
]);
const response: SearchResponse = await apiClient.search(queryRaw, 10);
if (cancelled) return;
const filtered: SearchState = {
employees: (employees.results || []).filter((u) =>
contains(`${u.last_name} ${u.first_name} ${u.patronymic || ""}`, query) ||
contains(u.email, query)
),
departments: (departments.results || []).filter((d) =>
contains(d.name, query) || contains(d.description, query)
),
documents: (documents.results || []).filter((d) =>
contains(d.title, query) || contains(d.description, query) || contains(d.document_type, query)
),
requests: (requests.results || []).filter((r) =>
contains(r.title, query) || contains(r.description, query) || contains(r.request_type, query)
),
chats: (chats.results || []).filter((c) => contains(c.name, query) || contains(c.last_message?.content, query)),
posts: (posts.results || []).filter((p) => contains(p.content, query)),
};
setResults(filtered);
setSearchResponse(response);
} catch (err) {
if (!cancelled) {
console.error("Ошибка поиска:", err);
@ -103,16 +88,16 @@ export default function SearchPage() {
};
}, [query, queryRaw]);
const total = useMemo(
() =>
results.employees.length +
results.departments.length +
results.documents.length +
results.requests.length +
results.chats.length +
results.posts.length,
[results]
);
// Группируем результаты по типам
const resultsByType: Partial<Record<SearchModelType, SearchResult[]>> = {};
if (searchResponse) {
searchResponse.results.forEach((result) => {
if (!resultsByType[result.model_name]) {
resultsByType[result.model_name] = [];
}
resultsByType[result.model_name]!.push(result);
});
}
return (
<AppShell>
@ -120,97 +105,82 @@ export default function SearchPage() {
<div className="mb-4 flex items-center gap-2">
<Search size={16} className="text-gray-400" />
<p className="text-sm text-gray-600">
{query ? `Результаты по запросу: ${queryRaw}` : "Введите запрос в поиске сверху"}
{query ? `Результаты по запросу: "${queryRaw}"` : "Введите запрос в поиске сверху"}
</p>
</div>
{query && searchResponse && (
<div className="mb-4 text-sm text-gray-500">
Найдено результатов: <span className="font-semibold">{searchResponse.total}</span>
</div>
)}
{loading ? (
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">Идёт поиск...</div>
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
<p className="mt-2">Поиск...</p>
</div>
) : error ? (
<div className="rounded-xl bg-red-50 p-6 text-center text-sm text-red-800">{error}</div>
) : !query ? (
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">Начните вводить запрос в строке поиска</div>
) : total === 0 ? (
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">Ничего не найдено</div>
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">
Начните вводить запрос в строке поиска
</div>
) : searchResponse?.total === 0 ? (
<div className="rounded-xl bg-gray-50 p-6 text-center text-sm text-gray-500">
Ничего не найдено по запросу "{queryRaw}"
</div>
) : (
<div className="space-y-4">
{results.employees.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Сотрудники</p>
<div className="space-y-2">
{results.employees.map((u) => (
<Link key={u.id} href="/employees" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{`${u.last_name} ${u.first_name}`.trim()} {u.email ? `• ${u.email}` : ""}
</Link>
))}
</div>
</div>
) : null}
<div className="space-y-6">
{(Object.keys(resultsByType) as SearchModelType[]).map((modelType) => {
const results = resultsByType[modelType]!;
const Icon = modelIcons[modelType];
const categoryName = modelNames[modelType];
{results.departments.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Отделы</p>
<div className="space-y-2">
{results.departments.map((d) => (
<Link key={d.id} href="/departments" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{d.name}
</Link>
))}
return (
<div key={modelType}>
<div className="mb-3 flex items-center gap-2">
<Icon size={16} className="text-gray-400" />
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
{categoryName} <span className="font-normal">({results.length})</span>
</p>
</div>
<div className="space-y-2">
{results.map((result) => (
<Link
key={`${result.model_name}-${result.object_id}`}
href={result.url}
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">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{result.title}</p>
{result.description && (
<p className="mt-1 text-xs text-gray-500 line-clamp-2">{result.description}</p>
)}
{result.meta && Object.keys(result.meta).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{Object.entries(result.meta).map(([key, value]) => {
if (!value || key === 'id') return null;
return (
<span
key={key}
className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
>
{String(value)}
</span>
);
})}
</div>
)}
</div>
</div>
</Link>
))}
</div>
</div>
</div>
) : null}
{results.requests.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Заявления</p>
<div className="space-y-2">
{results.requests.map((r) => (
<Link key={r.id} href="/requests" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{r.title}
</Link>
))}
</div>
</div>
) : null}
{results.documents.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Документы</p>
<div className="space-y-2">
{results.documents.map((d) => (
<Link key={d.id} href="/documents" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{d.title}
</Link>
))}
</div>
</div>
) : null}
{results.chats.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Чаты</p>
<div className="space-y-2">
{results.chats.map((c) => (
<Link key={c.id} href={`/messages/${c.id}`} className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{c.name || `Чат #${c.id}`}
</Link>
))}
</div>
</div>
) : null}
{results.posts.length > 0 ? (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Посты</p>
<div className="space-y-2">
{results.posts.map((p) => (
<Link key={p.id} href="/" className="block rounded-lg border border-gray-100 px-3 py-2 text-sm hover:bg-gray-50">
{p.content?.slice(0, 120) ?? "Без текста"}
</Link>
))}
</div>
</div>
) : null}
);
})}
</div>
)}
</section>

View File

@ -230,6 +230,16 @@ class ApiClient {
return this.request('/api/v1/employees/me/');
}
// Глобальный поиск (django-watson)
async search(query: string, limit?: number): Promise<any> {
const queryParams = new URLSearchParams();
queryParams.append('q', query);
if (limit) queryParams.append('limit', limit.toString());
const url = `/api/v1/search/?${queryParams.toString()}`;
return this.request(url);
}
// Сотрудники
async getEmployees(params?: { search?: string; department?: string; page?: number; limit?: number }): Promise<any> {
const queryParams = new URLSearchParams();

View File

@ -279,3 +279,22 @@ export interface RegisterData {
whatsapp?: string;
wechat?: string;
}
// Search types (django-watson)
export type SearchModelType = 'post' | 'employee' | 'department' | 'request' | 'chat' | 'message' | 'event' | 'schedule_event' | 'procurement_request' | 'equipment' | 'document' | 'notification';
export interface SearchResult {
model_name: SearchModelType;
object_id: number;
title: string;
description?: string;
url: string;
meta?: Record<string, any>;
}
export interface SearchResponse {
query: string;
results: SearchResult[];
counts: Record<SearchModelType, number>;
total: number;
}