Coverage for src/lexigram/admin/navigation/clusters.py: 0%
59 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
1"""Cluster navigation helpers for Lexigram Admin.
3A cluster groups contributor navigation items (declared via
4``NavigationContribution(group=...)``) behind a single top-level sidebar
5entry that opens a center with its own secondary sidebar — mirroring how
6the Configuration Center works for settings.
8All helpers accept an explicit :class:`~lexigram.admin.clusters.Cluster`;
9when omitted they fall back to the built-in infrastructure cluster, so
10existing call sites keep working. Any cluster registered in the
11:class:`~lexigram.admin.clusters.ClusterRegistry` gets the same center
12treatment — landing URL, namespaced child hrefs, secondary sidebar, and
13primary collapse — with no per-cluster code.
14"""
16from __future__ import annotations
18from typing import Any
20from lexigram.admin.clusters.base import Cluster
21from lexigram.admin.clusters.registry import INFRASTRUCTURE_CLUSTER
23# Backwards-compatible constants describing the built-in cluster.
24CLUSTER_GROUP = INFRASTRUCTURE_CLUSTER.group or "infrastructure"
25CLUSTER_LABEL = INFRASTRUCTURE_CLUSTER.label
26CLUSTER_URL = f"/admin/{INFRASTRUCTURE_CLUSTER.slug or 'infrastructure'}"
27CLUSTER_ICON = INFRASTRUCTURE_CLUSTER.icon or "server"
29__all__ = [
30 "CLUSTER_GROUP",
31 "CLUSTER_ICON",
32 "CLUSTER_LABEL",
33 "CLUSTER_URL",
34 "build_secondary_nav",
35 "cluster_child_href",
36 "cluster_items",
37 "collapse_cluster_in_primary",
38 "is_cluster_path",
39]
42def cluster_child_href(
43 url: str | None,
44 *,
45 cluster: Cluster = INFRASTRUCTURE_CLUSTER,
46) -> str:
47 """Namespace a cluster child URL under the cluster center prefix.
49 Maps e.g. ``/admin/web`` -> ``/admin/infrastructure/web`` so cluster
50 areas live under a single center namespace, mirroring how settings
51 sub-pages are nested below ``/admin/settings``. URLs already inside
52 the namespace, non-admin URLs, and empty values are returned unchanged.
54 Args:
55 url: Original URL to namespace.
56 cluster: Cluster whose center namespace is used.
58 Returns:
59 Namespaced URL, or the input unchanged.
60 """
61 if not url or not url.startswith("/admin/"):
62 return url or ""
63 cleaned = url.rstrip("/")
64 cluster_url = f"/admin/{cluster.slug}"
65 if cleaned == cluster_url or cleaned.startswith(cluster_url + "/"):
66 return url
67 relative = cleaned.removeprefix("/admin/")
68 return f"{cluster_url}/{relative}"
71def cluster_items(
72 groups: dict[str, Any] | None,
73 *,
74 cluster: Cluster = INFRASTRUCTURE_CLUSTER,
75) -> list[Any]:
76 """Return the top-level items contributed to the cluster group.
78 Args:
79 groups: Assembler groups mapping (group name -> items).
80 cluster: Cluster whose group is read.
82 Returns:
83 List of contributed items (possibly empty).
84 """
85 if not groups:
86 return []
87 return list(groups.get(cluster.group, ()) or ())
90def is_cluster_path(
91 current_path: str | None,
92 items: list[Any],
93 *,
94 cluster: Cluster = INFRASTRUCTURE_CLUSTER,
95) -> bool:
96 """Return True when the path belongs to the cluster center.
98 Matches the landing URL plus every top-level item URL (prefix match,
99 so child pages count as well) contributed by the cluster group.
101 Args:
102 current_path: Request path.
103 items: Contributed cluster items.
104 cluster: Cluster to test against.
106 Returns:
107 True when the path belongs to the cluster center.
108 """
109 if not current_path:
110 return False
111 cluster_url = f"/admin/{cluster.slug}"
112 if current_path == cluster_url or current_path.startswith(cluster_url + "/"):
113 return True
114 current = current_path
115 return any(
116 current == item.url or current.startswith(item.url + "/") for item in items
117 )
120def _is_active(current_path: str | None, url: str) -> bool:
121 if not current_path:
122 return False
123 return current_path == url or current_path.startswith(url + "/")
126def build_secondary_nav(
127 items: list[Any],
128 current_path: str | None,
129 *,
130 cluster: Cluster = INFRASTRUCTURE_CLUSTER,
131) -> list[dict[str, Any]]:
132 """Build secondary sidebar entries for the cluster center.
134 Each top-level item becomes an entry with an ``active`` flag; child
135 contributions are nested as ``children`` entries. Parents are marked
136 active when their own URL or any child URL matches the current path.
138 Args:
139 items: Contributed cluster items.
140 current_path: Request path.
141 cluster: Cluster whose center namespace is used for hrefs.
143 Returns:
144 Secondary nav entries as dicts.
145 """
146 result: list[dict[str, Any]] = []
147 for item in items:
148 children = [
149 {
150 "label": child.label,
151 "href": cluster_child_href(child.url, cluster=cluster),
152 "icon": child.icon,
153 "active": _is_active(current_path, child.url),
154 }
155 for child in item.children
156 ]
157 entry: dict[str, Any] = {
158 "label": item.label,
159 "href": cluster_child_href(item.url, cluster=cluster),
160 "icon": item.icon,
161 "active": _is_active(current_path, item.url)
162 or any(child["active"] for child in children),
163 }
164 if children:
165 entry["children"] = children
166 result.append(entry)
167 return result
170def collapse_cluster_in_primary(
171 flat_items: list[dict[str, Any]],
172 current_path: str | None,
173 items: list[Any],
174 *,
175 cluster: Cluster = INFRASTRUCTURE_CLUSTER,
176) -> list[dict[str, Any]]:
177 """Remove the cluster group from the primary sidebar.
179 The group header (``cluster.label``) and all of its items are dropped
180 entirely; the center is reached from the user dropdown and its
181 secondary sidebar. Items outside the group are preserved in order.
183 Args:
184 flat_items: Flat primary nav items.
185 current_path: Request path (unused, retained for signature parity).
186 items: Contributed cluster items.
187 cluster: Cluster whose group is collapsed.
189 Returns:
190 Primary nav without the cluster group.
191 """
192 in_cluster = False
193 result: list[dict[str, Any]] = []
194 for item in flat_items:
195 if not isinstance(item, dict):
196 result.append(item)
197 continue
198 if item.get("is_group"):
199 in_cluster = item.get("label") == cluster.label
200 if not in_cluster:
201 result.append(item)
202 continue
203 if in_cluster:
204 continue
205 result.append(item)
206 return result