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.
- codex_fastapi_cabinet-0.1.0.dist-info/METADATA +182 -0
- codex_fastapi_cabinet-0.1.0.dist-info/RECORD +39 -0
- codex_fastapi_cabinet-0.1.0.dist-info/WHEEL +4 -0
- codex_fastapi_cabinet-0.1.0.dist-info/licenses/LICENSE +190 -0
- fastapi_cabinet/__init__.py +90 -0
- fastapi_cabinet/contracts/__init__.py +86 -0
- fastapi_cabinet/contracts/admin.py +50 -0
- fastapi_cabinet/contracts/layout.py +22 -0
- fastapi_cabinet/contracts/navigation.py +29 -0
- fastapi_cabinet/contracts/pages.py +164 -0
- fastapi_cabinet/contracts/permissions.py +16 -0
- fastapi_cabinet/contracts/providers.py +9 -0
- fastapi_cabinet/contracts/widgets.py +102 -0
- fastapi_cabinet/exceptions.py +18 -0
- fastapi_cabinet/fastapi.py +36 -0
- fastapi_cabinet/loader.py +68 -0
- fastapi_cabinet/registry.py +31 -0
- fastapi_cabinet/rendering/__init__.py +5 -0
- fastapi_cabinet/rendering/layout_mapper.py +88 -0
- fastapi_cabinet/rendering/page_mapper.py +44 -0
- fastapi_cabinet/rendering/widget_mapper.py +59 -0
- fastapi_cabinet/runtime.py +58 -0
- fastapi_cabinet/site.py +262 -0
- fastapi_cabinet/static/css/cabinet.css +126 -0
- fastapi_cabinet/static/js/cabinet.js +6 -0
- fastapi_cabinet/templates/cabinet/base.html +21 -0
- fastapi_cabinet/templates/cabinet/dashboard.html +14 -0
- fastapi_cabinet/templates/cabinet/includes/sidebar.html +20 -0
- fastapi_cabinet/templates/cabinet/includes/topbar.html +21 -0
- fastapi_cabinet/templates/cabinet/includes/widget_frame.html +3 -0
- fastapi_cabinet/templates/cabinet/module.html +18 -0
- fastapi_cabinet/templates/cabinet/pages/detail.html +39 -0
- fastapi_cabinet/templates/cabinet/pages/form.html +47 -0
- fastapi_cabinet/templates/cabinet/pages/list.html +81 -0
- fastapi_cabinet/templates/cabinet/pages/operation.html +29 -0
- fastapi_cabinet/templates/cabinet/widgets/chart.html +20 -0
- fastapi_cabinet/templates/cabinet/widgets/list.html +8 -0
- fastapi_cabinet/templates/cabinet/widgets/metric.html +9 -0
- 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
|