Coverage for src / lexigram / admin / resources / handler.py: 19%
337 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1from __future__ import annotations
3from datetime import date, datetime
4from decimal import Decimal
5from decimal import InvalidOperation as DecimalInvalidOp
6from enum import Enum
7import types
8from typing import Any, Protocol, Union, get_args, get_origin, get_type_hints
9from uuid import UUID
11from starlette.requests import Request as StarletteRequest
12from starlette.responses import HTMLResponse, RedirectResponse
14from lexigram.admin.config import AdminConfig
15from lexigram.di.decorators import inject
16from lexigram.logging import get_logger
18logger = get_logger(__name__)
21def _unwrap_optional(tp: type) -> type:
22 """Unwrap Optional[T] or T | None to T."""
23 origin = get_origin(tp)
24 if origin in (Union, types.UnionType):
25 non_none = [a for a in get_args(tp) if a is not type(None)]
26 if len(non_none) == 1:
27 return non_none[0]
28 return tp
31def _coerce_form_data(data: dict, model: type | None) -> dict:
32 """Convert HTML form string values to proper Python types."""
33 if model is None:
34 return data
35 try:
36 hints = get_type_hints(model)
37 except Exception:
38 return data
40 for key, value in list(data.items()):
41 if key not in hints:
42 continue
43 if not isinstance(value, str):
44 continue
46 expected = _unwrap_optional(hints[key])
47 origin = get_origin(expected)
48 if expected is bool:
49 data[key] = value == "on"
50 elif expected is int:
51 try:
52 data[key] = int(value)
53 except (ValueError, TypeError):
54 pass
55 elif expected is float:
56 if value == "":
57 data[key] = None
58 else:
59 try:
60 data[key] = float(value)
61 except (ValueError, TypeError):
62 pass
63 elif expected is Decimal:
64 if value == "":
65 data[key] = None
66 else:
67 try:
68 data[key] = Decimal(value)
69 except (DecimalInvalidOp, ValueError, TypeError):
70 pass
71 elif expected is UUID:
72 if value == "":
73 data[key] = None
74 else:
75 try:
76 data[key] = UUID(value)
77 except (ValueError, AttributeError):
78 pass
79 elif expected is datetime:
80 if value == "":
81 data[key] = None
82 else:
83 try:
84 data[key] = datetime.fromisoformat(value.replace("Z", "+00:00"))
85 except (ValueError, TypeError):
86 pass
87 elif expected is date:
88 if value == "":
89 data[key] = None
90 else:
91 try:
92 data[key] = date.fromisoformat(value)
93 except (ValueError, TypeError):
94 pass
95 elif origin is list:
96 if value == "":
97 data[key] = []
98 else:
99 args = get_args(expected)
100 inner = args[0] if args else str
101 items = [s.strip() for s in value.split(",")]
102 if inner is not str:
103 try:
104 data[key] = [inner(item) for item in items]
105 except (ValueError, TypeError):
106 pass
107 else:
108 data[key] = items
109 elif isinstance(expected, type) and issubclass(expected, Enum):
110 if value != "":
111 try:
112 data[key] = expected(value)
113 except (ValueError, TypeError):
114 pass
115 elif origin is dict:
116 if value != "":
117 try:
118 from lexigram.serialization import loads_str as _json_loads
120 data[key] = _json_loads(value)
121 except Exception:
122 pass
124 return data
127def _validation_errors_to_dict(error: Any) -> dict[str, list[str]]:
128 """Convert AdminValidationError.errors (list[FieldError]) to dict form."""
129 errors: dict[str, list[str]] = {}
130 for fe in error.errors:
131 errors.setdefault(fe.field, []).append(fe.message)
132 return errors
135class ResourceActionHandler(Protocol):
136 """Protocol for action handlers."""
138 def can_handle(self, action: str) -> bool: ...
140 async def handle(
141 self, request: StarletteRequest, resource: Any, **kwargs
142 ) -> Any: ...
145class ListActionHandler:
146 def __init__(self, list_renderer: Any):
147 self.list_renderer = list_renderer
149 def can_handle(self, action: str) -> bool:
150 return action == "list"
152 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
153 return await self.list_renderer.render(request, resource)
156class DetailActionHandler:
157 def __init__(self, detail_renderer: Any):
158 self.detail_renderer = detail_renderer
160 def can_handle(self, action: str) -> bool:
161 return action == "detail"
163 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
164 item_id = request.path_params.get("id", "?")
165 return await self.detail_renderer.render_detail(request, resource, item_id)
168class CreateActionHandler:
169 def __init__(self, form_renderer: Any):
170 self.form_renderer = form_renderer
172 def can_handle(self, action: str) -> bool:
173 return action == "create"
175 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
176 if request.method == "POST":
177 return await self._handle_create(request, resource)
178 return await self.form_renderer.render_create(request, resource)
180 async def _handle_create(self, request: StarletteRequest, resource: Any) -> Any:
181 from lexigram.admin.resources.base import Resource
183 form = request.scope.get("admin_form_data") or await request.form()
184 data = dict(form)
185 data.pop("csrf_token", None)
187 if isinstance(resource, Resource) and resource._data_source:
188 validation = await resource.before_validate(data)
189 if validation.is_err():
190 error = validation.unwrap_err()
191 return await self.form_renderer.render_create(
192 request, resource, errors=_validation_errors_to_dict(error)
193 )
195 validated_data = validation.unwrap()
196 validated = await resource.before_create(validated_data)
197 record = await resource._data_source.create(validated)
198 await resource.after_create(record)
200 resource_prefix = request.scope.get(
201 "admin_resource_prefix", resource.name or ""
202 )
204 from starlette.responses import HTMLResponse
206 return HTMLResponse(
207 f'<html><head><meta http-equiv="refresh" content="0;url=/admin/{resource_prefix}"></head><body></body></html>'
208 )
210 return await self.form_renderer.render_create(request, resource)
213class EditActionHandler:
214 def __init__(self, form_renderer: Any):
215 self.form_renderer = form_renderer
217 def can_handle(self, action: str) -> bool:
218 return action == "edit"
220 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
221 item_id = request.path_params.get("id", "?")
222 if request.method == "POST":
223 return await self._handle_update(request, resource, item_id)
224 return await self.form_renderer.render_edit(request, resource, item_id)
226 async def _handle_update(
227 self, request: StarletteRequest, resource: Any, item_id: str
228 ) -> Any:
229 from lexigram.admin.resources.base import Resource
231 form = request.scope.get("admin_form_data") or await request.form()
232 data = dict(form)
233 data.pop("csrf_token", None)
235 if isinstance(resource, Resource) and resource._data_source:
236 validation = await resource.before_validate(data)
237 if validation.is_err():
238 error = validation.unwrap_err()
239 return await self.form_renderer.render_edit(
240 request,
241 resource,
242 item_id,
243 errors=_validation_errors_to_dict(error),
244 )
246 validated_data = validation.unwrap()
247 validated = await resource.before_update(item_id, validated_data)
248 record = await resource._data_source.update(item_id, validated)
249 await resource.after_update(record)
251 resource_prefix = request.scope.get(
252 "admin_resource_prefix", resource.name or ""
253 )
255 from starlette.responses import HTMLResponse
257 return HTMLResponse(
258 f'<html><head><meta http-equiv="refresh" content="0;url=/admin/{resource_prefix}"></head><body></body></html>'
259 )
261 return await self.form_renderer.render_edit(request, resource, item_id)
264class CloneActionHandler:
265 """Handler for the ``clone`` action — duplicates a record and redirects."""
267 def can_handle(self, action: str) -> bool:
268 return action == "clone"
270 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
271 from lexigram.admin.resources.base import Resource
273 item_id = request.path_params.get("id", "?")
275 if not isinstance(resource, Resource):
276 return HTMLResponse(
277 "<h1>Clone not supported for this resource</h1>", status_code=400
278 )
280 new_record = await resource.duplicate(item_id)
281 new_id = str(getattr(new_record, "id", "?"))
282 resource_prefix = request.scope.get(
283 "admin_resource_prefix", resource.name or ""
284 )
285 from starlette.responses import RedirectResponse
287 return RedirectResponse(
288 url=f"/admin/{resource_prefix}/{new_id}/edit",
289 status_code=302,
290 )
293class RestoreActionHandler:
294 """Handler for the ``restore`` action — restores a soft-deleted record and redirects."""
296 def can_handle(self, action: str) -> bool:
297 return action == "restore"
299 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
300 from lexigram.admin.resources.base import Resource
302 item_id = request.path_params.get("id", "?")
304 if not isinstance(resource, Resource):
305 return HTMLResponse(
306 "<h1>Restore not supported for this resource</h1>", status_code=400
307 )
309 restored = await resource.restore(item_id)
310 new_id = str(getattr(restored, "id", "?"))
311 resource_prefix = request.scope.get(
312 "admin_resource_prefix", resource.name or ""
313 )
314 from starlette.responses import RedirectResponse
316 return RedirectResponse(
317 url=f"/admin/{resource_prefix}/{new_id}/edit",
318 status_code=302,
319 )
322class PurgeActionHandler:
323 """Handler for the ``purge`` action — permanently deletes a record and redirects."""
325 def can_handle(self, action: str) -> bool:
326 return action == "purge"
328 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
329 from lexigram.admin.resources.base import Resource
331 item_id = request.path_params.get("id", "?")
333 if not isinstance(resource, Resource):
334 return HTMLResponse(
335 "<h1>Purge not supported for this resource</h1>", status_code=400
336 )
338 await resource.purge(item_id)
339 resource_prefix = request.scope.get(
340 "admin_resource_prefix", resource.name or ""
341 )
342 from starlette.responses import RedirectResponse
344 return RedirectResponse(
345 url=f"/admin/{resource_prefix}",
346 status_code=302,
347 )
350class DeleteActionHandler:
351 """Handler for delete-confirm and delete actions."""
353 def can_handle(self, action: str) -> bool:
354 return action in ("delete-confirm", "delete")
356 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
357 item_id = request.path_params.get("id", "?")
359 if request.method == "GET":
360 return await self._confirm_delete(request, resource, item_id)
361 return await self._execute_delete(request, resource, item_id)
363 async def _confirm_delete(
364 self, request: StarletteRequest, resource: Any, item_id: str
365 ) -> Any:
366 from lexigram.admin.resources.base import Resource as AdminResource
368 label = getattr(resource, "label", "Record")
369 record_label = f"{label} #{item_id}"
371 if isinstance(resource, AdminResource) and resource._data_source:
372 try:
373 item = await resource._data_source.find_one(item_id)
374 if item:
375 for field in ("name", "title", "email", "username", "label"):
376 val = (
377 item.get(field)
378 if isinstance(item, dict)
379 else getattr(item, field, None)
380 )
381 if val:
382 record_label = str(val)
383 break
384 except Exception:
385 pass
387 resource_prefix = request.scope.get("admin_resource_prefix", "")
388 delete_url = f"/admin/{resource_prefix}/{item_id}/delete"
390 from lexigram.admin.ui.organisms.admin_slide_over import render_delete_confirm
392 html = render_delete_confirm(
393 record_label=record_label,
394 delete_url=delete_url,
395 )
396 return HTMLResponse(html)
398 async def _execute_delete(
399 self, request: StarletteRequest, resource: Any, item_id: str
400 ) -> Any:
401 from lexigram.admin.resources.base import Resource as AdminResource
403 if isinstance(resource, AdminResource) and resource._data_source:
404 success = await resource._data_source.delete(item_id)
405 if not success:
406 return HTMLResponse("Not found", status_code=404)
408 after_delete = getattr(resource, "after_delete", None)
409 if after_delete:
410 await after_delete(item_id)
412 is_htmx = request.headers.get("HX-Request") == "true"
413 resource_prefix = request.scope.get("admin_resource_prefix", "")
415 if is_htmx:
416 response = HTMLResponse("")
417 response.headers["HX-Trigger"] = (
418 '{"refresh-list":true,"show-toast":{"message":"Deleted successfully","type":"success"}}'
419 )
420 response.headers["HX-Redirect"] = f"/admin/{resource_prefix}"
421 return response
423 return HTMLResponse(
424 f'<html><head><meta http-equiv="refresh" content="0;url=/admin/{resource_prefix}"></head><body></body></html>'
425 )
427 return HTMLResponse("Delete not supported", status_code=400)
430class BulkActionHandler:
431 """Handler for the ``bulk`` action — processes bulk operations."""
433 def can_handle(self, action: str) -> bool:
434 return action in ("bulk", "bulk-delete-confirm")
436 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
437 from lexigram.admin.resources.base import Resource as AdminResource
438 from lexigram.admin.ui.organisms.admin_slide_over import (
439 render_bulk_delete_confirm,
440 )
442 if not isinstance(resource, AdminResource) or not resource._data_source:
443 return HTMLResponse(
444 "<h1>Bulk actions not supported for this resource</h1>",
445 status_code=400,
446 )
448 # ── Bulk delete confirmation (GET) ──
449 if request.method == "GET":
450 ids = request.query_params.getlist("ids")
451 record_count = len(ids)
452 resource_prefix = request.scope.get(
453 "admin_resource_prefix", resource.name or ""
454 )
455 bulk_url = f"/admin/{resource_prefix}/bulk"
456 html = render_bulk_delete_confirm(
457 record_count=record_count,
458 bulk_url=bulk_url,
459 )
460 return HTMLResponse(html)
462 # ── Bulk action execution (POST) ──
463 form = request.scope.get("admin_form_data")
464 if form is None:
465 form = await request.form()
466 action_name = form.get("action", "")
467 form_ids = form.getlist("ids") if hasattr(form, "getlist") else []
469 if not form_ids:
470 return HTMLResponse("No records selected", status_code=400)
472 is_htmx = request.headers.get("HX-Request") == "true"
474 if action_name == "delete":
475 count = await resource._data_source.bulk_delete(form_ids)
476 message = f"Deleted {count} item(s)"
478 if is_htmx:
479 response = HTMLResponse(f"<p>{message}</p>")
480 response.headers["HX-Trigger"] = (
481 '{"refresh-list":true,"show-toast":{"message":"'
482 + message.replace('"', '\\"')
483 + '","type":"success"}}'
484 )
485 return response
486 resource_prefix = request.scope.get(
487 "admin_resource_prefix", resource.name or ""
488 )
489 return RedirectResponse(url=f"/admin/{resource_prefix}", status_code=302)
491 return HTMLResponse(f"Unknown action: {action_name}", status_code=400)
494class DefaultActionHandler:
495 def can_handle(self, action: str) -> bool:
496 return True
498 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
499 return HTMLResponse("<h1>Unknown action</h1>")
502class ActionHandlerRegistry:
503 """Registry for action handlers."""
505 def __init__(self, config: AdminConfig, name: str, resources: dict | None = None):
506 self._config = config
507 self.name = name
508 self._resources = resources or {}
509 self._handlers: list[ResourceActionHandler] = []
510 self._initialize_handlers()
512 def _initialize_handlers(self) -> None:
513 from lexigram.admin.engine.renderer import AdminRenderer as EngineAdminRenderer
514 from lexigram.admin.resources.detail_renderer import DetailRenderer
515 from lexigram.admin.resources.form_renderer import FormRenderer
516 from lexigram.admin.resources.list_renderer import ListRenderer
518 # AdminRenderer is stateless — nav is resolved from request.app.state at
519 # render time, so a fresh instance per handler registry is safe.
520 renderer = EngineAdminRenderer()
522 list_renderer = ListRenderer(self._config, self.name, renderer)
523 detail_renderer = DetailRenderer(self._config, self.name, renderer)
524 form_renderer = FormRenderer(self._config, self.name, renderer)
526 self._handlers = [
527 ListActionHandler(list_renderer),
528 DetailActionHandler(detail_renderer),
529 CreateActionHandler(form_renderer),
530 EditActionHandler(form_renderer),
531 CloneActionHandler(),
532 RestoreActionHandler(),
533 PurgeActionHandler(),
534 DeleteActionHandler(),
535 BulkActionHandler(),
536 DefaultActionHandler(),
537 ]
539 async def handle(
540 self, request: StarletteRequest, resource: Any, action: str
541 ) -> Any:
542 for handler in self._handlers:
543 if handler.can_handle(action):
544 return await handler.handle(request, resource)
545 return HTMLResponse("<h1>Unknown action</h1>")
548@inject
549class ResourceHandler:
550 """Handler for resource routes."""
552 def __init__(
553 self,
554 config: AdminConfig,
555 name: str,
556 action: str,
557 resources: dict | None = None,
558 ):
559 self._config = config
560 self.name = name
561 self.action = action
562 self._resources = resources or {}
563 self._registry = ActionHandlerRegistry(config, name, resources=self._resources)
565 async def __call__(self, scope, receive, send) -> Any:
566 request = StarletteRequest(scope, receive, send)
567 scope["admin_resource_prefix"] = self.name
568 resource = self._resources.get(self.name) if self._resources else None
569 response = await self._registry.handle(request, resource, self.action)
570 await response(scope, receive, send)