Coverage for src/lexigram/admin/resources/handler.py: 0%

147 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:18 +0800

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from starlette.requests import Request as StarletteRequest 

6from starlette.responses import HTMLResponse, RedirectResponse 

7 

8from lexigram.admin.config import AdminConfig 

9from lexigram.di.decorators import inject 

10from lexigram.logging import get_logger 

11 

12logger = get_logger(__name__) 

13 

14from lexigram.admin.resources.action_handlers import ( 

15 CloneActionHandler, 

16 CreateActionHandler, 

17 DeleteActionHandler, 

18 DetailActionHandler, 

19 EditActionHandler, 

20 ImportActionHandler, 

21 ListActionHandler, 

22 PurgeActionHandler, 

23 ResourceActionHandler, 

24 RestoreActionHandler, 

25) 

26 

27 

28class UserPermissionsActionHandler: 

29 """Handler for the per-user direct permission editing page (users only).""" 

30 

31 def __init__(self, config: AdminConfig) -> None: 

32 """Initialize the handler. 

33 

34 Args: 

35 config: Admin configuration (used for the CSRF secret). 

36 """ 

37 self._config = config 

38 

39 def can_handle(self, action: str) -> bool: 

40 """Whether this handler serves the given route action.""" 

41 return action == "permissions" 

42 

43 async def handle( 

44 self, request: StarletteRequest, resource: Any, **kwargs: Any 

45 ) -> Any: 

46 """Serve GET (form) and POST (save) for user permissions. 

47 

48 Only :class:`~lexigram.admin.resources.users.UserResource` 

49 instances support the page; other resources get a 404. 

50 """ 

51 from lexigram.admin.resources.users import UserResource 

52 

53 if not isinstance(resource, UserResource): 

54 return HTMLResponse( 

55 "<h1>Permissions not supported for this resource</h1>", 

56 status_code=404, 

57 ) 

58 item_id = request.path_params.get("id", "?") 

59 data_source = getattr(resource, "_data_source", None) 

60 if data_source is None: 

61 return HTMLResponse("Permissions not available", status_code=400) 

62 

63 if request.method == "POST": 

64 return await self._handle_submit(request, resource, data_source, item_id) 

65 return await self._handle_form(request, resource, data_source, item_id) 

66 

67 async def _handle_form( 

68 self, 

69 request: StarletteRequest, 

70 resource: Any, 

71 data_source: Any, 

72 item_id: str, 

73 ) -> Any: 

74 """Render the permission checkboxes for one user.""" 

75 user = await data_source.find_one(item_id) 

76 if user is None: 

77 return HTMLResponse("<h1>User not found</h1>", status_code=404) 

78 

79 self._ensure_csrf_token(request) 

80 prefix = request.scope.get("admin_resource_prefix", resource.name or "") 

81 

82 from lexigram.admin.resources.permissions_renderer import ( 

83 UserPermissionsRenderer, 

84 ) 

85 

86 renderer = UserPermissionsRenderer(resource_name=resource.name or "") 

87 return renderer.render_form( 

88 request=request, 

89 user=user, 

90 inventory=self._permission_inventory(resource), 

91 item_id=item_id, 

92 prefix=prefix, 

93 ) 

94 

95 async def _handle_submit( 

96 self, 

97 request: StarletteRequest, 

98 resource: Any, 

99 data_source: Any, 

100 item_id: str, 

101 ) -> Any: 

102 """Persist the submitted direct permissions for one user.""" 

103 form = request.scope.get("admin_form_data") or await request.form() 

104 permissions = sorted( 

105 {str(v).strip() for v in form.getlist("permissions") if str(v).strip()} 

106 ) 

107 

108 user = await data_source.find_one(item_id) 

109 prefix = request.scope.get("admin_resource_prefix", resource.name or "") 

110 if user is None: 

111 return RedirectResponse( 

112 url=f"/admin/{prefix}?error=User not found.", status_code=302 

113 ) 

114 await data_source.update(item_id, {"permissions": permissions}) 

115 return RedirectResponse( 

116 url=f"/admin/{prefix}?notice=User permissions updated.", 

117 status_code=302, 

118 ) 

119 

120 def _permission_inventory(self, resource: Any) -> Any: 

121 """Return the grouped permission inventory for the form. 

122 

123 Uses the inventory wired onto the resource at mount time; falls 

124 back to a local inventory scoped to the resource's own name. 

125 """ 

126 inventory = getattr(resource, "permission_inventory", None) 

127 if inventory is not None: 

128 return inventory 

129 from lexigram.admin.rbac.inventory import PermissionInventoryService 

130 

131 logger.debug( 

132 "admin.user_permissions_inventory_fallback", 

133 resource=resource.name, 

134 ) 

135 fallback = PermissionInventoryService() 

136 fallback.register_resources([resource.name or "users"]) 

137 return fallback 

138 

139 def _ensure_csrf_token(self, request: StarletteRequest) -> None: 

140 """Ensure ``request.state.csrf_token`` exists for the form embed.""" 

141 if getattr(getattr(request, "state", None), "csrf_token", None): 

142 return 

143 from lexigram.admin.auth.services.csrf_service import AdminCsrfService 

144 

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

146 session_id = session.get("csrf_session_id") or session.get( 

147 "admin_user_id", "anonymous" 

148 ) 

149 request.state.csrf_token = AdminCsrfService( 

150 secret=self._config.auth.session_secret.get_secret_value() 

151 ).generate_token(session_id) 

152 

153 

154class BulkActionHandler: 

155 """Handler for the ``bulk`` action — processes bulk operations.""" 

156 

157 _CONFIRM_LABELS = { 

158 "bulk-delete-confirm": ("delete", "Delete", "DELETE"), 

159 "bulk-purge-confirm": ("purge", "Purge", "PURGE"), 

160 "bulk-restore-confirm": ("restore", "Restore", "RESTORE"), 

161 } 

162 

163 def can_handle(self, action: str) -> bool: 

164 return action in ( 

165 "bulk", 

166 "bulk-delete-confirm", 

167 "bulk-purge-confirm", 

168 "bulk-restore-confirm", 

169 ) 

170 

171 async def handle( 

172 self, request: StarletteRequest, resource: Any, **kwargs: Any 

173 ) -> Any: 

174 from lexigram.admin.resources.base import Resource as AdminResource 

175 from lexigram.admin.ui.organisms.admin_slide_over import ( 

176 render_bulk_delete_confirm, 

177 ) 

178 

179 if not isinstance(resource, AdminResource) or not resource._data_source: 

180 return HTMLResponse( 

181 "<h1>Bulk actions not supported for this resource</h1>", 

182 status_code=400, 

183 ) 

184 

185 # ── Bulk confirmation (GET) ── 

186 if request.method == "GET": 

187 ids = request.query_params.getlist("ids") 

188 record_count = len(ids) 

189 resource_prefix = request.scope.get( 

190 "admin_resource_prefix", resource.name or "" 

191 ) 

192 bulk_url = f"/admin/{resource_prefix}/bulk" 

193 confirm_action = request.scope.get("admin_action", "bulk-delete-confirm") 

194 action, confirm_label, confirm_phrase = self._CONFIRM_LABELS.get( 

195 confirm_action, ("delete", "Delete", "DELETE") 

196 ) 

197 html = render_bulk_delete_confirm( 

198 record_count=record_count, 

199 bulk_url=bulk_url, 

200 action=action, 

201 confirm_label=confirm_label, 

202 confirm_phrase=confirm_phrase, 

203 ) 

204 return HTMLResponse(html) 

205 

206 # ── Bulk action execution (POST) ── 

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

208 if form is None: 

209 form = await request.form() 

210 action_name = form.get("action", "") 

211 form_ids = form.getlist("ids") if hasattr(form, "getlist") else [] 

212 

213 if not form_ids: 

214 return HTMLResponse("No records selected", status_code=400) 

215 

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

217 

218 if action_name == "delete": 

219 count = await resource._data_source.bulk_delete(form_ids) 

220 message = f"Deleted {count} item(s)" 

221 elif action_name == "purge": 

222 count = await resource._data_source.bulk_delete(form_ids) 

223 message = f"Purged {count} item(s)" 

224 elif action_name == "restore": 

225 count = 0 

226 for item_id in form_ids: 

227 updated = await resource._data_source.update( 

228 item_id, {"deleted_at": None} 

229 ) 

230 if updated is not None: 

231 count += 1 

232 message = f"Restored {count} item(s)" 

233 else: 

234 return HTMLResponse(f"Unknown action: {action_name}", status_code=400) 

235 

236 if is_htmx: 

237 response = HTMLResponse(f"<p>{message}</p>") 

238 response.headers["HX-Trigger"] = ( 

239 '{"refresh-list":true,"show-toast":{"message":"' 

240 + message.replace('"', '\\"') 

241 + '","type":"success"}}' 

242 ) 

243 return response 

244 resource_prefix = request.scope.get( 

245 "admin_resource_prefix", resource.name or "" 

246 ) 

247 return RedirectResponse(url=f"/admin/{resource_prefix}", status_code=302) 

248 

249 

250class DefaultActionHandler: 

251 def can_handle(self, action: str) -> bool: 

252 return True 

253 

254 async def handle( 

255 self, request: StarletteRequest, resource: Any, **kwargs: Any 

256 ) -> Any: 

257 return HTMLResponse("<h1>Unknown action</h1>") 

258 

259 

260class ActionHandlerRegistry: 

261 """Registry for action handlers.""" 

262 

263 def __init__(self, config: AdminConfig, name: str, resources: dict | None = None): 

264 self._config = config 

265 self.name = name 

266 self._resources = resources or {} 

267 self._handlers: list[ResourceActionHandler] = [] 

268 self._initialize_handlers() 

269 

270 def _initialize_handlers(self) -> None: 

271 from lexigram.admin.engine.renderer import AdminRenderer as EngineAdminRenderer 

272 from lexigram.admin.resources.detail_renderer import DetailRenderer 

273 from lexigram.admin.resources.form_renderer import FormRenderer 

274 from lexigram.admin.resources.list_renderer import ListRenderer 

275 

276 # AdminRenderer is stateless — nav is resolved from request.app.state at 

277 # render time, so a fresh instance per handler registry is safe. 

278 renderer = EngineAdminRenderer() 

279 

280 list_renderer = ListRenderer(self._config, self.name, renderer) 

281 detail_renderer = DetailRenderer(self._config, self.name, renderer) 

282 form_renderer = FormRenderer(self._config, self.name, renderer) 

283 

284 self._handlers = [ 

285 ListActionHandler(list_renderer), 

286 DetailActionHandler(detail_renderer), 

287 CreateActionHandler(form_renderer), 

288 EditActionHandler(form_renderer), 

289 CloneActionHandler(), 

290 RestoreActionHandler(), 

291 PurgeActionHandler(), 

292 DeleteActionHandler(), 

293 ImportActionHandler(), 

294 UserPermissionsActionHandler(self._config), 

295 BulkActionHandler(), 

296 DefaultActionHandler(), 

297 ] 

298 

299 async def handle( 

300 self, request: StarletteRequest, resource: Any, action: str 

301 ) -> Any: 

302 for handler in self._handlers: 

303 if handler.can_handle(action): 

304 return await handler.handle(request, resource) 

305 return HTMLResponse("<h1>Unknown action</h1>") 

306 

307 

308@inject 

309class ResourceHandler: 

310 """Handler for resource routes.""" 

311 

312 def __init__( 

313 self, 

314 config: AdminConfig, 

315 name: str, 

316 action: str, 

317 resources: dict | None = None, 

318 ): 

319 self._config = config 

320 self.name = name 

321 self.action = action 

322 self._resources = resources or {} 

323 self._registry = ActionHandlerRegistry(config, name, resources=self._resources) 

324 

325 async def __call__(self, scope: Any, receive: Any, send: Any) -> Any: 

326 request = StarletteRequest(scope, receive, send) 

327 scope["admin_resource_prefix"] = self.name 

328 scope["admin_action"] = self.action 

329 resource = self._resources.get(self.name) if self._resources else None 

330 response = await self._registry.handle(request, resource, self.action) 

331 await response(scope, receive, send)