Coverage for src / lexigram / admin / services / search_service.py: 38%
66 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Global search service for cross-resource search."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from typing import Any
9@dataclass
10class SearchResult:
11 """A single search result from any resource."""
13 resource_name: str
14 resource_label: str
15 id: Any
16 title: str
17 subtitle: str = ""
18 url: str = ""
21@dataclass
22class SearchResults:
23 """Aggregated search results grouped by resource."""
25 query: str
26 total_count: int = 0
27 results: list[SearchResult] = field(default_factory=list)
28 resource_counts: dict[str, int] = field(default_factory=dict)
30 @property
31 def has_results(self) -> bool:
32 return self.total_count > 0
34 @property
35 def group_count(self) -> int:
36 return len(self.resource_counts)
39class SearchService:
40 """Searches across all registered admin resources.
42 Discovers resources, runs Resource.search() on each, aggregates
43 and returns combined results.
44 """
46 def __init__(self, resource_manager: Any) -> None:
47 self._resource_manager = resource_manager
49 async def search(
50 self,
51 query: str,
52 *,
53 limit: int = 5,
54 per_resource: int = 5,
55 ) -> SearchResults:
56 """Search across all resources.
58 Args:
59 query: The search query string.
60 limit: Maximum total results.
61 per_resource: Maximum results per resource.
63 Returns:
64 Aggregated SearchResults.
65 """
66 if not query or not query.strip():
67 return SearchResults(query=query)
69 query = query.strip()
70 results = SearchResults(query=query)
71 resources = self.get_searchable_resources()
73 for resource_cls in resources:
74 try:
75 items = await resource_cls.search(query, limit=per_resource)
76 except Exception: # noqa: S112
77 continue
79 if not items:
80 continue
82 resource_name = getattr(resource_cls, "name", "")
83 resource_label = getattr(resource_cls, "label", resource_name)
84 resource_count = 0
86 for item in items:
87 title = item.get("title", str(item.get("id", "")))
88 subtitle = item.get("subtitle", "")
89 item_id = item.get("id", "")
90 url = f"/admin/{resource_name}/{item_id}"
92 results.results.append(
93 SearchResult(
94 resource_name=resource_name,
95 resource_label=resource_label,
96 id=item_id,
97 title=title,
98 subtitle=subtitle,
99 url=url,
100 )
101 )
102 resource_count += 1
104 results.resource_counts[resource_name] = resource_count
106 results.total_count = len(results.results)
107 if limit > 0 and results.total_count > limit:
108 results.results = results.results[:limit]
109 results.total_count = limit
111 return results
113 def get_searchable_resources(self) -> list[Any]:
114 """Get list of resources with search_fields configured."""
115 resources: list[Any] = []
116 try:
117 registered = self._resource_manager.get_all_resources()
118 for r in registered:
119 search_fields = getattr(r, "search_fields", None) or []
120 if search_fields:
121 resources.append(r)
122 except Exception: # noqa: S112
123 pass
124 return resources