Coverage for src / lexigram / admin / dashboard / assembler.py: 37%

63 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Dashboard assembler — implements AdminDashboardProtocol.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Sequence 

6from typing import TYPE_CHECKING 

7 

8from lexigram.admin.contributors.exceptions import ( 

9 ContributorNotFoundError, 

10 ContributorPermissionError, 

11) 

12from lexigram.contracts.admin.protocols import AdminContributorProtocol 

13from lexigram.contracts.admin.types import ( 

14 AdminActionDefinition, 

15 DashboardWidgetDefinition, 

16 ManagementPageDefinition, 

17 NavigationContribution, 

18 SettingsPanelDefinition, 

19) 

20from lexigram.contracts.core.health import HealthCheckResult, HealthStatus 

21from lexigram.logging import get_logger 

22 

23if TYPE_CHECKING: 

24 from lexigram.admin.dashboard.page_assembler import PageAssembler 

25 from lexigram.admin.dashboard.settings_assembler import SettingsPanelAssembler 

26 from lexigram.admin.types import AdminUser 

27 

28logger = get_logger(__name__) 

29 

30 

31class DashboardAssembler: 

32 """Assembles the admin dashboard from contributor definitions. 

33 

34 Collects widgets, navigation, health definitions, pages, settings 

35 panels, and actions from all registered contributors and presents a 

36 unified view. Enforces RBAC on ``execute_action`` via each 

37 contributor's ``required_permissions`` attribute. 

38 

39 Args: 

40 contributors: Ordered sequence of contributors to assemble. 

41 page_assembler: Optional PageAssembler for management pages. 

42 settings_assembler: Optional SettingsPanelAssembler for settings. 

43 """ 

44 

45 def __init__( 

46 self, 

47 contributors: Sequence[AdminContributorProtocol], 

48 *, 

49 page_assembler: PageAssembler | None = None, 

50 settings_assembler: SettingsPanelAssembler | None = None, 

51 ) -> None: 

52 self._contributors: Sequence[AdminContributorProtocol] = contributors 

53 self._contributors_by_id: dict[str, AdminContributorProtocol] = { 

54 c.contributor_id: c for c in contributors 

55 } 

56 self._page_assembler = page_assembler 

57 self._settings_assembler = settings_assembler 

58 

59 async def get_all_widgets(self) -> Sequence[DashboardWidgetDefinition]: 

60 """Collect and sort widgets from all contributors by category then order. 

61 

62 When two contributors register a widget with the same ``name``, a 

63 ``admin_widget_conflict`` warning is emitted and the later contributor's 

64 widget replaces the earlier one (last writer wins). 

65 """ 

66 seen: dict[str, str] = {} # name → contributor_id of last writer 

67 by_name: dict[str, DashboardWidgetDefinition] = {} 

68 for contributor in self._contributors: 

69 for widget in contributor.get_dashboard_widgets(): 

70 if widget.name in seen: 

71 logger.warning( 

72 "admin_widget_conflict", 

73 widget_name=widget.name, 

74 first_contributor=seen[widget.name], 

75 second_contributor=contributor.contributor_id, 

76 ) 

77 seen[widget.name] = contributor.contributor_id 

78 by_name[widget.name] = widget 

79 return sorted(by_name.values(), key=lambda w: (w.category.value, w.order)) 

80 

81 async def get_all_navigation(self) -> Sequence[NavigationContribution]: 

82 """Collect and sort navigation from all contributors by group then order. 

83 

84 When two contributors register a navigation item with the same ``label``, 

85 an ``admin_navigation_conflict`` warning is emitted and the later 

86 contributor's item replaces the earlier one (last writer wins). 

87 """ 

88 seen: dict[str, str] = {} # label → contributor_id of last writer 

89 by_label: dict[str, NavigationContribution] = {} 

90 for contributor in self._contributors: 

91 for item in contributor.get_navigation_items(): 

92 if item.label in seen: 

93 logger.warning( 

94 "admin_navigation_conflict", 

95 item_label=item.label, 

96 first_contributor=seen[item.label], 

97 second_contributor=contributor.contributor_id, 

98 ) 

99 seen[item.label] = contributor.contributor_id 

100 by_label[item.label] = item 

101 return sorted(by_label.values(), key=lambda n: (n.group, n.order)) 

102 

103 async def get_framework_health(self) -> dict[str, HealthCheckResult]: 

104 """Collect health definitions from all contributors. 

105 

106 Returns a dict mapping health definition name to a placeholder 

107 HealthCheckResult. Actual checks are performed lazily via 

108 the ``check_endpoint`` HTMX calls. 

109 """ 

110 results: dict[str, HealthCheckResult] = {} 

111 for contributor in self._contributors: 

112 for health_def in contributor.get_health_definitions(): 

113 results[health_def.name] = HealthCheckResult( 

114 component=health_def.component, 

115 status=HealthStatus.UNKNOWN, 

116 message="Pending health check", 

117 ) 

118 return results 

119 

120 async def get_all_actions(self) -> Sequence[AdminActionDefinition]: 

121 """Collect actions from all contributors.""" 

122 actions: list[AdminActionDefinition] = [] 

123 for contributor in self._contributors: 

124 actions.extend(contributor.get_actions()) 

125 return actions 

126 

127 async def get_management_pages( 

128 self, 

129 user: AdminUser | None = None, 

130 ) -> list[ManagementPageDefinition]: 

131 """Collect management pages via PageAssembler or return empty.""" 

132 if self._page_assembler is not None: 

133 return self._page_assembler.assemble(self._contributors, user=user) # type: ignore[arg-type] 

134 return [] 

135 

136 async def get_settings_panels( 

137 self, 

138 user: AdminUser | None = None, 

139 ) -> list[SettingsPanelDefinition]: 

140 """Collect settings panels via SettingsPanelAssembler or return empty.""" 

141 if self._settings_assembler is not None: 

142 return self._settings_assembler.assemble(self._contributors, user=user) # type: ignore[arg-type] 

143 return [] 

144 

145 async def execute_action( 

146 self, 

147 contributor_id: str, 

148 action_name: str, 

149 params: dict[str, object], 

150 user_permissions: frozenset[str], 

151 ) -> object: 

152 """Execute a framework-level action after RBAC enforcement. 

153 

154 Looks up the contributor by *contributor_id*, verifies that 

155 *user_permissions* is a superset of the contributor's 

156 ``required_permissions``, then delegates to 

157 ``contributor.execute_action(action_name, params)``. 

158 

159 Args: 

160 contributor_id: Identifier of the target contributor. 

161 action_name: Name of the action to execute. 

162 params: Parameters forwarded to the action handler. 

163 user_permissions: Permissions held by the requesting user. 

164 

165 Returns: 

166 Whatever the contributor's action handler returns. 

167 

168 Raises: 

169 ContributorNotFoundError: If no contributor matches *contributor_id*. 

170 ContributorPermissionError: If *user_permissions* is missing any 

171 permissions declared by the contributor. 

172 """ 

173 contributor = self._contributors_by_id.get(contributor_id) 

174 if contributor is None: 

175 raise ContributorNotFoundError(contributor_id) 

176 

177 missing = contributor.required_permissions - user_permissions 

178 if missing: 

179 raise ContributorPermissionError(contributor_id, action_name, missing) 

180 

181 return await contributor.execute_action(action_name, params) # type: ignore[attr-defined] 

182 

183 

184__all__ = ["DashboardAssembler"]