Coverage for src / lexigram / admin / controllers / base.py: 38%

123 statements  

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

1"""Base controller classes for Lexigram Admin. 

2 

3This module provides base classes that leverage Lexigram's DI container 

4to provide common functionality to all admin controllers. 

5""" 

6 

7from __future__ import annotations 

8 

9from collections.abc import Awaitable, Callable 

10import inspect 

11from typing import Any 

12 

13from starlette.requests import Request 

14from starlette.responses import HTMLResponse 

15 

16from lexigram.admin.auth.models import AdminUser 

17from lexigram.admin.engine.renderer import AdminRenderer 

18from lexigram.admin.middleware.auth import current_user 

19from lexigram.concurrency import Parallel 

20from lexigram.contracts.core import TaskManagerProtocol 

21from lexigram.contracts.web.controller import ControllerProtocol 

22from lexigram.di.decorators import inject 

23 

24 

25@inject 

26class AdminController(ControllerProtocol): 

27 """Base async controller for admin pages with authentication and rendering helpers. 

28 

29 This base class provides: 

30 - Access to AdminRenderer for rendering pages 

31 - current_user() helper for auth 

32 - render_admin() for consistent page rendering (async) 

33 - Flash message support 

34 - Breadcrumb generation 

35 - Parallel async operations 

36 - Background task scheduling 

37 

38 All admin controllers should inherit from this to get these features. 

39 

40 Example: 

41 ```python 

42 class MyAdminController(AdminController): 

43 def __init__(self, renderer: AdminRenderer): 

44 super().__init__(renderer) 

45 

46 @get("/admin/dashboard") 

47 async def dashboard(self, request: Request): 

48 user = self.current_user(request) 

49 content = f"Welcome {user.name}!" 

50 return await self.render_admin(request, content) 

51 ``` 

52 """ 

53 

54 def __init__( 

55 self, 

56 renderer: AdminRenderer, 

57 task_manager: TaskManagerProtocol | None = None, 

58 settings_service: Any | None = None, 

59 ): 

60 """Initialize admin controller. 

61 

62 Args: 

63 renderer: AdminRenderer instance (DI-injected) 

64 task_manager: TaskManagerProtocol instance (optional) 

65 settings_service: AdminSettingsService instance (optional), for 

66 runtime theme overrides (site_name, primary_color). 

67 """ 

68 self.renderer = renderer 

69 self.task_manager = task_manager 

70 self._settings_service = settings_service 

71 self._flash_messages: list[dict[str, str]] = [] 

72 

73 @classmethod 

74 def collect_routes(cls) -> list[dict[str, Any]]: 

75 """Collect routes from controller methods.""" 

76 routes = [] 

77 seen_handlers = set() 

78 

79 for klass in cls.__mro__: 

80 if klass is object: 

81 continue 

82 

83 for attr_name in dir(klass): 

84 if attr_name.startswith("_") or attr_name in seen_handlers: 

85 continue 

86 

87 attr_value = getattr(klass, attr_name, None) 

88 if attr_value is not None and hasattr(attr_value, "_route_config"): 

89 route_config = attr_value._route_config 

90 routes.append( 

91 { 

92 "method": route_config["method"], 

93 "path": route_config["path"], 

94 "handler_name": attr_name, 

95 "response_model": route_config.get("response_model"), 

96 "request_model": route_config.get("request_model"), 

97 "status_code": route_config.get("status_code", 200), 

98 "summary": route_config.get("summary"), 

99 "description": route_config.get("description"), 

100 "tags": route_config.get("tags"), 

101 "operation_id": route_config.get("operation_id"), 

102 "responses": route_config.get("responses"), 

103 "deprecated": route_config.get("deprecated", False), 

104 } 

105 ) 

106 seen_handlers.add(attr_name) 

107 

108 return routes 

109 

110 def current_user(self, request: Request) -> AdminUser: 

111 """Get the current authenticated user. 

112 

113 Args: 

114 request: The current request 

115 

116 Returns: 

117 AdminUser instance or GUEST_USER if not authenticated 

118 """ 

119 return current_user(request) # type: ignore[return-value] 

120 

121 async def _apply_theme_overrides( 

122 self, 

123 request: Request, 

124 extra_context: dict[str, Any], 

125 ) -> None: 

126 """Load runtime theme settings and merge into extra_context. 

127 

128 Uses the controller's settings service when injected, otherwise 

129 builds one from the request-scoped DI container (mirroring the 

130 bundle's own construction) so every renderer path honors the same 

131 persisted branding. 

132 """ 

133 if not self._settings_service: 

134 try: 

135 from lexigram.admin.services.settings_service import ( 

136 resolve_admin_settings_service, 

137 ) 

138 

139 container = getattr(request.state, "container", None) or getattr( 

140 request.app.state, "container", None 

141 ) 

142 if container is not None: 

143 self._settings_service = await resolve_admin_settings_service( 

144 container 

145 ) 

146 except Exception: # noqa: BLE001 — non-fatal 

147 pass 

148 if not self._settings_service: 

149 return 

150 try: 

151 from lexigram.admin.multitenancy.adapter import resolve_tenant_id 

152 

153 tenant = await resolve_tenant_id(request, default="default") 

154 overrides = await self._settings_service.get_all(tenant) 

155 for field in ( 

156 "primary_color", 

157 "site_name", 

158 "logo_url", 

159 "favicon_url", 

160 "dark_mode", 

161 ): 

162 value = overrides.get(field) or overrides.get(f"admin.branding.{field}") 

163 if value: 

164 extra_context.setdefault(field, value) 

165 except Exception: # noqa: BLE001 — non-fatal 

166 pass 

167 

168 async def render_admin( 

169 self, 

170 request: Request, 

171 content: Any, 

172 title: str = "Admin", 

173 breadcrumbs: list[dict[str, Any]] | None = None, 

174 **extra_context: Any, 

175 ) -> HTMLResponse: 

176 """Render content within admin shell (async). 

177 

178 Args: 

179 request: The current request 

180 content: Content to render (Component or HTML string) 

181 title: Page title 

182 breadcrumbs: List of breadcrumb dicts 

183 **extra_context: Additional context passed to the renderer. 

184 

185 Returns: 

186 HTMLResponse with rendered admin page 

187 """ 

188 # Inject runtime theme overrides (primary_color, site_name) 

189 await self._apply_theme_overrides(request, extra_context) 

190 

191 # If content is awaitable, resolve it first 

192 if inspect.isawaitable(content): 

193 content = await content 

194 

195 # Check for HTMX request targeting #main-content 

196 is_htmx = request.headers.get("HX-Request") == "true" 

197 target = request.headers.get("HX-Target") 

198 

199 if is_htmx and target == "main-content": 

200 # Only return the partial content 

201 return self.renderer.render_partial(content) 

202 

203 return self.renderer.render_page( 

204 content, 

205 request=request, 

206 title=title, 

207 breadcrumbs=breadcrumbs, 

208 **extra_context, 

209 ) 

210 

211 def flash(self, message: str, category: str = "info") -> None: 

212 """Add a flash message to be displayed on next page. 

213 

214 Args: 

215 message: Message text 

216 category: Message category (info, success, warning, error) 

217 """ 

218 self._flash_messages.append({"message": message, "category": category}) 

219 

220 def get_flash_messages(self) -> list[dict[str, str]]: 

221 """Get and clear flash messages. 

222 

223 Returns: 

224 List of flash message dicts 

225 """ 

226 messages = self._flash_messages.copy() 

227 self._flash_messages.clear() 

228 return messages 

229 

230 def generate_breadcrumbs( 

231 self, 

232 *crumbs: tuple[str, str], 

233 current: str | None = None, 

234 ) -> list[dict[str, str]]: 

235 """Generate breadcrumb navigation. 

236 

237 Args: 

238 *crumbs: Variable number of (label, url) tuples 

239 current: Label for current page (no link) 

240 

241 Returns: 

242 List of breadcrumb dicts with 'label' and 'url' keys 

243 

244 Example: 

245 ```python 

246 breadcrumbs = self.generate_breadcrumbs( 

247 ("Home", "/admin/"), 

248 ("Users", "/admin/users"), 

249 current="Edit User" 

250 ) 

251 ``` 

252 """ 

253 result = [] 

254 

255 for label, url in crumbs: 

256 result.append({"label": label, "url": url}) 

257 

258 if current: 

259 result.append({"label": current, "url": ""}) 

260 

261 return result 

262 

263 def build_specification(self, request: Request, allowed_fields: list[str]) -> Any: 

264 """Build a specification from request query parameters. 

265 

266 Args: 

267 request: The request 

268 allowed_fields: List of fields allowed to be filtered 

269 

270 Returns: 

271 SpecificationProtocol or None 

272 """ 

273 from lexigram.admin.lib.specifications import ( 

274 AndSpecification, 

275 FieldSpecification, 

276 ) 

277 

278 specs = [] 

279 for field in allowed_fields: 

280 if value := request.query_params.get(field): 

281 specs.append(FieldSpecification(field, value)) # type: ignore[abstract] 

282 

283 if not specs: 

284 return None 

285 

286 if len(specs) == 1: 

287 return specs[0] 

288 

289 return AndSpecification(*specs) 

290 

291 async def parallel_fetch( 

292 self, 

293 *callables: Callable[[], Awaitable[Any]], 

294 ) -> list[Any]: 

295 """Fetch multiple async operations in parallel. 

296 

297 Args: 

298 *callables: Async functions to execute concurrently 

299 

300 Returns: 

301 List of results in same order as input 

302 

303 Example: 

304 ```python 

305 users, posts, comments = await self.parallel_fetch( 

306 lambda: user_service.list(), 

307 lambda: post_service.list(), 

308 lambda: comment_service.list(), 

309 ) 

310 ``` 

311 """ 

312 results = await Parallel.gather(*(fn() for fn in callables)) 

313 return list(results) 

314 

315 async def background_task( 

316 self, 

317 task: Callable[[], Awaitable[Any]], 

318 name: str | None = None, 

319 ) -> None: 

320 """Schedule task to run in background without blocking response. 

321 

322 Args: 

323 task: Async function to run in background 

324 name: Optional task name for tracking 

325 

326 Example: 

327 ```python 

328 # Send email in background 

329 await self.background_task( 

330 lambda: email_service.send(user, "Welcome!"), 

331 name="welcome_email" 

332 ) 

333 # Response returns immediately 

334 ``` 

335 """ 

336 # Use central TaskManager for background tasks 

337 self.task_manager.create_background_task(task(), name=name) # type: ignore[union-attr] 

338 

339 def get_routes(self) -> list[Any]: 

340 """Extract decorated routes from this controller instance.""" 

341 from starlette.routing import Route 

342 

343 routes = [] 

344 

345 # In lexigram-web, decorated methods have _route_config 

346 for name, method in inspect.getmembers(self, predicate=inspect.ismethod): 

347 if hasattr(method, "_route_config"): 

348 config = method._route_config 

349 # We use the Router._create_endpoint logic to wrap the handler 

350 # but since we already have an instance, we can simplify/adapt 

351 

352 # Mock a container or just wrap the method directly? 

353 # The Router normally wants a class and method name to resolve from container. 

354 # But here we already have the instance. 

355 

356 # Let's create a compatible Starlette handler 

357 async def starlette_handler(request: Request, m=method) -> Any: 

358 # We need to handle parameters like Router does 

359 # For simplicity, we can reuse Router._create_endpoint logic 

360 # Or just call the method if signature allows 

361 sig = inspect.signature(m) 

362 if "request" in sig.parameters: 

363 return await m(request=request) 

364 return await m() 

365 

366 # Prepend controller prefix to the route path 

367 base_path = getattr(self, "prefix", "").rstrip("/") 

368 route_path = config["path"] 

369 if not route_path.startswith("/"): 

370 route_path = f"/{route_path}" 

371 

372 if route_path == "/" and base_path: 

373 full_path = base_path 

374 else: 

375 full_path = f"{base_path}{route_path}" 

376 

377 if not full_path: 

378 full_path = "/" 

379 

380 routes.append( 

381 Route( 

382 full_path, 

383 endpoint=starlette_handler, 

384 methods=[config["method"]], 

385 name=config.get("name") or f"admin_custom_{name}", 

386 ), 

387 ) 

388 

389 # Also check for 'index' method if no explicit route matches prefix 

390 if hasattr(self, "index") and not any(r.path == "/" for r in routes): 

391 

392 async def index_handler(request: Request) -> Any: 

393 return await self.index(request) 

394 

395 routes.append( 

396 Route( 

397 "/", 

398 endpoint=index_handler, 

399 methods=["GET"], 

400 name="admin_custom_index", 

401 ), 

402 ) 

403 

404 return routes