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

211 statements  

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

1"""Record-level CRUD action handlers for Admin Resources. 

2 

3List / detail / create / edit / clone / restore / purge / delete flows, 

4plus the form-data coercion helpers they share. Specialized handlers 

5(import, user permissions, bulk) live in :mod:`.handler`. 

6""" 

7 

8from __future__ import annotations 

9 

10from typing import Any, Protocol 

11 

12from starlette.requests import Request as StarletteRequest 

13from starlette.responses import HTMLResponse 

14 

15from lexigram.admin.resources.form_coercion import ( 

16 _validation_errors_to_dict, 

17) 

18from lexigram.logging import get_logger 

19 

20logger = get_logger(__name__) 

21 

22 

23class ResourceActionHandler(Protocol): 

24 """Protocol for action handlers.""" 

25 

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

27 

28 async def handle( 

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

30 ) -> Any: ... 

31 

32 

33class ListActionHandler: 

34 def __init__(self, list_renderer: Any): 

35 self.list_renderer = list_renderer 

36 

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

38 return action == "list" 

39 

40 async def handle( 

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

42 ) -> Any: 

43 return await self.list_renderer.render(request, resource) 

44 

45 

46class DetailActionHandler: 

47 def __init__(self, detail_renderer: Any): 

48 self.detail_renderer = detail_renderer 

49 

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

51 return action == "detail" 

52 

53 async def handle( 

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

55 ) -> Any: 

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

57 return await self.detail_renderer.render_detail(request, resource, item_id) 

58 

59 

60class CreateActionHandler: 

61 def __init__(self, form_renderer: Any): 

62 self.form_renderer = form_renderer 

63 

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

65 return action == "create" 

66 

67 async def handle( 

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

69 ) -> Any: 

70 if request.method == "POST": 

71 return await self._handle_create(request, resource) 

72 return await self.form_renderer.render_create(request, resource) 

73 

74 async def _handle_create(self, request: StarletteRequest, resource: Any) -> Any: 

75 from lexigram.admin.resources.base import Resource 

76 

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

78 data = dict(form) 

79 data.pop("csrf_token", None) 

80 

81 if isinstance(resource, Resource) and resource._data_source: 

82 validation = await resource.before_validate(data) 

83 if validation.is_err(): 

84 error = validation.unwrap_err() 

85 return await self.form_renderer.render_create( 

86 request, resource, errors=_validation_errors_to_dict(error) 

87 ) 

88 

89 validated_data = validation.unwrap() 

90 validated = await resource.before_create(validated_data) 

91 record = await resource._data_source.create(validated) 

92 await resource.after_create(record) 

93 

94 resource_prefix = request.scope.get( 

95 "admin_resource_prefix", resource.name or "" 

96 ) 

97 

98 from starlette.responses import HTMLResponse 

99 

100 return HTMLResponse( 

101 f'<html><head><meta http-equiv="refresh" content="0;url=/admin/{resource_prefix}"></head><body></body></html>' 

102 ) 

103 

104 return await self.form_renderer.render_create(request, resource) 

105 

106 

107class EditActionHandler: 

108 def __init__(self, form_renderer: Any): 

109 self.form_renderer = form_renderer 

110 

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

112 return action == "edit" 

113 

114 async def handle( 

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

116 ) -> Any: 

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

118 if request.method == "POST": 

119 return await self._handle_update(request, resource, item_id) 

120 return await self.form_renderer.render_edit(request, resource, item_id) 

121 

122 async def _handle_update( 

123 self, request: StarletteRequest, resource: Any, item_id: str 

124 ) -> Any: 

125 from lexigram.admin.resources.base import Resource 

126 

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

128 data = dict(form) 

129 data.pop("csrf_token", None) 

130 

131 if isinstance(resource, Resource) and resource._data_source: 

132 validation = await resource.before_validate(data) 

133 if validation.is_err(): 

134 error = validation.unwrap_err() 

135 return await self.form_renderer.render_edit( 

136 request, 

137 resource, 

138 item_id, 

139 errors=_validation_errors_to_dict(error), 

140 ) 

141 

142 validated_data = validation.unwrap() 

143 validated = await resource.before_update(item_id, validated_data) 

144 record = await resource._data_source.find_one(item_id) 

145 can_update = getattr(resource, "can_update", None) 

146 if can_update and not can_update(record): 

147 return HTMLResponse("This record cannot be updated", status_code=403) 

148 updated_record = await resource._data_source.update(item_id, validated) 

149 await resource.after_update(updated_record) 

150 

151 resource_prefix = request.scope.get( 

152 "admin_resource_prefix", resource.name or "" 

153 ) 

154 

155 return HTMLResponse( 

156 f'<html><head><meta http-equiv="refresh" content="0;url=/admin/{resource_prefix}"></head><body></body></html>' 

157 ) 

158 

159 return await self.form_renderer.render_edit(request, resource, item_id) 

160 

161 

162class CloneActionHandler: 

163 """Handler for the ``clone`` action — duplicates a record and redirects.""" 

164 

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

166 return action == "clone" 

167 

168 async def handle( 

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

170 ) -> Any: 

171 from lexigram.admin.resources.base import Resource 

172 

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

174 

175 if not isinstance(resource, Resource): 

176 return HTMLResponse( 

177 "<h1>Clone not supported for this resource</h1>", status_code=400 

178 ) 

179 

180 new_record = await resource.duplicate(item_id) 

181 new_id = str(getattr(new_record, "id", "?")) 

182 resource_prefix = request.scope.get( 

183 "admin_resource_prefix", resource.name or "" 

184 ) 

185 from starlette.responses import RedirectResponse 

186 

187 return RedirectResponse( 

188 url=f"/admin/{resource_prefix}/{new_id}/edit", 

189 status_code=302, 

190 ) 

191 

192 

193class RestoreActionHandler: 

194 """Handler for the ``restore`` action — restores a soft-deleted record and redirects.""" 

195 

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

197 return action == "restore" 

198 

199 async def handle( 

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

201 ) -> Any: 

202 from lexigram.admin.resources.base import Resource 

203 

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

205 

206 if not isinstance(resource, Resource): 

207 return HTMLResponse( 

208 "<h1>Restore not supported for this resource</h1>", status_code=400 

209 ) 

210 

211 restored = await resource.restore(item_id) 

212 new_id = str(getattr(restored, "id", "?")) 

213 resource_prefix = request.scope.get( 

214 "admin_resource_prefix", resource.name or "" 

215 ) 

216 from starlette.responses import RedirectResponse 

217 

218 return RedirectResponse( 

219 url=f"/admin/{resource_prefix}/{new_id}/edit", 

220 status_code=302, 

221 ) 

222 

223 

224class PurgeActionHandler: 

225 """Handler for the ``purge`` action — permanently deletes a record and redirects.""" 

226 

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

228 return action == "purge" 

229 

230 async def handle( 

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

232 ) -> Any: 

233 from lexigram.admin.resources.base import Resource 

234 

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

236 

237 if not isinstance(resource, Resource): 

238 return HTMLResponse( 

239 "<h1>Purge not supported for this resource</h1>", status_code=400 

240 ) 

241 

242 await resource.purge(item_id) 

243 resource_prefix = request.scope.get( 

244 "admin_resource_prefix", resource.name or "" 

245 ) 

246 from starlette.responses import RedirectResponse 

247 

248 return RedirectResponse( 

249 url=f"/admin/{resource_prefix}", 

250 status_code=302, 

251 ) 

252 

253 

254class ImportActionHandler: 

255 """Handler for import download routes (example CSV, failed-import report). 

256 

257 Serves GET ``import-example`` (the resource's declared 

258 :class:`~lexigram.admin.actions.standard.ImportAction` template) and 

259 GET ``import-report`` (a stored failed-import report as CSV). 

260 """ 

261 

262 _ACTIONS = ("import-example", "import-report") 

263 

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

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

266 return action in self._ACTIONS 

267 

268 @staticmethod 

269 def _find_import_action(resource: Any) -> Any: 

270 """Locate the resource's declared ImportAction, if any.""" 

271 from lexigram.admin.actions.standard import ImportAction 

272 

273 for collection in ("header_actions", "actions"): 

274 for action in getattr(resource, collection, None) or []: 

275 if isinstance(action, ImportAction): 

276 return action 

277 return None 

278 

279 @staticmethod 

280 def _csv_response(content: str, filename: str) -> Any: 

281 """Build an attachment CSV response.""" 

282 from starlette.responses import Response 

283 

284 return Response( 

285 content=content, 

286 media_type="text/csv", 

287 headers={"Content-Disposition": f'attachment; filename="{filename}"'}, 

288 ) 

289 

290 async def handle( 

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

292 ) -> Any: 

293 from starlette.responses import HTMLResponse 

294 

295 action = self._find_import_action(resource) 

296 if action is None: 

297 return HTMLResponse( 

298 "<h1>Import not configured for this resource</h1>", 

299 status_code=404, 

300 ) 

301 if request.method != "GET": 

302 return HTMLResponse("Method not allowed", status_code=405) 

303 

304 requested = request.scope.get("admin_action", "") 

305 if requested == "import-example": 

306 content = action.example_csv() 

307 if not content: 

308 return HTMLResponse( 

309 "<h1>No example CSV configured</h1>", status_code=404 

310 ) 

311 return self._csv_response(content, action.example_filename) 

312 

313 report_id = request.query_params.get("report_id", "") 

314 content = action.report_csv(report_id) 

315 if content is None: 

316 return HTMLResponse("<h1>Report not found</h1>", status_code=404) 

317 filename = action.report_filename(report_id) or "import-errors.csv" 

318 return self._csv_response(content, filename) 

319 

320 

321class DeleteActionHandler: 

322 """Handler for delete-confirm and delete actions.""" 

323 

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

325 return action in ("delete-confirm", "delete") 

326 

327 async def handle( 

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

329 ) -> Any: 

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

331 

332 if request.method == "GET": 

333 return await self._confirm_delete(request, resource, item_id) 

334 return await self._execute_delete(request, resource, item_id) 

335 

336 async def _confirm_delete( 

337 self, request: StarletteRequest, resource: Any, item_id: str 

338 ) -> Any: 

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

340 

341 label = getattr(resource, "label", "Record") 

342 record_label = f"{label} #{item_id}" 

343 

344 if isinstance(resource, AdminResource) and resource._data_source: 

345 try: 

346 item = await resource._data_source.find_one(item_id) 

347 if item: 

348 for field in ("name", "title", "email", "username", "label"): 

349 val = ( 

350 item.get(field) 

351 if isinstance(item, dict) 

352 else getattr(item, field, None) 

353 ) 

354 if val: 

355 record_label = str(val) 

356 break 

357 except Exception: # noqa: S110 — intentional best-effort fallback 

358 pass 

359 

360 resource_prefix = request.scope.get("admin_resource_prefix", "") 

361 delete_url = f"/admin/{resource_prefix}/{item_id}/delete" 

362 

363 from lexigram.admin.ui.organisms.admin_slide_over import render_delete_confirm 

364 

365 html = render_delete_confirm( 

366 record_label=record_label, 

367 delete_url=delete_url, 

368 ) 

369 return HTMLResponse(html) 

370 

371 async def _execute_delete( 

372 self, request: StarletteRequest, resource: Any, item_id: str 

373 ) -> Any: 

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

375 

376 if isinstance(resource, AdminResource) and resource._data_source: 

377 item = await resource._data_source.find_one(item_id) 

378 if item is None: 

379 return HTMLResponse("Not found", status_code=404) 

380 

381 can_delete = getattr(resource, "can_delete", None) 

382 if can_delete and not can_delete(item): 

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

384 if is_htmx: 

385 response = HTMLResponse("") 

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

387 '{"show-toast":{"message":"This record cannot be deleted","type":"error"}}' 

388 ) 

389 return response 

390 return HTMLResponse( 

391 '<html><head><meta http-equiv="refresh" content="0;url=/admin/"></head>' 

392 "<body>This record cannot be deleted</body></html>", 

393 status_code=409, 

394 ) 

395 

396 success = await resource._data_source.delete(item_id) 

397 if not success: 

398 return HTMLResponse("Not found", status_code=404) 

399 

400 after_delete = getattr(resource, "after_delete", None) 

401 if after_delete: 

402 await after_delete(item_id) 

403 

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

405 resource_prefix = request.scope.get("admin_resource_prefix", "") 

406 

407 if is_htmx: 

408 response = HTMLResponse("") 

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

410 '{"refresh-list":true,"show-toast":{"message":"Deleted successfully","type":"success"}}' 

411 ) 

412 response.headers["HX-Redirect"] = f"/admin/{resource_prefix}" 

413 return response 

414 

415 return HTMLResponse( 

416 f'<html><head><meta http-equiv="refresh" content="0;url=/admin/{resource_prefix}"></head><body></body></html>' 

417 ) 

418 

419 return HTMLResponse("Delete not supported", status_code=400)