Coverage for src / lexigram / admin / controllers / settings.py: 31%

123 statements  

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

1"""Spec-driven settings controller for the admin interface. 

2 

3Renders editable configuration specs (branding, caching, security) through 

4the config panel UI and persists values to the DB-backed store. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import TYPE_CHECKING, Any 

10 

11from starlette.requests import Request 

12from starlette.responses import HTMLResponse, RedirectResponse, Response 

13 

14from lexigram.admin.auth.types import AdminSecurityEventType 

15from lexigram.admin.controllers.base import AdminController 

16from lexigram.admin.settings.panel import BooleanNode 

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

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

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

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

21from lexigram.contracts.web import get, post 

22from lexigram.logging import get_logger 

23 

24if TYPE_CHECKING: 

25 from lexigram.admin.auth.protocols import AdminCsrfServiceProtocol 

26 from lexigram.admin.engine.renderer import AdminRenderer 

27 from lexigram.admin.services.settings_service import AdminSettingsService 

28 

29logger = get_logger(__name__) 

30 

31__all__ = ["SettingsController"] 

32 

33_SYSTEM_CATEGORY = "system" 

34 

35 

36class SettingsController(AdminController): 

37 """Spec-driven settings controller. 

38 

39 Routes: 

40 GET /admin/settings - Redirect to first editable spec 

41 GET /admin/settings/{namespace} - Spec edit form 

42 POST /admin/settings/{namespace} - Save spec values 

43 """ 

44 

45 prefix = "/settings" 

46 

47 def __init__( 

48 self, 

49 renderer: AdminRenderer, 

50 settings_service: AdminSettingsService | None = None, 

51 csrf_service: AdminCsrfServiceProtocol | None = None, 

52 audit_service: Any = None, 

53 registry: ConfigRegistry | None = None, 

54 ) -> None: 

55 super().__init__(renderer=renderer, settings_service=settings_service) 

56 self._csrf_service = csrf_service 

57 self._audit_service = audit_service 

58 self._registry = registry or ConfigRegistry.with_defaults() 

59 

60 # -- helpers -- 

61 

62 def _store_name(self) -> str: 

63 """Use the DB store when registered, else the in-memory default.""" 

64 return "db" if self._registry.has_store("db") else "default" 

65 

66 @staticmethod 

67 def _user_permissions(request: Request) -> frozenset[str]: 

68 """Return the requesting user's permission set (empty when unknown).""" 

69 user = getattr(getattr(request, "state", None), "user", None) 

70 return frozenset(getattr(user, "permissions", None) or ()) 

71 

72 @staticmethod 

73 def _user_is_superadmin(request: Request) -> bool: 

74 """Return True when the requesting user holds the superadmin role. 

75 

76 Superadmin bypasses per-spec permission gating so accounts created 

77 with an empty permission set (e.g. via the setup wizard) can still 

78 manage system configurations. 

79 """ 

80 user = getattr(getattr(request, "state", None), "user", None) 

81 roles = getattr(user, "roles", None) or () 

82 return "superadmin" in roles 

83 

84 def _build_categories( 

85 self, request: Request 

86 ) -> tuple[list[ConfigCategory], list[Any]]: 

87 """Build categories with the visible specs for the requesting user.""" 

88 permissions = self._user_permissions(request) 

89 is_superadmin = self._user_is_superadmin(request) 

90 visible = [ 

91 spec 

92 for spec in self._registry.get_specs(_SYSTEM_CATEGORY) 

93 if not spec.required_permissions 

94 or is_superadmin 

95 or permissions.issuperset(spec.required_permissions) 

96 ] 

97 categories = get_default_categories() 

98 for cat in categories: 

99 if cat.name == _SYSTEM_CATEGORY: 

100 cat.specs.extend(visible) 

101 return categories, visible 

102 

103 def _get_csrf_token(self, request: Request) -> str | None: 

104 """Resolve the CSRF token for form rendering, if available.""" 

105 if not self._csrf_service: 

106 return None 

107 try: 

108 session = getattr(request, "session", {}) 

109 session_id: str = session.get("admin_user_id", "") 

110 return self._csrf_service.generate_token(session_id) 

111 except Exception: # noqa: BLE001 — non-fatal for form rendering 

112 logger.warning("settings.csrf_token_unavailable") 

113 return None 

114 

115 async def _audit( 

116 self, 

117 request: Request, 

118 success: bool = True, 

119 event_type: AdminSecurityEventType = AdminSecurityEventType.SETTINGS_UPDATED, 

120 **metadata: Any, 

121 ) -> None: 

122 """Append a settings change to the security audit log, best-effort.""" 

123 if not self._audit_service: 

124 return 

125 try: 

126 client = getattr(request, "client", None) 

127 await self._audit_service.log_event( 

128 event_type=event_type, 

129 ip_address=getattr(client, "host", "unknown"), 

130 user_agent=request.headers.get("user-agent", "") or "", 

131 success=success, 

132 metadata=metadata, 

133 ) 

134 except Exception: # noqa: BLE001 — audit failures must not break saves 

135 logger.warning("settings.audit_failed", **metadata) 

136 

137 # -- routes -- 

138 

139 @get("/") 

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

141 """Redirect to the first editable spec, or render an empty state.""" 

142 _, visible = self._build_categories(request) 

143 if visible: 

144 return RedirectResponse( 

145 url=f"/admin/settings/{visible[0].namespace}", 

146 status_code=302, 

147 ) 

148 

149 categories = get_default_categories() 

150 layout = ConfigLayout( 

151 categories=categories, 

152 active_category=None, 

153 active_namespace=None, 

154 content=None, 

155 title="Settings", 

156 ) 

157 return await self.render_admin( 

158 request, 

159 layout, 

160 title="Settings", 

161 breadcrumbs=self.generate_breadcrumbs( 

162 ("Home", "/admin/"), 

163 current="Settings", 

164 ), 

165 ) 

166 

167 @get("/{namespace:path}") 

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

169 """Spec detail/edit view.""" 

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

171 spec = self._registry.get_spec(namespace) 

172 if not spec or not spec.get_nodes(): 

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

174 return RedirectResponse(url="/admin/settings", status_code=302) 

175 

176 categories, _ = self._build_categories(request) 

177 values = await self._registry.get_values(namespace, self._store_name()) 

178 

179 ui = ConfigDashboardUI() 

180 form_content = ui.render_config_form( 

181 spec=spec.to_dict(), 

182 values=values, 

183 action=f"/admin/settings/{namespace}", 

184 csrf_token=self._get_csrf_token(request), 

185 ) 

186 

187 layout = ConfigLayout( 

188 categories=categories, 

189 active_category=_SYSTEM_CATEGORY, 

190 active_namespace=namespace, 

191 content=form_content, 

192 title="Settings", 

193 ) 

194 

195 return await self.render_admin( 

196 request, 

197 layout, 

198 title=f"{spec.label or namespace} - Settings", 

199 breadcrumbs=self.generate_breadcrumbs( 

200 ("Home", "/admin/"), 

201 ("Settings", "/admin/settings"), 

202 current=spec.label or namespace, 

203 ), 

204 ) 

205 

206 @post("/{namespace:path}") 

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

208 """Save configuration changes for a spec.""" 

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

210 spec = self._registry.get_spec(namespace) 

211 if not spec or not spec.get_nodes(): 

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

213 return RedirectResponse(url="/admin/settings", status_code=302) 

214 

215 permissions = self._user_permissions(request) 

216 if ( 

217 spec.required_permissions 

218 and not self._user_is_superadmin(request) 

219 and not permissions.issuperset(spec.required_permissions) 

220 ): 

221 await self._audit( 

222 request, 

223 success=False, 

224 event_type=AdminSecurityEventType.PERMISSION_DENIED, 

225 reason="permission_denied", 

226 ) 

227 self.flash("You do not have permission to edit this setting.", "error") 

228 return RedirectResponse( 

229 url=f"/admin/settings/{namespace}", 

230 status_code=302, 

231 ) 

232 

233 form = request.scope.get("admin_form_data") 

234 if form is None: 

235 form = await request.form() 

236 

237 nodes = spec.get_nodes() 

238 updates = { 

239 key: ( 

240 "true" 

241 if isinstance(nodes[key], BooleanNode) and value == "on" 

242 else value 

243 ) 

244 for key, value in form.items() 

245 if not key.startswith("_") and key in nodes 

246 } 

247 

248 invalid = [ 

249 key 

250 for key, value in updates.items() 

251 if str(nodes[key].validate(value)).lower() != value.lower() 

252 ] 

253 await self._registry.save_values(namespace, updates, self._store_name()) 

254 

255 await self._audit( 

256 request, 

257 namespace=namespace, 

258 keys=sorted(updates), 

259 invalid=invalid, 

260 ) 

261 

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

263 from lexigram.ui.core.base import render_to_string 

264 

265 self._flash_messages.clear() 

266 if invalid: 

267 toast_message = ( 

268 "Settings saved. Invalid values reset to defaults: " 

269 + ", ".join(invalid) 

270 ) 

271 toast_kind = "warning" 

272 else: 

273 toast_message = "Settings saved successfully." 

274 toast_kind = "success" 

275 toast_html = render_to_string(self._render_toast(toast_message, toast_kind)) 

276 flash_oob = ( 

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

278 ) 

279 

280 values = await self._registry.get_values(namespace, self._store_name()) 

281 ui = ConfigDashboardUI() 

282 form_content = ui.render_config_form( 

283 spec=spec.to_dict(), 

284 values=values, 

285 action=f"/admin/settings/{namespace}", 

286 csrf_token=self._get_csrf_token(request), 

287 ) 

288 form_html = render_to_string(form_content) 

289 

290 return HTMLResponse(flash_oob + form_html) 

291 

292 if invalid: 

293 self.flash( 

294 f"Saved. Invalid values reset to defaults: {', '.join(invalid)}", 

295 "warning", 

296 ) 

297 else: 

298 self.flash("Settings saved successfully.", "success") 

299 return RedirectResponse( 

300 url=f"/admin/settings/{namespace}", 

301 status_code=302, 

302 ) 

303 

304 def _render_toast(self, message: str, kind: str) -> str: 

305 """Build a toast component for htmx responses.""" 

306 return f'<div class="toast toast-{kind}">{message}</div>'