codex-fastapi-cabinet 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. codex_fastapi_cabinet-0.1.0.dist-info/METADATA +182 -0
  2. codex_fastapi_cabinet-0.1.0.dist-info/RECORD +39 -0
  3. codex_fastapi_cabinet-0.1.0.dist-info/WHEEL +4 -0
  4. codex_fastapi_cabinet-0.1.0.dist-info/licenses/LICENSE +190 -0
  5. fastapi_cabinet/__init__.py +90 -0
  6. fastapi_cabinet/contracts/__init__.py +86 -0
  7. fastapi_cabinet/contracts/admin.py +50 -0
  8. fastapi_cabinet/contracts/layout.py +22 -0
  9. fastapi_cabinet/contracts/navigation.py +29 -0
  10. fastapi_cabinet/contracts/pages.py +164 -0
  11. fastapi_cabinet/contracts/permissions.py +16 -0
  12. fastapi_cabinet/contracts/providers.py +9 -0
  13. fastapi_cabinet/contracts/widgets.py +102 -0
  14. fastapi_cabinet/exceptions.py +18 -0
  15. fastapi_cabinet/fastapi.py +36 -0
  16. fastapi_cabinet/loader.py +68 -0
  17. fastapi_cabinet/registry.py +31 -0
  18. fastapi_cabinet/rendering/__init__.py +5 -0
  19. fastapi_cabinet/rendering/layout_mapper.py +88 -0
  20. fastapi_cabinet/rendering/page_mapper.py +44 -0
  21. fastapi_cabinet/rendering/widget_mapper.py +59 -0
  22. fastapi_cabinet/runtime.py +58 -0
  23. fastapi_cabinet/site.py +262 -0
  24. fastapi_cabinet/static/css/cabinet.css +126 -0
  25. fastapi_cabinet/static/js/cabinet.js +6 -0
  26. fastapi_cabinet/templates/cabinet/base.html +21 -0
  27. fastapi_cabinet/templates/cabinet/dashboard.html +14 -0
  28. fastapi_cabinet/templates/cabinet/includes/sidebar.html +20 -0
  29. fastapi_cabinet/templates/cabinet/includes/topbar.html +21 -0
  30. fastapi_cabinet/templates/cabinet/includes/widget_frame.html +3 -0
  31. fastapi_cabinet/templates/cabinet/module.html +18 -0
  32. fastapi_cabinet/templates/cabinet/pages/detail.html +39 -0
  33. fastapi_cabinet/templates/cabinet/pages/form.html +47 -0
  34. fastapi_cabinet/templates/cabinet/pages/list.html +81 -0
  35. fastapi_cabinet/templates/cabinet/pages/operation.html +29 -0
  36. fastapi_cabinet/templates/cabinet/widgets/chart.html +20 -0
  37. fastapi_cabinet/templates/cabinet/widgets/list.html +8 -0
  38. fastapi_cabinet/templates/cabinet/widgets/metric.html +9 -0
  39. fastapi_cabinet/templates/cabinet/widgets/table.html +43 -0
@@ -0,0 +1,29 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class SidebarItem(BaseModel):
5
+ """A navigation item shown inside the active admin module."""
6
+
7
+ model_config = ConfigDict(frozen=True)
8
+
9
+ key: str
10
+ label: str
11
+ path: str
12
+ icon: str = ""
13
+ badge_key: str | None = None
14
+ order: int = 100
15
+ permission: str | None = None
16
+
17
+
18
+ class HeaderItem(BaseModel):
19
+ """A top-level registered admin module."""
20
+
21
+ model_config = ConfigDict(frozen=True)
22
+
23
+ key: str
24
+ label: str
25
+ path: str
26
+ icon: str = ""
27
+ group: str = "main"
28
+ group_label: str = ""
29
+ order: int = 100
@@ -0,0 +1,164 @@
1
+ from typing import Any, Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from fastapi_cabinet.contracts.widgets import TableActionMap, TableColumnMap
6
+
7
+
8
+ class CabinetPage(BaseModel):
9
+ """Declarative management page connected to an async provider."""
10
+
11
+ model_config = ConfigDict(frozen=True)
12
+
13
+ key: str
14
+ label: str
15
+ path: str
16
+ provider: str
17
+ kind: str
18
+ order: int = 100
19
+ permission: str | None = None
20
+ template: str | None = None
21
+
22
+
23
+ class ListPage(CabinetPage):
24
+ kind: Literal["list"] = "list"
25
+
26
+
27
+ class DetailPage(CabinetPage):
28
+ kind: Literal["detail"] = "detail"
29
+
30
+
31
+ class FormPage(CabinetPage):
32
+ kind: Literal["form"] = "form"
33
+
34
+
35
+ class OperationPage(CabinetPage):
36
+ kind: Literal["operation"] = "operation"
37
+
38
+
39
+ class FilterChoiceMap(BaseModel):
40
+ value: str
41
+ label: str
42
+ selected: bool = False
43
+
44
+
45
+ class FilterFieldMap(BaseModel):
46
+ name: str
47
+ label: str
48
+ value: str = ""
49
+ input_type: Literal["text", "search", "select", "checkbox"] = "text"
50
+ choices: list[FilterChoiceMap] = Field(default_factory=list)
51
+
52
+
53
+ class PaginationMap(BaseModel):
54
+ page: int = Field(ge=1)
55
+ pages: int = Field(ge=1)
56
+ total: int = Field(ge=0)
57
+ previous_url: str | None = None
58
+ next_url: str | None = None
59
+
60
+
61
+ class ListPageMap(BaseModel):
62
+ kind: Literal["list"] = "list"
63
+ title: str
64
+ subtitle: str | None = None
65
+ columns: list[TableColumnMap]
66
+ rows: list[dict[str, Any]]
67
+ filters: list[FilterFieldMap] = Field(default_factory=list)
68
+ pagination: PaginationMap | None = None
69
+ row_href_key: str | None = None
70
+ actions: list[TableActionMap] = Field(default_factory=list)
71
+ create_url: str | None = None
72
+ create_label: str = "Create"
73
+ empty_message: str = "No records found."
74
+
75
+
76
+ class DetailFieldMap(BaseModel):
77
+ label: str
78
+ value: Any = None
79
+ key: str | None = None
80
+
81
+
82
+ class DetailSectionMap(BaseModel):
83
+ title: str
84
+ fields: list[DetailFieldMap] = Field(default_factory=list)
85
+
86
+
87
+ class PageLinkMap(BaseModel):
88
+ label: str
89
+ url: str
90
+ css_class: str = ""
91
+
92
+
93
+ class DetailPageMap(BaseModel):
94
+ kind: Literal["detail"] = "detail"
95
+ title: str
96
+ subtitle: str | None = None
97
+ fields: list[DetailFieldMap] = Field(default_factory=list)
98
+ sections: list[DetailSectionMap] = Field(default_factory=list)
99
+ actions: list[PageLinkMap] = Field(default_factory=list)
100
+ back_url: str | None = None
101
+
102
+
103
+ class FormChoiceMap(BaseModel):
104
+ value: str
105
+ label: str
106
+ selected: bool = False
107
+
108
+
109
+ class FormFieldMap(BaseModel):
110
+ name: str
111
+ label: str
112
+ input_type: Literal[
113
+ "text",
114
+ "email",
115
+ "password",
116
+ "number",
117
+ "textarea",
118
+ "select",
119
+ "checkbox",
120
+ "hidden",
121
+ "url",
122
+ ] = "text"
123
+ value: Any = ""
124
+ placeholder: str = ""
125
+ help_text: str | None = None
126
+ required: bool = False
127
+ disabled: bool = False
128
+ choices: list[FormChoiceMap] = Field(default_factory=list)
129
+ errors: list[str] = Field(default_factory=list)
130
+
131
+
132
+ class FormPageMap(BaseModel):
133
+ kind: Literal["form"] = "form"
134
+ title: str
135
+ action_url: str
136
+ fields: list[FormFieldMap]
137
+ method: Literal["POST", "PUT", "PATCH"] = "POST"
138
+ subtitle: str | None = None
139
+ submit_label: str = "Save"
140
+ cancel_url: str | None = None
141
+ cancel_label: str = "Cancel"
142
+ errors: list[str] = Field(default_factory=list)
143
+
144
+
145
+ class OperationActionMap(BaseModel):
146
+ key: str
147
+ label: str
148
+ action_url: str
149
+ method: Literal["POST", "PUT", "PATCH", "DELETE"] = "POST"
150
+ description: str | None = None
151
+ confirmation: str | None = None
152
+ css_class: str = ""
153
+
154
+
155
+ class OperationPageMap(BaseModel):
156
+ kind: Literal["operation"] = "operation"
157
+ title: str
158
+ subtitle: str | None = None
159
+ description: str | None = None
160
+ actions: list[OperationActionMap] = Field(default_factory=list)
161
+ notices: list[str] = Field(default_factory=list)
162
+
163
+
164
+ type PageMap = ListPageMap | DetailPageMap | FormPageMap | OperationPageMap
@@ -0,0 +1,16 @@
1
+ from typing import Protocol
2
+
3
+ from fastapi import Request
4
+
5
+
6
+ class PermissionProvider(Protocol):
7
+ """Application-owned authorization port."""
8
+
9
+ async def can(self, request: Request, permission: str) -> bool: ...
10
+
11
+
12
+ class AllowAllPermissionProvider:
13
+ """Convenient default for apps that secure the whole mount externally."""
14
+
15
+ async def can(self, request: Request, permission: str) -> bool:
16
+ return True
@@ -0,0 +1,9 @@
1
+ from collections.abc import Awaitable, Callable
2
+
3
+ from fastapi import Request
4
+
5
+ from fastapi_cabinet.contracts.pages import PageMap
6
+ from fastapi_cabinet.contracts.widgets import WidgetMap
7
+
8
+ type ProviderResult = WidgetMap | PageMap
9
+ type CabinetProvider = Callable[[Request], Awaitable[ProviderResult]]
@@ -0,0 +1,102 @@
1
+ from typing import Any, Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class DashboardWidget(BaseModel):
7
+ """Declarative widget connected to an async provider."""
8
+
9
+ model_config = ConfigDict(frozen=True)
10
+
11
+ key: str
12
+ title: str
13
+ provider: str
14
+ kind: str
15
+ order: int = 100
16
+ permission: str | None = None
17
+ span: int = Field(default=1, ge=1, le=4)
18
+
19
+
20
+ class MetricWidget(DashboardWidget):
21
+ kind: Literal["metric"] = "metric"
22
+ icon: str = ""
23
+
24
+
25
+ class TableWidget(DashboardWidget):
26
+ kind: Literal["table"] = "table"
27
+
28
+
29
+ class ListWidget(DashboardWidget):
30
+ kind: Literal["list"] = "list"
31
+
32
+
33
+ class ChartWidget(DashboardWidget):
34
+ kind: Literal["chart"] = "chart"
35
+ chart_type: Literal["bar", "line", "pie", "doughnut"] = "bar"
36
+
37
+
38
+ class MetricWidgetMap(BaseModel):
39
+ kind: Literal["metric"] = "metric"
40
+ key: str
41
+ title: str
42
+ value: str
43
+ subtitle: str | None = None
44
+ trend: str | None = None
45
+ icon: str = ""
46
+ span: int = Field(default=1, ge=1, le=4)
47
+
48
+
49
+ class TableColumnMap(BaseModel):
50
+ key: str
51
+ label: str
52
+ align: Literal["left", "center", "right"] = "left"
53
+
54
+
55
+ class TableActionMap(BaseModel):
56
+ key: str
57
+ label: str
58
+ action_url: str
59
+ method: Literal["POST", "PUT", "PATCH", "DELETE"] = "POST"
60
+ css_class: str = ""
61
+ permission: str | None = None
62
+
63
+
64
+ class TableWidgetMap(BaseModel):
65
+ kind: Literal["table"] = "table"
66
+ key: str
67
+ title: str
68
+ columns: list[TableColumnMap]
69
+ rows: list[dict[str, Any]]
70
+ row_href_key: str | None = None
71
+ actions: list[TableActionMap] = Field(default_factory=list)
72
+ empty_message: str = "No data."
73
+ span: int = Field(default=1, ge=1, le=4)
74
+
75
+
76
+ class ListWidgetMap(BaseModel):
77
+ kind: Literal["list"] = "list"
78
+ key: str
79
+ title: str
80
+ items: list[str] = Field(default_factory=list)
81
+ empty_message: str = "No data."
82
+ span: int = Field(default=1, ge=1, le=4)
83
+
84
+
85
+ class ChartDatasetMap(BaseModel):
86
+ label: str
87
+ data: list[float]
88
+ color: str | None = None
89
+
90
+
91
+ class ChartWidgetMap(BaseModel):
92
+ kind: Literal["chart"] = "chart"
93
+ key: str
94
+ title: str
95
+ chart_type: Literal["bar", "line", "pie", "doughnut"]
96
+ labels: list[str]
97
+ datasets: list[ChartDatasetMap]
98
+ height: int = Field(default=280, ge=120, le=1200)
99
+ span: int = Field(default=1, ge=1, le=4)
100
+
101
+
102
+ type WidgetMap = MetricWidgetMap | TableWidgetMap | ListWidgetMap | ChartWidgetMap
@@ -0,0 +1,18 @@
1
+ class CabinetError(Exception):
2
+ """Base error for cabinet configuration and rendering failures."""
3
+
4
+
5
+ class CabinetRegistrationError(CabinetError):
6
+ """Raised when an admin declaration cannot be registered."""
7
+
8
+
9
+ class CabinetDuplicateKeyError(CabinetRegistrationError):
10
+ """Raised when two admin declarations use the same key."""
11
+
12
+
13
+ class CabinetModuleLoadError(CabinetError):
14
+ """Raised when a configured cabinet module cannot be loaded."""
15
+
16
+
17
+ class CabinetProviderError(CabinetError):
18
+ """Raised when a provider cannot be resolved or returns an invalid map."""
@@ -0,0 +1,36 @@
1
+ from collections.abc import Sequence
2
+ from pathlib import Path
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.staticfiles import StaticFiles
6
+
7
+ from fastapi_cabinet.loader import CabinetModuleRef, load_cabinet_modules
8
+ from fastapi_cabinet.runtime import normalize_mount_path
9
+ from fastapi_cabinet.site import CabinetSite
10
+
11
+
12
+ def include_cabinet(
13
+ app: FastAPI,
14
+ *,
15
+ modules: Sequence[CabinetModuleRef] = (),
16
+ mount_path: str = "/cabinet",
17
+ static_mount_path: str | None = None,
18
+ site: CabinetSite | None = None,
19
+ ) -> CabinetSite:
20
+ """Load modules and attach one cabinet site to a FastAPI application."""
21
+
22
+ active_site = site or CabinetSite()
23
+ if modules:
24
+ load_cabinet_modules(modules, active_site.registry)
25
+ mount = normalize_mount_path(mount_path)
26
+ assets = static_mount_path or f"{mount.rstrip('/')}/static"
27
+ active_site.static_mount_path = assets
28
+ app.include_router(active_site.build_router(mount_path=mount, static_mount_path=assets))
29
+
30
+ static_dir = Path(__file__).parent / "static"
31
+ app.mount(
32
+ assets,
33
+ StaticFiles(directory=static_dir),
34
+ name=f"fastapi_cabinet_static_{id(active_site)}",
35
+ )
36
+ return active_site
@@ -0,0 +1,68 @@
1
+ from collections.abc import Sequence
2
+ from importlib import import_module
3
+ from inspect import signature
4
+ from types import ModuleType
5
+
6
+ from fastapi_cabinet.contracts.admin import CabinetAdmin
7
+ from fastapi_cabinet.exceptions import CabinetModuleLoadError
8
+ from fastapi_cabinet.registry import CabinetRegistry
9
+
10
+ type CabinetModuleRef = str | ModuleType
11
+
12
+
13
+ def load_cabinet_modules(
14
+ modules: Sequence[CabinetModuleRef],
15
+ registry: CabinetRegistry,
16
+ ) -> tuple[ModuleType, ...]:
17
+ """Load modules through an explicit hook or a CABINET_ADMINS declaration."""
18
+
19
+ loaded: list[ModuleType] = []
20
+ for ref in modules:
21
+ try:
22
+ module = import_module(ref) if isinstance(ref, str) else ref
23
+ except ImportError as exc:
24
+ raise CabinetModuleLoadError(f"Could not import cabinet module {ref!r}.") from exc
25
+
26
+ hook = getattr(module, "register_cabinet", None)
27
+ declarations = getattr(module, "CABINET_ADMINS", None)
28
+ if hook is not None:
29
+ _call_registration_hook(module, hook, registry)
30
+ elif declarations is not None:
31
+ _register_declarations(module, declarations, registry)
32
+ else:
33
+ raise CabinetModuleLoadError(
34
+ f"Cabinet module {module.__name__!r} must expose register_cabinet(registry) or CABINET_ADMINS."
35
+ )
36
+ loaded.append(module)
37
+ return tuple(loaded)
38
+
39
+
40
+ def _call_registration_hook(module: ModuleType, hook: object, registry: CabinetRegistry) -> None:
41
+ if not callable(hook):
42
+ raise CabinetModuleLoadError(
43
+ f"Cabinet module {module.__name__!r} defines register_cabinet, but it is not callable."
44
+ )
45
+ try:
46
+ parameters = signature(hook).parameters
47
+ except (TypeError, ValueError) as exc:
48
+ raise CabinetModuleLoadError(
49
+ f"Cabinet module {module.__name__!r} has an invalid register_cabinet hook."
50
+ ) from exc
51
+ if len(parameters) != 1:
52
+ raise CabinetModuleLoadError(
53
+ f"Cabinet module {module.__name__!r} register_cabinet hook must accept exactly one registry."
54
+ )
55
+ hook(registry)
56
+
57
+
58
+ def _register_declarations(module: ModuleType, declarations: object, registry: CabinetRegistry) -> None:
59
+ if not isinstance(declarations, (tuple, list)):
60
+ raise CabinetModuleLoadError(f"Cabinet module {module.__name__!r} CABINET_ADMINS must be a sequence.")
61
+ for declaration in declarations:
62
+ if not isinstance(declaration, CabinetAdmin) and not (
63
+ isinstance(declaration, type) and issubclass(declaration, CabinetAdmin)
64
+ ):
65
+ raise CabinetModuleLoadError(
66
+ f"Cabinet module {module.__name__!r} contains a non-CabinetAdmin declaration."
67
+ )
68
+ registry.register(declaration)
@@ -0,0 +1,31 @@
1
+ from collections.abc import Iterable
2
+
3
+ from fastapi_cabinet.contracts.admin import CabinetAdmin
4
+ from fastapi_cabinet.exceptions import CabinetDuplicateKeyError, CabinetRegistrationError
5
+
6
+
7
+ class CabinetRegistry:
8
+ """Application-scoped registry of cabinet modules."""
9
+
10
+ def __init__(self) -> None:
11
+ self._admins: dict[str, CabinetAdmin] = {}
12
+
13
+ def register(self, admin: type[CabinetAdmin] | CabinetAdmin) -> CabinetAdmin:
14
+ admin_instance = admin() if isinstance(admin, type) else admin
15
+ key = getattr(admin_instance, "key", "").strip()
16
+ label = getattr(admin_instance, "label", "").strip()
17
+ if not key or not label:
18
+ raise CabinetRegistrationError("Cabinet admin must define non-empty key and label values.")
19
+ if key in self._admins:
20
+ raise CabinetDuplicateKeyError(f"Cabinet admin key {key!r} is already registered.")
21
+ self._admins[key] = admin_instance
22
+ return admin_instance
23
+
24
+ def get(self, key: str) -> CabinetAdmin:
25
+ return self._admins[key]
26
+
27
+ def all(self) -> tuple[CabinetAdmin, ...]:
28
+ return tuple(sorted(self._admins.values(), key=lambda admin: (admin.order, admin.key)))
29
+
30
+ def keys(self) -> Iterable[str]:
31
+ return self._admins.keys()
@@ -0,0 +1,5 @@
1
+ from fastapi_cabinet.rendering.layout_mapper import build_layout_map
2
+ from fastapi_cabinet.rendering.page_mapper import resolve_page
3
+ from fastapi_cabinet.rendering.widget_mapper import resolve_widget, resolve_widgets
4
+
5
+ __all__ = ["build_layout_map", "resolve_page", "resolve_widget", "resolve_widgets"]
@@ -0,0 +1,88 @@
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+
4
+ from fastapi_cabinet.contracts.admin import CabinetAdmin
5
+ from fastapi_cabinet.contracts.layout import CabinetLayoutMap, HeaderGroup
6
+ from fastapi_cabinet.contracts.navigation import HeaderItem, SidebarItem
7
+ from fastapi_cabinet.runtime import admin_public_path
8
+
9
+ _STATIC_DIR = Path(__file__).parents[1] / "static"
10
+
11
+
12
+ def build_layout_map(
13
+ admins: tuple[CabinetAdmin, ...],
14
+ *,
15
+ mount_path: str,
16
+ static_mount_path: str,
17
+ brand_name: str,
18
+ active_admin: CabinetAdmin | None,
19
+ active_path: str,
20
+ sidebar: list[SidebarItem] | None = None,
21
+ sidebar_badges: dict[str, int | str] | None = None,
22
+ title: str | None = None,
23
+ ) -> CabinetLayoutMap:
24
+ header = [
25
+ HeaderItem(
26
+ key=admin.key,
27
+ label=admin.label,
28
+ path=admin_public_path(admin, mount_path),
29
+ icon=admin.icon,
30
+ group=admin.group,
31
+ group_label=admin.group_label,
32
+ order=admin.order,
33
+ )
34
+ for admin in admins
35
+ ]
36
+ return CabinetLayoutMap(
37
+ mount_path=mount_path,
38
+ static_mount_path=static_mount_path,
39
+ static_version=_static_version(),
40
+ brand_name=brand_name,
41
+ title=title or (active_admin.label if active_admin else brand_name),
42
+ active_module=active_admin.key if active_admin else None,
43
+ active_path=active_path,
44
+ header_groups=_build_header_groups(header),
45
+ sidebar=_build_sidebar(sidebar or [], active_admin, mount_path),
46
+ sidebar_badges=sidebar_badges or {},
47
+ )
48
+
49
+
50
+ def _build_header_groups(header: list[HeaderItem]) -> list[HeaderGroup]:
51
+ groups: dict[str, HeaderGroup] = {}
52
+ for item in sorted(header, key=lambda value: (value.order, value.key)):
53
+ if item.group not in groups:
54
+ groups[item.group] = HeaderGroup(
55
+ key=item.group,
56
+ label=item.group_label or item.group,
57
+ items=[],
58
+ )
59
+ groups[item.group].items.append(item)
60
+ return list(groups.values())
61
+
62
+
63
+ def _build_sidebar(
64
+ sidebar: list[SidebarItem],
65
+ active_admin: CabinetAdmin | None,
66
+ mount_path: str,
67
+ ) -> list[SidebarItem]:
68
+ items = sorted(sidebar, key=lambda item: (item.order, item.key))
69
+ if active_admin is None:
70
+ return items
71
+ base_path = admin_public_path(active_admin, mount_path).rstrip("/")
72
+ return [
73
+ item
74
+ if item.path.startswith("/")
75
+ else item.model_copy(update={"path": f"{base_path}/{item.path.strip('/')}"})
76
+ for item in items
77
+ ]
78
+
79
+
80
+ @lru_cache(maxsize=1)
81
+ def _static_version() -> str:
82
+ mtimes: list[int] = []
83
+ for relative in ("css/cabinet.css", "js/cabinet.js"):
84
+ try:
85
+ mtimes.append(int((_STATIC_DIR / relative).stat().st_mtime))
86
+ except OSError:
87
+ mtimes.append(0)
88
+ return str(max(mtimes))
@@ -0,0 +1,44 @@
1
+ from typing import cast
2
+
3
+ from fastapi import Request
4
+ from pydantic import BaseModel, ValidationError
5
+
6
+ from fastapi_cabinet.contracts.admin import CabinetAdmin
7
+ from fastapi_cabinet.contracts.pages import (
8
+ CabinetPage,
9
+ DetailPageMap,
10
+ FormPageMap,
11
+ ListPageMap,
12
+ OperationPageMap,
13
+ PageMap,
14
+ )
15
+ from fastapi_cabinet.exceptions import CabinetProviderError
16
+
17
+
18
+ async def resolve_page(admin: CabinetAdmin, declaration: CabinetPage, request: Request) -> PageMap:
19
+ provider = admin.providers.get(declaration.provider)
20
+ if provider is None:
21
+ raise CabinetProviderError(
22
+ f"Provider {declaration.provider!r} is not registered for cabinet admin {admin.key!r}."
23
+ )
24
+ result = await provider(request)
25
+ model = _model_for_kind(declaration.kind)
26
+ try:
27
+ return cast("PageMap", model.model_validate(result))
28
+ except ValidationError as exc:
29
+ raise CabinetProviderError(
30
+ f"Provider {declaration.provider!r} returned an invalid {declaration.kind!r} page map."
31
+ ) from exc
32
+
33
+
34
+ def _model_for_kind(kind: str) -> type[BaseModel]:
35
+ models: dict[str, type[BaseModel]] = {
36
+ "list": ListPageMap,
37
+ "detail": DetailPageMap,
38
+ "form": FormPageMap,
39
+ "operation": OperationPageMap,
40
+ }
41
+ try:
42
+ return models[kind]
43
+ except KeyError as exc:
44
+ raise CabinetProviderError(f"Unsupported cabinet page kind {kind!r}.") from exc
@@ -0,0 +1,59 @@
1
+ from typing import cast
2
+
3
+ from fastapi import Request
4
+ from pydantic import BaseModel, ValidationError
5
+
6
+ from fastapi_cabinet.contracts.admin import CabinetAdmin
7
+ from fastapi_cabinet.contracts.widgets import (
8
+ ChartWidgetMap,
9
+ DashboardWidget,
10
+ ListWidgetMap,
11
+ MetricWidgetMap,
12
+ TableWidgetMap,
13
+ WidgetMap,
14
+ )
15
+ from fastapi_cabinet.exceptions import CabinetProviderError
16
+
17
+
18
+ async def resolve_widgets(
19
+ admin: CabinetAdmin,
20
+ declarations: tuple[DashboardWidget, ...],
21
+ request: Request,
22
+ ) -> list[WidgetMap]:
23
+ result: list[WidgetMap] = []
24
+ for declaration in sorted(declarations, key=lambda widget: (widget.order, widget.key)):
25
+ result.append(await resolve_widget(admin, declaration, request))
26
+ return result
27
+
28
+
29
+ async def resolve_widget(
30
+ admin: CabinetAdmin,
31
+ declaration: DashboardWidget,
32
+ request: Request,
33
+ ) -> WidgetMap:
34
+ provider = admin.providers.get(declaration.provider)
35
+ if provider is None:
36
+ raise CabinetProviderError(
37
+ f"Provider {declaration.provider!r} is not registered for cabinet admin {admin.key!r}."
38
+ )
39
+ result = await provider(request)
40
+ model = _model_for_kind(declaration.kind)
41
+ try:
42
+ return cast("WidgetMap", model.model_validate(result))
43
+ except ValidationError as exc:
44
+ raise CabinetProviderError(
45
+ f"Provider {declaration.provider!r} returned an invalid {declaration.kind!r} widget map."
46
+ ) from exc
47
+
48
+
49
+ def _model_for_kind(kind: str) -> type[BaseModel]:
50
+ models: dict[str, type[BaseModel]] = {
51
+ "metric": MetricWidgetMap,
52
+ "table": TableWidgetMap,
53
+ "list": ListWidgetMap,
54
+ "chart": ChartWidgetMap,
55
+ }
56
+ try:
57
+ return models[kind]
58
+ except KeyError as exc:
59
+ raise CabinetProviderError(f"Unsupported dashboard widget kind {kind!r}.") from exc