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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""Unified Configuration Center controller.
3Provides a single entry point for all configuration management with
4category-based navigation and spec-level editing.
5"""
7from __future__ import annotations
9from starlette.requests import Request
10from starlette.responses import RedirectResponse, Response
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
19__all__ = ["ConfigController"]
22class ConfigController(AdminController):
23 """Unified Configuration Center controller.
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 """
32 prefix = "/config"
34 async def _build_categories(self, request: Request) -> list[ConfigCategory]:
35 """Build category list with specs from registry."""
36 categories = get_default_categories()
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 }
47 registry = await request.state.resolver.resolve(ConfigRegistry)
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)
56 return categories
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)
66 @get("/")
67 async def index(self, request: Request) -> Response:
68 """Configuration Center landing page."""
69 categories = await self._build_categories(request)
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 )
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 )
89 return await self.render_admin(request, layout, title="Configuration")
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)
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 )
105 # Category empty or not found - show landing
106 return RedirectResponse(url="/admin/config", status_code=302)
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", "")
114 registry = await request.state.resolver.resolve(ConfigRegistry)
116 categories = await self._build_categories(request)
117 category = self._get_category(categories, category_name)
119 if not category:
120 self.flash(f"Category '{category_name}' not found.", "error")
121 return RedirectResponse(url="/admin/config", status_code=302)
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 )
132 # Load current values
133 values = await registry.get_values(namespace)
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 )
143 layout = ConfigLayout(
144 categories=categories,
145 active_category=category_name,
146 active_namespace=namespace,
147 content=form_content,
148 title="Configuration",
149 )
151 return await self.render_admin(
152 request,
153 layout,
154 title=f"{spec.label or namespace} - Configuration",
155 )
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", "")
163 registry = await request.state.resolver.resolve(ConfigRegistry)
165 form = await request.form()
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
174 # Save to registry
175 await registry.save_values(namespace, updates)
177 if request.headers.get("hx-request") == "true":
178 # For HTMX, return updated form + OOB flash message
179 from starlette.responses import HTMLResponse
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
186 # Helper to clear flash messages from session since we are consuming them
187 self._flash_messages.clear()
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 )
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]
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)
211 return HTMLResponse(flash_oob + form_html)
213 self.flash("Configuration saved successfully.", "success")
214 return RedirectResponse(
215 url=f"/admin/config/{category_name}/{namespace}",
216 status_code=302,
217 )