Coverage for src / lexigram / admin / settings / panel / controller.py: 0%

90 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:25 +0800

1"""Unified Configuration Center controller. 

2 

3Provides a single entry point for all configuration management with 

4category-based navigation and spec-level editing. 

5""" 

6 

7from __future__ import annotations 

8 

9from starlette.requests import Request 

10from starlette.responses import RedirectResponse, Response 

11 

12from lexigram.admin.controllers.base import AdminController 

13from lexigram.admin.settings.panel.layout import ConfigLayout 

14from lexigram.admin.settings.panel.registry import ConfigRegistry 

15from lexigram.admin.settings.panel.types import ConfigCategory, get_default_categories 

16from lexigram.admin.settings.panel.ui import ConfigDashboardUI 

17from lexigram.contracts.web import get, post 

18 

19__all__ = ["ConfigController"] 

20 

21 

22class ConfigController(AdminController): 

23 """Unified Configuration Center controller. 

24 

25 Routes: 

26 GET /admin/config - Landing page with category overview 

27 GET /admin/config/{category} - Category view (redirects to first spec) 

28 GET /admin/config/{category}/{namespace} - Spec detail/edit view 

29 POST /admin/config/{category}/{namespace} - Save configuration 

30 """ 

31 

32 prefix = "/config" 

33 

34 async def _build_categories(self, request: Request) -> list[ConfigCategory]: 

35 """Build category list with specs from registry.""" 

36 categories = get_default_categories() 

37 

38 # Map registry categories to display categories 

39 # Registry uses: env, admin, app, system 

40 # Display uses: env, app, system 

41 category_map = { 

42 "env": "env", 

43 "app": "app", 

44 "system": "system", 

45 } 

46 

47 registry = await request.state.resolver.resolve(ConfigRegistry) 

48 

49 for cat in categories: 

50 # Get specs for this category from registry 

51 for registry_cat, target_cat in category_map.items(): 

52 if target_cat == cat.name: 

53 specs = registry.get_specs(registry_cat) 

54 cat.specs.extend(specs) 

55 

56 return categories 

57 

58 def _get_category( 

59 self, 

60 categories: list[ConfigCategory], 

61 name: str, 

62 ) -> ConfigCategory | None: 

63 """Find a category by name.""" 

64 return next((c for c in categories if c.name == name), None) 

65 

66 @get("/") 

67 async def index(self, request: Request) -> Response: 

68 """Configuration Center landing page.""" 

69 categories = await self._build_categories(request) 

70 

71 # Find first category with specs and redirect to first spec 

72 for cat in sorted(categories, key=lambda c: c.order): 

73 if cat.specs: 

74 first_spec = cat.specs[0] 

75 return RedirectResponse( 

76 url=f"/admin/config/{cat.name}/{first_spec.namespace}", 

77 status_code=302, 

78 ) 

79 

80 # No specs registered - show empty state 

81 layout = ConfigLayout( 

82 categories=categories, 

83 active_category=None, 

84 active_namespace=None, 

85 content=None, 

86 title="Configuration", 

87 ) 

88 

89 return await self.render_admin(request, layout, title="Configuration") 

90 

91 @get("/{category}") 

92 async def category_view(self, request: Request) -> Response: 

93 """Category view - redirects to first spec in category.""" 

94 category_name = request.path_params.get("category", "") 

95 categories = await self._build_categories(request) 

96 

97 category = self._get_category(categories, category_name) 

98 if category and category.specs: 

99 first_spec = category.specs[0] 

100 return RedirectResponse( 

101 url=f"/admin/config/{category_name}/{first_spec.namespace}", 

102 status_code=302, 

103 ) 

104 

105 # Category empty or not found - show landing 

106 return RedirectResponse(url="/admin/config", status_code=302) 

107 

108 @get("/{category}/{namespace:path}") 

109 async def spec_view(self, request: Request) -> Response: 

110 """Spec detail/edit view.""" 

111 category_name = request.path_params.get("category", "") 

112 namespace = request.path_params.get("namespace", "") 

113 

114 registry = await request.state.resolver.resolve(ConfigRegistry) 

115 

116 categories = await self._build_categories(request) 

117 category = self._get_category(categories, category_name) 

118 

119 if not category: 

120 self.flash(f"Category '{category_name}' not found.", "error") 

121 return RedirectResponse(url="/admin/config", status_code=302) 

122 

123 # Find spec in category 

124 spec = next((s for s in category.specs if s.namespace == namespace), None) 

125 if not spec: 

126 self.flash(f"Configuration '{namespace}' not found.", "error") 

127 return RedirectResponse( 

128 url=f"/admin/config/{category_name}", 

129 status_code=302, 

130 ) 

131 

132 # Load current values 

133 values = await registry.get_values(namespace) 

134 

135 # Render form 

136 ui = ConfigDashboardUI() 

137 form_content = ui.render_config_form( 

138 spec=spec.to_dict(), 

139 values=values, 

140 action=f"/admin/config/{category_name}/{namespace}", 

141 ) 

142 

143 layout = ConfigLayout( 

144 categories=categories, 

145 active_category=category_name, 

146 active_namespace=namespace, 

147 content=form_content, 

148 title="Configuration", 

149 ) 

150 

151 return await self.render_admin( 

152 request, 

153 layout, 

154 title=f"{spec.label or namespace} - Configuration", 

155 ) 

156 

157 @post("/{category}/{namespace:path}") 

158 async def save_spec(self, request: Request) -> Response: 

159 """Save configuration changes.""" 

160 category_name = request.path_params.get("category", "") 

161 namespace = request.path_params.get("namespace", "") 

162 

163 registry = await request.state.resolver.resolve(ConfigRegistry) 

164 

165 form = await request.form() 

166 

167 # Extract values (skip internal fields) 

168 updates = {} 

169 for key, value in form.items(): 

170 if key.startswith("_"): 

171 continue 

172 updates[key] = value 

173 

174 # Save to registry 

175 await registry.save_values(namespace, updates) 

176 

177 if request.headers.get("hx-request") == "true": 

178 # For HTMX, return updated form + OOB flash message 

179 from starlette.responses import HTMLResponse 

180 

181 from lexigram.admin.ui.data.toast import ( # type: ignore[import-untyped] 

182 Toast, 

183 ) 

184 from lexigram.ui.core.base import render_to_string 

185 

186 # Helper to clear flash messages from session since we are consuming them 

187 self._flash_messages.clear() 

188 

189 # 1. Prepare Flash OOB 

190 toast_html = render_to_string( 

191 Toast("Configuration saved successfully.", type="success"), 

192 ) 

193 flash_oob = ( 

194 f'<div id="flash-container" hx-swap-oob="true">{toast_html}</div>' 

195 ) 

196 

197 # 2. Re-render Form 

198 values = await registry.get_values(namespace) 

199 categories = await self._build_categories(request) 

200 category = self._get_category(categories, category_name) 

201 spec = next((s for s in category.specs if s.namespace == namespace), None) # type: ignore[union-attr] 

202 

203 ui = ConfigDashboardUI() 

204 form_content = ui.render_config_form( 

205 spec=spec.to_dict(), # type: ignore[union-attr] 

206 values=values, 

207 action=f"/admin/config/{category_name}/{namespace}", 

208 ) 

209 form_html = render_to_string(form_content) 

210 

211 return HTMLResponse(flash_oob + form_html) 

212 

213 self.flash("Configuration saved successfully.", "success") 

214 return RedirectResponse( 

215 url=f"/admin/config/{category_name}/{namespace}", 

216 status_code=302, 

217 )