a2fe4a49e3
Сотрудник сам подключает ящик (autoconfig, вложения, пагинация списка, надёжный IMAP-поиск); в хедере работает /api/search по разделам и сущностям с ACL. Также устойчивость Mattermost-токена для чата. Co-authored-by: Cursor <cursoragent@cursor.com>
256 lines
7.5 KiB
Python
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
|