Files
ACORE/core/portal_search.py
T
lokses a2fe4a49e3 mail + portal: самоподключение почты, веб-клиент и глобальный поиск
Сотрудник сам подключает ящик (autoconfig, вложения, пагинация списка, надёжный IMAP-поиск); в хедере работает /api/search по разделам и сущностям с ACL. Также устойчивость Mattermost-токена для чата.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 12:22:53 +03:00

256 lines
7.5 KiB
Python

"""Глобальный поиск по порталу (хедер → /api/search)."""
from __future__ import annotations
from dataclasses import dataclass
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.shortcuts import render
from core.location_access import accessible_pharmacies
from core.portal_access import user_can_access
from core.portal_sections import PORTAL_SECTIONS
MIN_QUERY_LEN = 2
PER_GROUP = 5
@dataclass(frozen=True)
class SearchHit:
title: str
subtitle: str
url: str
icon: str = 'file'
@dataclass(frozen=True)
class SearchGroup:
key: str
label: str
hits: list[SearchHit]
@login_required
def portal_search_view(request):
"""HTMX partial для дропдауна в хедере."""
q = (request.GET.get('q') or '').strip()
groups = portal_search(request.user, q) if len(q) >= MIN_QUERY_LEN else []
return render(request, 'partials/portal-search-results.html', {
'query': q,
'groups': groups,
'min_len': MIN_QUERY_LEN,
})
def portal_search(user, query: str) -> list[SearchGroup]:
"""Собрать группы результатов с учётом ACL разделов."""
q = (query or '').strip()
if len(q) < MIN_QUERY_LEN:
return []
groups: list[SearchGroup] = []
sections = _search_sections(user, q)
if sections:
groups.append(SearchGroup('sections', 'Разделы', sections))
if user_can_access(user, 'employees') or user_can_access(user, 'hr_portal'):
hits = _search_employees(q)
if hits:
groups.append(SearchGroup('employees', 'Сотрудники', hits))
if user_can_access(user, 'pharmacies') or user_can_access(user, 'dislocation'):
hits = _search_pharmacies(user, q)
if hits:
groups.append(SearchGroup('pharmacies', 'Аптеки', hits))
if user_can_access(user, 'tasks'):
hits = _search_tasks(user, q)
if hits:
groups.append(SearchGroup('tasks', 'Задачи', hits))
if user_can_access(user, 'help'):
hits = _search_help(q)
if hits:
groups.append(SearchGroup('help', 'Справка', hits))
if user_can_access(user, 'news'):
hits = _search_news(q)
if hits:
groups.append(SearchGroup('news', 'Новости', hits))
if user_can_access(user, 'it_support') or user_can_access(user, 'it_manager'):
hits = _search_tickets(user, q)
if hits:
groups.append(SearchGroup('tickets', 'Заявки IT', hits))
return groups
def _search_sections(user, q: str) -> list[SearchHit]:
ql = q.lower()
hits: list[SearchHit] = []
for key, section in PORTAL_SECTIONS.items():
if section.nav == 'none' and key not in ('profile',):
continue
if key == 'pharmacy_detail':
continue
if not user_can_access(user, key):
continue
hay = f'{section.label} {section.path} {key}'.lower()
if ql not in hay:
continue
hits.append(SearchHit(
title=section.label,
subtitle=section.path,
url=section.path,
icon='section',
))
if len(hits) >= PER_GROUP:
break
return hits
def _search_employees(q: str) -> list[SearchHit]:
from apps.employees.models import Employee
qs = (
Employee.objects.filter(is_active=True)
.filter(
Q(last_name__icontains=q)
| Q(first_name__icontains=q)
| Q(middle_name__icontains=q)
| Q(username__icontains=q)
| Q(email__icontains=q)
)
.select_related('post', 'department')
.order_by('last_name', 'first_name')[:PER_GROUP]
)
hits = []
for emp in qs:
bits = [p for p in (getattr(emp.post, 'name', None), getattr(emp.department, 'name', None)) if p]
hits.append(SearchHit(
title=str(emp),
subtitle=' · '.join(bits) or (emp.email or emp.username),
url=f'/employees/{emp.pk}/?tab=overview',
icon='employees',
))
return hits
def _search_pharmacies(user, q: str) -> list[SearchHit]:
from apps.locations.models.base import pharmacy_object_code
qs = accessible_pharmacies(user).select_related('organization')
filters = (
Q(address__icontains=q)
| Q(street__icontains=q)
| Q(organization__name__icontains=q)
)
if q.isdigit():
filters |= Q(pharmacy_number=int(q))
else:
digits = ''.join(ch for ch in q if ch.isdigit())
if digits:
try:
filters |= Q(pharmacy_number=int(digits))
except ValueError:
pass
rows = list(qs.filter(filters).order_by('pharmacy_number')[:PER_GROUP])
hits = []
for ph in rows:
code = pharmacy_object_code(ph) or f'№{ph.pharmacy_number}'
addr = ph.get_full_address() or ph.address or ''
org = getattr(ph.organization, 'name', '') or ''
hits.append(SearchHit(
title=f'{code}' + (f' · {org}' if org else ''),
subtitle=addr[:120],
url=f'/pharmacies/{ph.pk}/',
icon='pharmacy',
))
return hits
def _search_tasks(user, q: str) -> list[SearchHit]:
from apps.tasks.models import Task
qs = (
Task.objects.for_user(user)
.filter(Q(title__icontains=q) | Q(description__icontains=q))
.exclude(status='cancelled')
.order_by('-created_at')[:PER_GROUP]
)
return [
SearchHit(
title=t.title,
subtitle=t.get_status_display(),
url=f'/tasks/{t.pk}/',
icon='task',
)
for t in qs
]
def _search_help(q: str) -> list[SearchHit]:
from apps.help.models import HelpArticle
qs = (
HelpArticle.objects.filter(is_published=True)
.filter(Q(title__icontains=q) | Q(body__icontains=q))
.select_related('category')
.order_by('sort_order', 'title')[:PER_GROUP]
)
return [
SearchHit(
title=a.title,
subtitle=getattr(a.category, 'title', '') or 'Справка',
url=f'/help/a/{a.slug}/',
icon='help',
)
for a in qs
]
def _search_news(q: str) -> list[SearchHit]:
from apps.feed.models import Post
qs = (
Post.objects.filter(is_published=True)
.filter(Q(title__icontains=q) | Q(body__icontains=q))
.order_by('-created_at')[:PER_GROUP]
)
return [
SearchHit(
title=p.title,
subtitle=p.get_category_display(),
url=f'/news/{p.pk}/',
icon='news',
)
for p in qs
]
def _search_tickets(user, q: str) -> list[SearchHit]:
from apps.it_manager.models.tickets import Ticket
qs = Ticket.objects.all()
if not user_can_access(user, 'it_manager'):
qs = qs.filter(Q(requester=user) | Q(assignee=user))
qs = (
qs.filter(Q(title__icontains=q) | Q(number__icontains=q))
.select_related('status')
.order_by('-created_at')[:PER_GROUP]
)
base_it = user_can_access(user, 'it_manager')
hits = []
for t in qs:
status = getattr(t.status, 'name', '') or ''
url = f'/it-manager/tickets/{t.pk}/update/' if base_it else '/it-support/'
hits.append(SearchHit(
title=f'{t.number} · {t.title}',
subtitle=status,
url=url,
icon='ticket',
))
return hits