Coverage for src/lexigram/admin/resources/handler.py: 64%
450 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +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: # noqa: S110 — intentional best-effort fallback
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.find_one(item_id)
249 can_update = getattr(resource, "can_update", None)
250 if can_update and not can_update(record):
251 return HTMLResponse("This record cannot be updated", status_code=403)
252 updated_record = await resource._data_source.update(item_id, validated)
253 await resource.after_update(updated_record)
255 resource_prefix = request.scope.get(
256 "admin_resource_prefix", resource.name or ""
257 )
259 return HTMLResponse(
260 f'<html><head><meta http-equiv="refresh" content="0;url=/admin/{resource_prefix}"></head><body></body></html>'
261 )
263 return await self.form_renderer.render_edit(request, resource, item_id)
266class CloneActionHandler:
267 """Handler for the ``clone`` action — duplicates a record and redirects."""
269 def can_handle(self, action: str) -> bool:
270 return action == "clone"
272 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
273 from lexigram.admin.resources.base import Resource
275 item_id = request.path_params.get("id", "?")
277 if not isinstance(resource, Resource):
278 return HTMLResponse(
279 "<h1>Clone not supported for this resource</h1>", status_code=400
280 )
282 new_record = await resource.duplicate(item_id)
283 new_id = str(getattr(new_record, "id", "?"))
284 resource_prefix = request.scope.get(
285 "admin_resource_prefix", resource.name or ""
286 )
287 from starlette.responses import RedirectResponse
289 return RedirectResponse(
290 url=f"/admin/{resource_prefix}/{new_id}/edit",
291 status_code=302,
292 )
295class RestoreActionHandler:
296 """Handler for the ``restore`` action — restores a soft-deleted record and redirects."""
298 def can_handle(self, action: str) -> bool:
299 return action == "restore"
301 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
302 from lexigram.admin.resources.base import Resource
304 item_id = request.path_params.get("id", "?")
306 if not isinstance(resource, Resource):
307 return HTMLResponse(
308 "<h1>Restore not supported for this resource</h1>", status_code=400
309 )
311 restored = await resource.restore(item_id)
312 new_id = str(getattr(restored, "id", "?"))
313 resource_prefix = request.scope.get(
314 "admin_resource_prefix", resource.name or ""
315 )
316 from starlette.responses import RedirectResponse
318 return RedirectResponse(
319 url=f"/admin/{resource_prefix}/{new_id}/edit",
320 status_code=302,
321 )
324class PurgeActionHandler:
325 """Handler for the ``purge`` action — permanently deletes a record and redirects."""
327 def can_handle(self, action: str) -> bool:
328 return action == "purge"
330 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
331 from lexigram.admin.resources.base import Resource
333 item_id = request.path_params.get("id", "?")
335 if not isinstance(resource, Resource):
336 return HTMLResponse(
337 "<h1>Purge not supported for this resource</h1>", status_code=400
338 )
340 await resource.purge(item_id)
341 resource_prefix = request.scope.get(
342 "admin_resource_prefix", resource.name or ""
343 )
344 from starlette.responses import RedirectResponse
346 return RedirectResponse(
347 url=f"/admin/{resource_prefix}",
348 status_code=302,
349 )
352class ImportActionHandler:
353 """Handler for import download routes (example CSV, failed-import report).
355 Serves GET ``import-example`` (the resource's declared
356 :class:`~lexigram.admin.actions.standard.ImportAction` template) and
357 GET ``import-report`` (a stored failed-import report as CSV).
358 """
360 _ACTIONS = ("import-example", "import-report")
362 def can_handle(self, action: str) -> bool:
363 """Whether this handler serves the given route action."""
364 return action in self._ACTIONS
366 @staticmethod
367 def _find_import_action(resource: Any) -> Any:
368 """Locate the resource's declared ImportAction, if any."""
369 from lexigram.admin.actions.standard import ImportAction
371 for collection in ("header_actions", "actions"):
372 for action in getattr(resource, collection, None) or []:
373 if isinstance(action, ImportAction):
374 return action
375 return None
377 @staticmethod
378 def _csv_response(content: str, filename: str) -> Any:
379 """Build an attachment CSV response."""
380 from starlette.responses import Response
382 return Response(
383 content=content,
384 media_type="text/csv",
385 headers={"Content-Disposition": f'attachment; filename="{filename}"'},
386 )
388 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
389 from starlette.responses import HTMLResponse
391 action = self._find_import_action(resource)
392 if action is None:
393 return HTMLResponse(
394 "<h1>Import not configured for this resource</h1>",
395 status_code=404,
396 )
397 if request.method != "GET":
398 return HTMLResponse("Method not allowed", status_code=405)
400 requested = request.scope.get("admin_action", "")
401 if requested == "import-example":
402 content = action.example_csv()
403 if not content:
404 return HTMLResponse(
405 "<h1>No example CSV configured</h1>", status_code=404
406 )
407 return self._csv_response(content, action.example_filename)
409 report_id = request.query_params.get("report_id", "")
410 content = action.report_csv(report_id)
411 if content is None:
412 return HTMLResponse("<h1>Report not found</h1>", status_code=404)
413 filename = action.report_filename(report_id) or "import-errors.csv"
414 return self._csv_response(content, filename)
417class DeleteActionHandler:
418 """Handler for delete-confirm and delete actions."""
420 def can_handle(self, action: str) -> bool:
421 return action in ("delete-confirm", "delete")
423 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
424 item_id = request.path_params.get("id", "?")
426 if request.method == "GET":
427 return await self._confirm_delete(request, resource, item_id)
428 return await self._execute_delete(request, resource, item_id)
430 async def _confirm_delete(
431 self, request: StarletteRequest, resource: Any, item_id: str
432 ) -> Any:
433 from lexigram.admin.resources.base import Resource as AdminResource
435 label = getattr(resource, "label", "Record")
436 record_label = f"{label} #{item_id}"
438 if isinstance(resource, AdminResource) and resource._data_source:
439 try:
440 item = await resource._data_source.find_one(item_id)
441 if item:
442 for field in ("name", "title", "email", "username", "label"):
443 val = (
444 item.get(field)
445 if isinstance(item, dict)
446 else getattr(item, field, None)
447 )
448 if val:
449 record_label = str(val)
450 break
451 except Exception: # noqa: S110 — intentional best-effort fallback
452 pass
454 resource_prefix = request.scope.get("admin_resource_prefix", "")
455 delete_url = f"/admin/{resource_prefix}/{item_id}/delete"
457 from lexigram.admin.ui.organisms.admin_slide_over import render_delete_confirm
459 html = render_delete_confirm(
460 record_label=record_label,
461 delete_url=delete_url,
462 )
463 return HTMLResponse(html)
465 async def _execute_delete(
466 self, request: StarletteRequest, resource: Any, item_id: str
467 ) -> Any:
468 from lexigram.admin.resources.base import Resource as AdminResource
470 if isinstance(resource, AdminResource) and resource._data_source:
471 item = await resource._data_source.find_one(item_id)
472 if item is None:
473 return HTMLResponse("Not found", status_code=404)
475 can_delete = getattr(resource, "can_delete", None)
476 if can_delete and not can_delete(item):
477 is_htmx = request.headers.get("HX-Request") == "true"
478 if is_htmx:
479 response = HTMLResponse("")
480 response.headers["HX-Trigger"] = (
481 '{"show-toast":{"message":"This record cannot be deleted","type":"error"}}'
482 )
483 return response
484 return HTMLResponse(
485 '<html><head><meta http-equiv="refresh" content="0;url=/admin/"></head>'
486 "<body>This record cannot be deleted</body></html>",
487 status_code=409,
488 )
490 success = await resource._data_source.delete(item_id)
491 if not success:
492 return HTMLResponse("Not found", status_code=404)
494 after_delete = getattr(resource, "after_delete", None)
495 if after_delete:
496 await after_delete(item_id)
498 is_htmx = request.headers.get("HX-Request") == "true"
499 resource_prefix = request.scope.get("admin_resource_prefix", "")
501 if is_htmx:
502 response = HTMLResponse("")
503 response.headers["HX-Trigger"] = (
504 '{"refresh-list":true,"show-toast":{"message":"Deleted successfully","type":"success"}}'
505 )
506 response.headers["HX-Redirect"] = f"/admin/{resource_prefix}"
507 return response
509 return HTMLResponse(
510 f'<html><head><meta http-equiv="refresh" content="0;url=/admin/{resource_prefix}"></head><body></body></html>'
511 )
513 return HTMLResponse("Delete not supported", status_code=400)
516class UserPermissionsActionHandler:
517 """Handler for the per-user direct permission editing page (users only)."""
519 def __init__(self, config: AdminConfig) -> None:
520 """Initialize the handler.
522 Args:
523 config: Admin configuration (used for the CSRF secret).
524 """
525 self._config = config
527 def can_handle(self, action: str) -> bool:
528 """Whether this handler serves the given route action."""
529 return action == "permissions"
531 async def handle(
532 self, request: StarletteRequest, resource: Any, **kwargs: Any
533 ) -> Any:
534 """Serve GET (form) and POST (save) for user permissions.
536 Only :class:`~lexigram.admin.resources.users.UserResource`
537 instances support the page; other resources get a 404.
538 """
539 from lexigram.admin.resources.users import UserResource
541 if not isinstance(resource, UserResource):
542 return HTMLResponse(
543 "<h1>Permissions not supported for this resource</h1>",
544 status_code=404,
545 )
546 item_id = request.path_params.get("id", "?")
547 data_source = getattr(resource, "_data_source", None)
548 if data_source is None:
549 return HTMLResponse("Permissions not available", status_code=400)
551 if request.method == "POST":
552 return await self._handle_submit(request, resource, data_source, item_id)
553 return await self._handle_form(request, resource, data_source, item_id)
555 async def _handle_form(
556 self,
557 request: StarletteRequest,
558 resource: Any,
559 data_source: Any,
560 item_id: str,
561 ) -> Any:
562 """Render the permission checkboxes for one user."""
563 user = await data_source.find_one(item_id)
564 if user is None:
565 return HTMLResponse("<h1>User not found</h1>", status_code=404)
567 self._ensure_csrf_token(request)
568 prefix = request.scope.get("admin_resource_prefix", resource.name or "")
570 from lexigram.admin.resources.permissions_renderer import (
571 UserPermissionsRenderer,
572 )
574 renderer = UserPermissionsRenderer(resource_name=resource.name or "")
575 return renderer.render_form(
576 request=request,
577 user=user,
578 inventory=self._permission_inventory(resource),
579 item_id=item_id,
580 prefix=prefix,
581 )
583 async def _handle_submit(
584 self,
585 request: StarletteRequest,
586 resource: Any,
587 data_source: Any,
588 item_id: str,
589 ) -> Any:
590 """Persist the submitted direct permissions for one user."""
591 form = request.scope.get("admin_form_data") or await request.form()
592 permissions = sorted(
593 {str(v).strip() for v in form.getlist("permissions") if str(v).strip()}
594 )
596 user = await data_source.find_one(item_id)
597 prefix = request.scope.get("admin_resource_prefix", resource.name or "")
598 if user is None:
599 return RedirectResponse(
600 url=f"/admin/{prefix}?error=User not found.", status_code=302
601 )
602 await data_source.update(item_id, {"permissions": permissions})
603 return RedirectResponse(
604 url=f"/admin/{prefix}?notice=User permissions updated.",
605 status_code=302,
606 )
608 def _permission_inventory(self, resource: Any) -> Any:
609 """Return the grouped permission inventory for the form.
611 Uses the inventory wired onto the resource at mount time; falls
612 back to a local inventory scoped to the resource's own name.
613 """
614 inventory = getattr(resource, "permission_inventory", None)
615 if inventory is not None:
616 return inventory
617 from lexigram.admin.rbac.inventory import PermissionInventoryService
619 logger.debug(
620 "admin.user_permissions_inventory_fallback",
621 resource=resource.name,
622 )
623 fallback = PermissionInventoryService()
624 fallback.register_resources([resource.name or "users"])
625 return fallback
627 def _ensure_csrf_token(self, request: StarletteRequest) -> None:
628 """Ensure ``request.state.csrf_token`` exists for the form embed."""
629 if getattr(getattr(request, "state", None), "csrf_token", None):
630 return
631 from lexigram.admin.auth.services.csrf_service import AdminCsrfService
633 session = getattr(request, "session", {})
634 session_id = session.get("csrf_session_id") or session.get(
635 "admin_user_id", "anonymous"
636 )
637 request.state.csrf_token = AdminCsrfService(
638 secret=self._config.auth.session_secret.get_secret_value()
639 ).generate_token(session_id)
642class BulkActionHandler:
643 """Handler for the ``bulk`` action — processes bulk operations."""
645 _CONFIRM_LABELS = {
646 "bulk-delete-confirm": ("delete", "Delete", "DELETE"),
647 "bulk-purge-confirm": ("purge", "Purge", "PURGE"),
648 "bulk-restore-confirm": ("restore", "Restore", "RESTORE"),
649 }
651 def can_handle(self, action: str) -> bool:
652 return action in (
653 "bulk",
654 "bulk-delete-confirm",
655 "bulk-purge-confirm",
656 "bulk-restore-confirm",
657 )
659 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
660 from lexigram.admin.resources.base import Resource as AdminResource
661 from lexigram.admin.ui.organisms.admin_slide_over import (
662 render_bulk_delete_confirm,
663 )
665 if not isinstance(resource, AdminResource) or not resource._data_source:
666 return HTMLResponse(
667 "<h1>Bulk actions not supported for this resource</h1>",
668 status_code=400,
669 )
671 # ── Bulk confirmation (GET) ──
672 if request.method == "GET":
673 ids = request.query_params.getlist("ids")
674 record_count = len(ids)
675 resource_prefix = request.scope.get(
676 "admin_resource_prefix", resource.name or ""
677 )
678 bulk_url = f"/admin/{resource_prefix}/bulk"
679 confirm_action = request.scope.get("admin_action", "bulk-delete-confirm")
680 action, confirm_label, confirm_phrase = self._CONFIRM_LABELS.get(
681 confirm_action, ("delete", "Delete", "DELETE")
682 )
683 html = render_bulk_delete_confirm(
684 record_count=record_count,
685 bulk_url=bulk_url,
686 action=action,
687 confirm_label=confirm_label,
688 confirm_phrase=confirm_phrase,
689 )
690 return HTMLResponse(html)
692 # ── Bulk action execution (POST) ──
693 form = request.scope.get("admin_form_data")
694 if form is None:
695 form = await request.form()
696 action_name = form.get("action", "")
697 form_ids = form.getlist("ids") if hasattr(form, "getlist") else []
699 if not form_ids:
700 return HTMLResponse("No records selected", status_code=400)
702 is_htmx = request.headers.get("HX-Request") == "true"
704 if action_name == "delete":
705 count = await resource._data_source.bulk_delete(form_ids)
706 message = f"Deleted {count} item(s)"
707 elif action_name == "purge":
708 count = await resource._data_source.bulk_delete(form_ids)
709 message = f"Purged {count} item(s)"
710 elif action_name == "restore":
711 count = 0
712 for item_id in form_ids:
713 updated = await resource._data_source.update(
714 item_id, {"deleted_at": None}
715 )
716 if updated is not None:
717 count += 1
718 message = f"Restored {count} item(s)"
719 else:
720 return HTMLResponse(f"Unknown action: {action_name}", status_code=400)
722 if is_htmx:
723 response = HTMLResponse(f"<p>{message}</p>")
724 response.headers["HX-Trigger"] = (
725 '{"refresh-list":true,"show-toast":{"message":"'
726 + message.replace('"', '\\"')
727 + '","type":"success"}}'
728 )
729 return response
730 resource_prefix = request.scope.get(
731 "admin_resource_prefix", resource.name or ""
732 )
733 return RedirectResponse(url=f"/admin/{resource_prefix}", status_code=302)
736class DefaultActionHandler:
737 def can_handle(self, action: str) -> bool:
738 return True
740 async def handle(self, request: StarletteRequest, resource: Any, **kwargs) -> Any:
741 return HTMLResponse("<h1>Unknown action</h1>")
744class ActionHandlerRegistry:
745 """Registry for action handlers."""
747 def __init__(self, config: AdminConfig, name: str, resources: dict | None = None):
748 self._config = config
749 self.name = name
750 self._resources = resources or {}
751 self._handlers: list[ResourceActionHandler] = []
752 self._initialize_handlers()
754 def _initialize_handlers(self) -> None:
755 from lexigram.admin.engine.renderer import AdminRenderer as EngineAdminRenderer
756 from lexigram.admin.resources.detail_renderer import DetailRenderer
757 from lexigram.admin.resources.form_renderer import FormRenderer
758 from lexigram.admin.resources.list_renderer import ListRenderer
760 # AdminRenderer is stateless — nav is resolved from request.app.state at
761 # render time, so a fresh instance per handler registry is safe.
762 renderer = EngineAdminRenderer()
764 list_renderer = ListRenderer(self._config, self.name, renderer)
765 detail_renderer = DetailRenderer(self._config, self.name, renderer)
766 form_renderer = FormRenderer(self._config, self.name, renderer)
768 self._handlers = [
769 ListActionHandler(list_renderer),
770 DetailActionHandler(detail_renderer),
771 CreateActionHandler(form_renderer),
772 EditActionHandler(form_renderer),
773 CloneActionHandler(),
774 RestoreActionHandler(),
775 PurgeActionHandler(),
776 DeleteActionHandler(),
777 ImportActionHandler(),
778 UserPermissionsActionHandler(self._config),
779 BulkActionHandler(),
780 DefaultActionHandler(),
781 ]
783 async def handle(
784 self, request: StarletteRequest, resource: Any, action: str
785 ) -> Any:
786 for handler in self._handlers:
787 if handler.can_handle(action):
788 return await handler.handle(request, resource)
789 return HTMLResponse("<h1>Unknown action</h1>")
792@inject
793class ResourceHandler:
794 """Handler for resource routes."""
796 def __init__(
797 self,
798 config: AdminConfig,
799 name: str,
800 action: str,
801 resources: dict | None = None,
802 ):
803 self._config = config
804 self.name = name
805 self.action = action
806 self._resources = resources or {}
807 self._registry = ActionHandlerRegistry(config, name, resources=self._resources)
809 async def __call__(self, scope, receive, send) -> Any:
810 request = StarletteRequest(scope, receive, send)
811 scope["admin_resource_prefix"] = self.name
812 scope["admin_action"] = self.action
813 resource = self._resources.get(self.name) if self._resources else None
814 response = await self._registry.handle(request, resource, self.action)
815 await response(scope, receive, send)