Coverage for src/lexigram/admin/dashboard/route_integrator.py: 0%
116 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1from __future__ import annotations
3from collections.abc import Sequence
4import inspect
5from typing import TYPE_CHECKING, Any
7from lexigram.admin.navigation.clusters import (
8 CLUSTER_GROUP,
9 cluster_child_href,
10)
11from lexigram.contracts.admin.types import (
12 ManagementPageDefinition,
13 SettingsPanelDefinition,
14)
15from lexigram.logging import get_logger
17if TYPE_CHECKING:
18 from lexigram.admin.core.routing import AdminRouter
19 from lexigram.admin.dashboard.naming_policy import NamingPolicy
20 from lexigram.contracts.admin.contributor import BaseAdminContributor
22logger = get_logger(__name__)
25from lexigram.admin.dashboard.page_handlers import (
26 AdminPageHandler,
27 _placeholder_page,
28 StructuredPageHandler,
29 _resolve_handler,
30)
33def _register_pages(
34 router: AdminRouter,
35 naming_policy: NamingPolicy,
36 prefix: str,
37 pages: list[ManagementPageDefinition],
38 container: Any = None,
39) -> None:
40 """Register management page routes on the admin router.
42 Routes are registered relative to the admin app mount point
43 (the prefix is stripped before registration — the admin router
44 already lives under the admin prefix due to ``AdminRouter.mount()``).
45 ``registered_internal_paths`` is updated externally so that
46 ``_ensure_nav_route`` does not create duplicate placeholders.
47 """
48 # prefix is intentionally unused — routes live inside the mounted
49 # admin app and must be relative to its mount point.
50 for page in pages:
51 handler = _resolve_handler(page.handler)
52 if handler is None:
53 continue
54 if inspect.isclass(handler) and container is not None:
55 handler = AdminPageHandler(handler, container)
56 else:
57 handler = StructuredPageHandler(handler)
58 path = page.route_path
59 if not path.startswith("/"):
60 path = f"/{path}"
61 ns_name = naming_policy.namespaced(page.contributor, page.name)
62 naming_policy.register("page", ns_name)
63 router.add_route(path=path, method="GET", handler=handler, name=ns_name)
66def _register_settings(
67 router: AdminRouter,
68 naming_policy: NamingPolicy,
69 prefix: str,
70 panels: list[SettingsPanelDefinition],
71 container: Any = None,
72) -> None:
73 """Register settings panel routes on the admin router.
75 Routes are registered relative to the admin app mount point
76 (the prefix is stripped before registration — see ``_register_pages``).
77 """
78 for panel in panels:
79 handler = _resolve_handler(panel.handler)
80 if handler is None:
81 continue
82 if inspect.isclass(handler) and container is not None:
83 handler = AdminPageHandler(handler, container)
84 else:
85 handler = StructuredPageHandler(handler)
86 path = panel.route_path
87 if not path.startswith("/"):
88 path = f"/{path}"
89 ns_name = naming_policy.namespaced(panel.contributor, panel.name)
90 naming_policy.register("panel", ns_name)
91 router.add_route(path=path, method="GET", handler=handler, name=ns_name)
94class RouteIntegrator:
95 """Collects ``AdminRouteSpec``, ``ManagementPageDefinition``, and
96 ``SettingsPanelDefinition`` from contributors and registers them
97 on the router."""
99 def __init__(
100 self,
101 *,
102 router: AdminRouter,
103 naming_policy: NamingPolicy,
104 route_prefix: str = "",
105 container: Any = None,
106 ) -> None:
107 self._router = router
108 self._naming = naming_policy
109 self._prefix = route_prefix
110 self._container = container
112 def register(self, contributors: Sequence[BaseAdminContributor]) -> None:
113 """Register each contributor's routes, management pages, and
114 settings panels on the admin router. Nav-item URLs that don't
115 have a corresponding handler automatically get a placeholder
116 route so they never 404."""
117 registered_internal_paths: set[str] = set()
119 for c in contributors:
120 # Routes
121 for spec in c.get_routes():
122 ns_name = self._naming.namespaced(c.package_source, spec.name)
123 self._naming.register("route", ns_name)
124 path = spec.path
125 # Contributor specs carry the full URL (e.g. "/admin/...")
126 # but routes live inside the mounted admin app, so strip
127 # the mount prefix like _ensure_nav_route does.
128 if self._prefix and path.startswith(self._prefix):
129 path = path[len(self._prefix) :]
130 if not path:
131 path = "/"
132 registered_internal_paths.add(path)
133 self._router.add_route(
134 path=path,
135 method=spec.method,
136 handler=spec.handler,
137 name=ns_name,
138 )
140 # Management pages
141 pages = c.get_management_pages()
142 if pages:
143 for page in pages:
144 internal = page.route_path
145 if not internal.startswith("/"):
146 internal = f"/{internal}"
147 registered_internal_paths.add(internal)
148 _register_pages(
149 self._router,
150 self._naming,
151 self._prefix,
152 pages, # type: ignore[arg-type]
153 container=self._container,
154 )
156 # Settings panels
157 panels = c.get_settings_panels()
158 if panels:
159 for panel in panels:
160 internal = panel.route_path
161 if not internal.startswith("/"):
162 internal = f"/{internal}"
163 registered_internal_paths.add(internal)
164 _register_settings(
165 self._router,
166 self._naming,
167 self._prefix,
168 panels, # type: ignore[arg-type]
169 container=self._container,
170 )
172 # Auto-register placeholder routes for nav items without handlers.
173 for c in contributors:
174 for item in c.get_navigation_items():
175 self._ensure_nav_route(item, registered_internal_paths)
176 for child in item.children or ():
177 self._ensure_nav_route(child, registered_internal_paths)
179 # Cluster areas are also reachable under the center namespace
180 # (e.g. /admin/infrastructure/web), mirroring how settings
181 # sub-pages nest below /admin/settings. Aliases share the source
182 # route's handler — real page or placeholder alike.
183 for c in contributors:
184 for item in c.get_navigation_items():
185 self._register_cluster_alias(item)
186 for child in item.children or ():
187 self._register_cluster_alias(child)
189 def _ensure_nav_route(
190 self,
191 item: Any,
192 registered_paths: set[str],
193 ) -> None:
194 """Register a placeholder route for *item* if its URL isn't covered."""
195 url = item.url
196 if not url or url.startswith("http"):
197 return
199 internal = url
200 if self._prefix and internal.startswith(self._prefix):
201 internal = internal[len(self._prefix) :]
202 if not internal:
203 internal = "/"
205 if internal in registered_paths or url in registered_paths:
206 return
208 safe_label = item.label.lower().replace(" ", "_").replace("/", "_")
209 self._router.add_route(
210 path=internal,
211 method="GET",
212 handler=_placeholder_page,
213 name=f"placeholder_{safe_label}",
214 )
215 registered_paths.add(internal)
217 def _register_cluster_alias(self, item: Any) -> None:
218 """Register a namespaced alias for a cluster group nav item.
220 Cluster areas live under the center namespace (``/admin/
221 infrastructure/web``) in addition to their contributor URL. When
222 the source URL has a real handler, the alias reuses it; otherwise
223 the alias falls back to the placeholder page.
224 """
225 if getattr(item, "group", None) != CLUSTER_GROUP:
226 return
227 url = item.url
228 if not url or url.startswith("http"):
229 return
230 namespaced = cluster_child_href(url)
231 if not namespaced or namespaced == url:
232 return
234 internal = url
235 internal_ns = namespaced
236 if self._prefix and internal.startswith(self._prefix):
237 internal = internal[len(self._prefix) :]
238 if self._prefix and internal_ns.startswith(self._prefix):
239 internal_ns = internal_ns[len(self._prefix) :]
240 if internal_ns == internal:
241 return
243 safe_label = item.label.lower().replace(" ", "_").replace("/", "_")
244 if not self._router.alias_route(
245 internal,
246 internal_ns,
247 name=f"cluster_alias_{safe_label}",
248 ):
249 self._router.add_route(
250 path=internal_ns,
251 method="GET",
252 handler=_placeholder_page,
253 name=f"cluster_alias_{safe_label}",
254 )