fastapi-admin-kit 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.
- fastapi_admin_kit/__init__.py +73 -0
- fastapi_admin_kit/actions/__init__.py +63 -0
- fastapi_admin_kit/actions/base.py +68 -0
- fastapi_admin_kit/actions/registry.py +43 -0
- fastapi_admin_kit/admin/__init__.py +17 -0
- fastapi_admin_kit/admin/admin_config.py +95 -0
- fastapi_admin_kit/admin/admin_database.py +138 -0
- fastapi_admin_kit/admin/admin_router.py +74 -0
- fastapi_admin_kit/admin/admin_template.py +203 -0
- fastapi_admin_kit/admin/builtin_models.py +284 -0
- fastapi_admin_kit/admin/core.py +1036 -0
- fastapi_admin_kit/admin/decorators.py +70 -0
- fastapi_admin_kit/admin/state.py +76 -0
- fastapi_admin_kit/admin.py +728 -0
- fastapi_admin_kit/api/__init__.py +44 -0
- fastapi_admin_kit/api/auth.py +342 -0
- fastapi_admin_kit/api/crud.py +128 -0
- fastapi_admin_kit/api/deps.py +79 -0
- fastapi_admin_kit/api/roles.py +128 -0
- fastapi_admin_kit/api/schema_generator.py +171 -0
- fastapi_admin_kit/api/schemas.py +81 -0
- fastapi_admin_kit/api/search.py +132 -0
- fastapi_admin_kit/audit/__init__.py +36 -0
- fastapi_admin_kit/audit/context.py +62 -0
- fastapi_admin_kit/audit/diff.py +77 -0
- fastapi_admin_kit/audit/event_bus.py +96 -0
- fastapi_admin_kit/audit/events.py +48 -0
- fastapi_admin_kit/audit/listener.py +159 -0
- fastapi_admin_kit/audit/logger.py +28 -0
- fastapi_admin_kit/audit/middleware.py +39 -0
- fastapi_admin_kit/audit/models.py +53 -0
- fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
- fastapi_admin_kit/auth/__init__.py +34 -0
- fastapi_admin_kit/auth/backend.py +95 -0
- fastapi_admin_kit/auth/csrf.py +240 -0
- fastapi_admin_kit/auth/dependencies.py +150 -0
- fastapi_admin_kit/auth/identity.py +181 -0
- fastapi_admin_kit/auth/models.py +246 -0
- fastapi_admin_kit/auth/password.py +35 -0
- fastapi_admin_kit/auth/permissions.py +205 -0
- fastapi_admin_kit/auth/protocol.py +22 -0
- fastapi_admin_kit/auth/ratelimit.py +88 -0
- fastapi_admin_kit/auth/router.py +10 -0
- fastapi_admin_kit/auth/session.py +79 -0
- fastapi_admin_kit/auth/totp.py +83 -0
- fastapi_admin_kit/auth/views.py +165 -0
- fastapi_admin_kit/cli.py +229 -0
- fastapi_admin_kit/config/__init__.py +19 -0
- fastapi_admin_kit/config/audit.py +18 -0
- fastapi_admin_kit/config/auth.py +54 -0
- fastapi_admin_kit/config/behavior.py +27 -0
- fastapi_admin_kit/config/nav.py +32 -0
- fastapi_admin_kit/config/storage.py +22 -0
- fastapi_admin_kit/config/theme.py +215 -0
- fastapi_admin_kit/config/ui.py +147 -0
- fastapi_admin_kit/dashboard/__init__.py +64 -0
- fastapi_admin_kit/db.py +133 -0
- fastapi_admin_kit/exceptions.py +5 -0
- fastapi_admin_kit/field_types.py +81 -0
- fastapi_admin_kit/filters/__init__.py +21 -0
- fastapi_admin_kit/filters/base.py +170 -0
- fastapi_admin_kit/filters/registry.py +68 -0
- fastapi_admin_kit/flash.py +45 -0
- fastapi_admin_kit/form/__init__.py +1 -0
- fastapi_admin_kit/form/pipeline.py +106 -0
- fastapi_admin_kit/inspection/__init__.py +117 -0
- fastapi_admin_kit/inspection/registry.py +253 -0
- fastapi_admin_kit/inspection.py +115 -0
- fastapi_admin_kit/modeladmin.py +375 -0
- fastapi_admin_kit/models/__init__.py +7 -0
- fastapi_admin_kit/models/base.py +7 -0
- fastapi_admin_kit/nav.py +208 -0
- fastapi_admin_kit/pagination/__init__.py +14 -0
- fastapi_admin_kit/pagination/base.py +40 -0
- fastapi_admin_kit/pagination/cursor.py +97 -0
- fastapi_admin_kit/pagination/dynamic.py +48 -0
- fastapi_admin_kit/pagination/offset.py +42 -0
- fastapi_admin_kit/plugins/__init__.py +1 -0
- fastapi_admin_kit/py.typed +0 -0
- fastapi_admin_kit/registry/__init__.py +5 -0
- fastapi_admin_kit/registry/core.py +287 -0
- fastapi_admin_kit/registry/validation.py +107 -0
- fastapi_admin_kit/registry.py +15 -0
- fastapi_admin_kit/router.py +335 -0
- fastapi_admin_kit/static/css/admin.css +4736 -0
- fastapi_admin_kit/static/css/presets.css +317 -0
- fastapi_admin_kit/static/css/tokens.css +217 -0
- fastapi_admin_kit/static/css/variables.css +74 -0
- fastapi_admin_kit/static/icons/heroicons.svg +160 -0
- fastapi_admin_kit/static/js/admin.js +692 -0
- fastapi_admin_kit/static/js/htmx-config.js +42 -0
- fastapi_admin_kit/storage/__init__.py +6 -0
- fastapi_admin_kit/storage/base.py +48 -0
- fastapi_admin_kit/storage/local.py +73 -0
- fastapi_admin_kit/templates/base.html +142 -0
- fastapi_admin_kit/templates/macros/form_fields.html +660 -0
- fastapi_admin_kit/templates/macros/icons.html +50 -0
- fastapi_admin_kit/templates/macros/table.html +108 -0
- fastapi_admin_kit/templates/macros/widgets.html +159 -0
- fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
- fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
- fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
- fastapi_admin_kit/templates/pages/audit_log.html +102 -0
- fastapi_admin_kit/templates/pages/dashboard.html +295 -0
- fastapi_admin_kit/templates/pages/detail.html +183 -0
- fastapi_admin_kit/templates/pages/form.html +119 -0
- fastapi_admin_kit/templates/pages/list.html +277 -0
- fastapi_admin_kit/templates/pages/login.html +85 -0
- fastapi_admin_kit/templates/pages/profile/password.html +78 -0
- fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
- fastapi_admin_kit/templates/pages/role_form.html +75 -0
- fastapi_admin_kit/templates/pages/roles/form.html +117 -0
- fastapi_admin_kit/templates/pages/roles/list.html +69 -0
- fastapi_admin_kit/templates/pages/roles.html +77 -0
- fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
- fastapi_admin_kit/templates/pages/users/form.html +229 -0
- fastapi_admin_kit/templates/pages/users/list.html +83 -0
- fastapi_admin_kit/templates/partials/command_palette.html +52 -0
- fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
- fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
- fastapi_admin_kit/templates/partials/head.html +21 -0
- fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
- fastapi_admin_kit/templates/partials/list_table.html +178 -0
- fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
- fastapi_admin_kit/templates/partials/pagination.html +82 -0
- fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
- fastapi_admin_kit/templates/partials/scripts.html +13 -0
- fastapi_admin_kit/templates/partials/sidebar.html +94 -0
- fastapi_admin_kit/templates/partials/topbar.html +95 -0
- fastapi_admin_kit/types.py +145 -0
- fastapi_admin_kit/validation.py +43 -0
- fastapi_admin_kit/views/__init__.py +78 -0
- fastapi_admin_kit/views/audit.py +134 -0
- fastapi_admin_kit/views/bulk.py +28 -0
- fastapi_admin_kit/views/class_views.py +1040 -0
- fastapi_admin_kit/views/context.py +588 -0
- fastapi_admin_kit/views/dashboard.py +162 -0
- fastapi_admin_kit/views/delete.py +31 -0
- fastapi_admin_kit/views/extra.py +65 -0
- fastapi_admin_kit/views/factory.py +667 -0
- fastapi_admin_kit/views/form.py +159 -0
- fastapi_admin_kit/views/list.py +28 -0
- fastapi_admin_kit/views/profile.py +219 -0
- fastapi_admin_kit/views/protocols.py +54 -0
- fastapi_admin_kit/views/renderers.py +634 -0
- fastapi_admin_kit/views/roles.py +230 -0
- fastapi_admin_kit/views/search.py +31 -0
- fastapi_admin_kit/views/settings.py +31 -0
- fastapi_admin_kit/views/sidebar.py +101 -0
- fastapi_admin_kit/views/totp.py +249 -0
- fastapi_admin_kit/views/users.py +347 -0
- fastapi_admin_kit/views.py +117 -0
- fastapi_admin_kit/widgets/__init__.py +44 -0
- fastapi_admin_kit/widgets/base.py +44 -0
- fastapi_admin_kit/widgets/inputs.py +363 -0
- fastapi_admin_kit/widgets/registry.py +110 -0
- fastapi_admin_kit/widgets/relation.py +70 -0
- fastapi_admin_kit/widgets/resolver.py +102 -0
- fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
- fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
- fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Filter ABCs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Filter(ABC):
|
|
10
|
+
"""Abstract base class for list view filters."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, field_name: str, label: str = "") -> None:
|
|
13
|
+
self.field_name = field_name
|
|
14
|
+
self.label = label or field_name.replace("_", " ").title()
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
18
|
+
"""Apply the filter to a SQLAlchemy select query."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def get_choices(self, session: Any) -> list[tuple[str, str]]:
|
|
22
|
+
"""Return available filter choices as (value, label) pairs."""
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TextFilter(Filter):
|
|
27
|
+
"""Simple text equality filter."""
|
|
28
|
+
|
|
29
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
30
|
+
model = query.column_descriptions[0]["entity"]
|
|
31
|
+
if hasattr(model, self.field_name):
|
|
32
|
+
col = getattr(model, self.field_name)
|
|
33
|
+
return query.where(col == value)
|
|
34
|
+
return query
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BooleanFilter(Filter):
|
|
38
|
+
"""Boolean filter — maps '1' to True, '0' to False."""
|
|
39
|
+
|
|
40
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
41
|
+
model = query.column_descriptions[0]["entity"]
|
|
42
|
+
if hasattr(model, self.field_name):
|
|
43
|
+
col = getattr(model, self.field_name)
|
|
44
|
+
return query.where(col == (value == "1"))
|
|
45
|
+
return query
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RelationFilter(Filter):
|
|
49
|
+
"""Filter by foreign key relationship."""
|
|
50
|
+
|
|
51
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
52
|
+
model = query.column_descriptions[0]["entity"]
|
|
53
|
+
if hasattr(model, self.field_name):
|
|
54
|
+
col = getattr(model, self.field_name)
|
|
55
|
+
return query.where(col == value)
|
|
56
|
+
return query
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EnumFilter(Filter):
|
|
60
|
+
"""Filter for enum columns."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
field_name: str,
|
|
65
|
+
label: str = "",
|
|
66
|
+
choices: list[str] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(field_name, label)
|
|
69
|
+
self._enum_choices = choices or []
|
|
70
|
+
|
|
71
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
72
|
+
model = query.column_descriptions[0]["entity"]
|
|
73
|
+
if hasattr(model, self.field_name):
|
|
74
|
+
col = getattr(model, self.field_name)
|
|
75
|
+
return query.where(col == value)
|
|
76
|
+
return query
|
|
77
|
+
|
|
78
|
+
def get_choices(self, session: Any) -> list[tuple[str, str]]:
|
|
79
|
+
choices = [("", "All")]
|
|
80
|
+
for val in self._enum_choices:
|
|
81
|
+
choices.append((val, val.replace("_", " ").title()))
|
|
82
|
+
return choices
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class NumericFilter(Filter):
|
|
86
|
+
"""Numeric range filter (gte/lte)."""
|
|
87
|
+
|
|
88
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
89
|
+
model = query.column_descriptions[0]["entity"]
|
|
90
|
+
if not hasattr(model, self.field_name):
|
|
91
|
+
return query
|
|
92
|
+
col = getattr(model, self.field_name)
|
|
93
|
+
if isinstance(value, dict):
|
|
94
|
+
if value.get("gte"):
|
|
95
|
+
query = query.where(col >= value["gte"])
|
|
96
|
+
if value.get("lte"):
|
|
97
|
+
query = query.where(col <= value["lte"])
|
|
98
|
+
return query
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class DateRangeFilter(Filter):
|
|
102
|
+
"""Date range filter (from/to)."""
|
|
103
|
+
|
|
104
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
105
|
+
from datetime import date
|
|
106
|
+
|
|
107
|
+
model = query.column_descriptions[0]["entity"]
|
|
108
|
+
if not hasattr(model, self.field_name):
|
|
109
|
+
return query
|
|
110
|
+
col = getattr(model, self.field_name)
|
|
111
|
+
if isinstance(value, dict):
|
|
112
|
+
if value.get("from"):
|
|
113
|
+
try:
|
|
114
|
+
d = date.fromisoformat(value["from"])
|
|
115
|
+
query = query.where(col >= d)
|
|
116
|
+
except (ValueError, TypeError):
|
|
117
|
+
pass
|
|
118
|
+
if value.get("to"):
|
|
119
|
+
try:
|
|
120
|
+
d = date.fromisoformat(value["to"])
|
|
121
|
+
query = query.where(col <= d)
|
|
122
|
+
except (ValueError, TypeError):
|
|
123
|
+
pass
|
|
124
|
+
return query
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DatetimeRangeFilter(Filter):
|
|
128
|
+
"""Datetime range filter (from/to)."""
|
|
129
|
+
|
|
130
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
131
|
+
from datetime import datetime
|
|
132
|
+
|
|
133
|
+
model = query.column_descriptions[0]["entity"]
|
|
134
|
+
if not hasattr(model, self.field_name):
|
|
135
|
+
return query
|
|
136
|
+
col = getattr(model, self.field_name)
|
|
137
|
+
if isinstance(value, dict):
|
|
138
|
+
if value.get("from"):
|
|
139
|
+
try:
|
|
140
|
+
dt = datetime.fromisoformat(value["from"])
|
|
141
|
+
query = query.where(col >= dt)
|
|
142
|
+
except (ValueError, TypeError):
|
|
143
|
+
pass
|
|
144
|
+
if value.get("to"):
|
|
145
|
+
try:
|
|
146
|
+
dt = datetime.fromisoformat(value["to"])
|
|
147
|
+
query = query.where(col <= dt)
|
|
148
|
+
except (ValueError, TypeError):
|
|
149
|
+
pass
|
|
150
|
+
return query
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class AutocompleteFilter(Filter):
|
|
154
|
+
"""Autocomplete search filter for related fields."""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
field_name: str,
|
|
159
|
+
label: str = "",
|
|
160
|
+
search_fields: list[str] | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
super().__init__(field_name, label)
|
|
163
|
+
self.search_fields = search_fields or ["name"]
|
|
164
|
+
|
|
165
|
+
def apply(self, query: Any, value: str) -> Any:
|
|
166
|
+
model = query.column_descriptions[0]["entity"]
|
|
167
|
+
if not hasattr(model, self.field_name):
|
|
168
|
+
return query
|
|
169
|
+
col = getattr(model, self.field_name)
|
|
170
|
+
return query.where(col == value)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Filter registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi_admin_kit.filters.base import (
|
|
8
|
+
BooleanFilter,
|
|
9
|
+
EnumFilter,
|
|
10
|
+
Filter,
|
|
11
|
+
RelationFilter,
|
|
12
|
+
TextFilter,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FilterRegistry:
|
|
17
|
+
"""Registry for custom filters per model."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._filters: dict[str, dict[str, Filter]] = {}
|
|
21
|
+
|
|
22
|
+
def register(self, model_name: str, filter_obj: Filter) -> None:
|
|
23
|
+
self._filters.setdefault(model_name, {})[filter_obj.field_name] = (
|
|
24
|
+
filter_obj
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def get_filters(self, model_name: str) -> dict[str, Filter]:
|
|
28
|
+
return self._filters.get(model_name, {}).copy()
|
|
29
|
+
|
|
30
|
+
def auto_generate(
|
|
31
|
+
self, model: Any, columns: list[Any]
|
|
32
|
+
) -> dict[str, Filter]:
|
|
33
|
+
from sqlalchemy import inspect as sa_inspect
|
|
34
|
+
|
|
35
|
+
mapper = sa_inspect(model)
|
|
36
|
+
filters: dict[str, Filter] = {}
|
|
37
|
+
|
|
38
|
+
for col_meta in columns:
|
|
39
|
+
field_name = col_meta.name
|
|
40
|
+
if field_name == "id":
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
rel_names = {r.key for r in mapper.relationships}
|
|
44
|
+
if field_name in rel_names:
|
|
45
|
+
filters[field_name] = RelationFilter(field_name)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
for prop in mapper.column_attrs:
|
|
49
|
+
if prop.key != field_name:
|
|
50
|
+
continue
|
|
51
|
+
col = prop.columns[0] if prop.columns else None
|
|
52
|
+
if col is None:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
type_name = col.type.__class__.__name__
|
|
56
|
+
if type_name == "Boolean":
|
|
57
|
+
filters[field_name] = BooleanFilter(field_name)
|
|
58
|
+
elif hasattr(col.type, "enums") and col.type.enums:
|
|
59
|
+
filters[field_name] = EnumFilter(
|
|
60
|
+
field_name, choices=list(col.type.enums)
|
|
61
|
+
)
|
|
62
|
+
elif col.foreign_keys:
|
|
63
|
+
filters[field_name] = RelationFilter(field_name)
|
|
64
|
+
else:
|
|
65
|
+
filters[field_name] = TextFilter(field_name)
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
return filters
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Flash message helpers for the admin UI.
|
|
2
|
+
|
|
3
|
+
Stored in a signed session cookie so they survive exactly one redirect-safe read.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
|
|
10
|
+
SESSION_KEY = "admin_flash"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def add_flash(request: Request, level: str, message: str) -> None:
|
|
14
|
+
session_backend = request.app.state.admin_session_backend
|
|
15
|
+
cookie_name = getattr(session_backend, "cookie_name", "admin_session")
|
|
16
|
+
data: dict[str, list[dict[str, str]]] | None = None
|
|
17
|
+
raw = request.cookies.get(cookie_name)
|
|
18
|
+
if raw and hasattr(session_backend, "load"):
|
|
19
|
+
loaded = session_backend.load(raw)
|
|
20
|
+
if isinstance(loaded, dict):
|
|
21
|
+
data = loaded
|
|
22
|
+
if data is None:
|
|
23
|
+
data = {}
|
|
24
|
+
data.setdefault(SESSION_KEY, []).append({"level": level, "text": message})
|
|
25
|
+
if hasattr(session_backend, "save"):
|
|
26
|
+
from starlette.responses import Response
|
|
27
|
+
response = Response()
|
|
28
|
+
session_backend.save(response, data)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def get_flash_messages(request: Request) -> list[dict[str, str]]:
|
|
32
|
+
session_backend = request.app.state.admin_session_backend
|
|
33
|
+
cookie_name = getattr(session_backend, "cookie_name", "admin_session")
|
|
34
|
+
raw = request.cookies.get(cookie_name)
|
|
35
|
+
if not raw or not hasattr(session_backend, "load"):
|
|
36
|
+
return []
|
|
37
|
+
data = session_backend.load(raw)
|
|
38
|
+
if not isinstance(data, dict):
|
|
39
|
+
return []
|
|
40
|
+
messages = data.pop(SESSION_KEY, []) if SESSION_KEY in data else []
|
|
41
|
+
if hasattr(session_backend, "save"):
|
|
42
|
+
from starlette.responses import Response
|
|
43
|
+
response = Response()
|
|
44
|
+
session_backend.save(response, data)
|
|
45
|
+
return messages
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Form generation and handling."""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Build FormContext from a RegisteredModel + DB object + values + errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi_admin_kit.types import (
|
|
8
|
+
FieldRenderContext,
|
|
9
|
+
FieldsetContext,
|
|
10
|
+
FormContext,
|
|
11
|
+
PermissionSet,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_form_context(
|
|
16
|
+
registered: Any,
|
|
17
|
+
obj: Any | None = None,
|
|
18
|
+
values: dict[str, Any] | None = None,
|
|
19
|
+
errors: dict[str, list[str]] | None = None,
|
|
20
|
+
request: Any = None,
|
|
21
|
+
is_create: bool = False,
|
|
22
|
+
rel_labels: dict[str, str] | None = None,
|
|
23
|
+
) -> FormContext:
|
|
24
|
+
values = values or {}
|
|
25
|
+
errors = errors or {}
|
|
26
|
+
rendered: list[FieldRenderContext] = []
|
|
27
|
+
fieldsets: list[FieldsetContext] = [FieldsetContext(fields=[])]
|
|
28
|
+
|
|
29
|
+
for field_meta in registered.form_fields:
|
|
30
|
+
col = next((c for c in registered.columns if c.name == field_meta.name), None)
|
|
31
|
+
rel = next((r for r in registered.relationships if r.name == field_meta.name), None)
|
|
32
|
+
widget = registered.get_widget(field_meta.name)
|
|
33
|
+
|
|
34
|
+
value = values.get(field_meta.name)
|
|
35
|
+
if value is None and obj is not None:
|
|
36
|
+
if hasattr(obj, "__dict__"):
|
|
37
|
+
value = obj.__dict__.get(field_meta.name)
|
|
38
|
+
# For M2M relationships, extract IDs from loaded collection
|
|
39
|
+
if value is None and rel is not None:
|
|
40
|
+
try:
|
|
41
|
+
from sqlalchemy import inspect as sa_inspect
|
|
42
|
+
mapper = sa_inspect(type(obj))
|
|
43
|
+
rel_prop = mapper.relationships.get(rel.name)
|
|
44
|
+
if rel_prop is not None:
|
|
45
|
+
if rel_prop.direction.name == "MANYTOMANY":
|
|
46
|
+
collection = getattr(obj, rel_prop.key, None)
|
|
47
|
+
if collection is not None:
|
|
48
|
+
value = [str(item.id) for item in collection]
|
|
49
|
+
else:
|
|
50
|
+
local_cols = [c.key for c in rel_prop.local_columns]
|
|
51
|
+
if local_cols:
|
|
52
|
+
value = getattr(obj, local_cols[0], None)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
# Handle case where __dict__ returned loaded M2M collection objects
|
|
56
|
+
if (
|
|
57
|
+
value is not None
|
|
58
|
+
and rel is not None
|
|
59
|
+
and isinstance(value, list)
|
|
60
|
+
and value
|
|
61
|
+
and hasattr(value[0], "id")
|
|
62
|
+
):
|
|
63
|
+
try:
|
|
64
|
+
from sqlalchemy import inspect as sa_inspect
|
|
65
|
+
mapper = sa_inspect(type(obj))
|
|
66
|
+
rel_prop = mapper.relationships.get(rel.name)
|
|
67
|
+
if rel_prop is not None and rel_prop.direction.name == "MANYTOMANY":
|
|
68
|
+
value = [str(item.id) for item in value]
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
elif value is None and col is not None:
|
|
72
|
+
value = getattr(obj, field_meta.name, None)
|
|
73
|
+
|
|
74
|
+
widget_macro = widget.macro_name
|
|
75
|
+
widget_ctx = widget.render_context(field_meta, value)
|
|
76
|
+
widget_ctx["is_create"] = is_create
|
|
77
|
+
if obj is not None:
|
|
78
|
+
widget_ctx["obj_id"] = getattr(obj, "id", "")
|
|
79
|
+
if rel is not None and rel_labels:
|
|
80
|
+
widget_ctx["label_text"] = rel_labels.get(rel.name, "")
|
|
81
|
+
field_errors = errors.get(field_meta.name, [])
|
|
82
|
+
rendered.append(
|
|
83
|
+
FieldRenderContext(
|
|
84
|
+
meta=field_meta,
|
|
85
|
+
widget_macro=widget_macro,
|
|
86
|
+
widget_context=widget_ctx,
|
|
87
|
+
errors=field_errors,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
fieldsets[0].fields = rendered
|
|
92
|
+
|
|
93
|
+
return FormContext(
|
|
94
|
+
model_name=registered.table_name,
|
|
95
|
+
verbose_name=registered.verbose_name,
|
|
96
|
+
is_create=is_create,
|
|
97
|
+
obj=obj,
|
|
98
|
+
fieldsets=fieldsets,
|
|
99
|
+
errors=errors,
|
|
100
|
+
values=values,
|
|
101
|
+
action_url="",
|
|
102
|
+
list_url="",
|
|
103
|
+
can_delete=not is_create,
|
|
104
|
+
permissions=PermissionSet(),
|
|
105
|
+
readonly=False,
|
|
106
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Model inspection — SQLAlchemy model → ColumnMeta / RelationMeta."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import inspect
|
|
9
|
+
|
|
10
|
+
from fastapi_admin_kit.types import ColumnMeta, RelationMeta
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inspect_model(model: type) -> tuple[list[ColumnMeta], list[RelationMeta]]:
|
|
14
|
+
"""Inspect a SQLAlchemy model and return column + relationship metadata."""
|
|
15
|
+
mapper = inspect(model)
|
|
16
|
+
columns: list[ColumnMeta] = []
|
|
17
|
+
relationships: list[RelationMeta] = []
|
|
18
|
+
|
|
19
|
+
for col in mapper.columns:
|
|
20
|
+
columns.append(
|
|
21
|
+
ColumnMeta(
|
|
22
|
+
name=col.key,
|
|
23
|
+
type=col.type,
|
|
24
|
+
nullable=col.nullable,
|
|
25
|
+
primary_key=col.primary_key,
|
|
26
|
+
foreign_keys=list(col.foreign_keys),
|
|
27
|
+
default=col.default,
|
|
28
|
+
server_default=col.server_default,
|
|
29
|
+
index=col.index,
|
|
30
|
+
unique=col.unique,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
for rel in mapper.relationships:
|
|
35
|
+
relationships.append(
|
|
36
|
+
RelationMeta(
|
|
37
|
+
name=rel.key,
|
|
38
|
+
direction=rel.direction.name,
|
|
39
|
+
target_model=rel.mapper.class_,
|
|
40
|
+
uselist=rel.uselist,
|
|
41
|
+
back_populates=rel.back_populates,
|
|
42
|
+
secondary=rel.secondary,
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return columns, relationships
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_abstract(model: type) -> bool:
|
|
50
|
+
"""Check if a model is abstract and should be skipped during auto-discovery."""
|
|
51
|
+
return getattr(model, "__abstract__", False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_pk_field(model: type) -> str | None:
|
|
55
|
+
"""Get the primary key field name for a model.
|
|
56
|
+
|
|
57
|
+
Returns the single PK field name for simple PKs,
|
|
58
|
+
or a tuple of names for composite PKs.
|
|
59
|
+
Returns None if no primary key is found.
|
|
60
|
+
"""
|
|
61
|
+
mapper = inspect(model)
|
|
62
|
+
pk_cols = mapper.primary_key
|
|
63
|
+
if not pk_cols:
|
|
64
|
+
return None
|
|
65
|
+
if len(pk_cols) == 1:
|
|
66
|
+
return pk_cols[0].key
|
|
67
|
+
return tuple(col.key for col in pk_cols)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def auto_label(name: str) -> str:
|
|
71
|
+
"""Auto-generate a human-readable label from a field name.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
"category_id" → "Category"
|
|
75
|
+
"is_active" → "Is Active"
|
|
76
|
+
"created_at" → "Created At"
|
|
77
|
+
"skuCode" → "Sku Code"
|
|
78
|
+
"""
|
|
79
|
+
label = name
|
|
80
|
+
if label.endswith("_id"):
|
|
81
|
+
label = label[:-3]
|
|
82
|
+
label = re.sub(r"([A-Z])", r" \1", label)
|
|
83
|
+
return label.replace("_", " ").strip().title()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_required(col: ColumnMeta) -> bool:
|
|
87
|
+
"""Determine if a column is required (NOT NULL with no default).
|
|
88
|
+
|
|
89
|
+
A column is required if:
|
|
90
|
+
- It is NOT NULL
|
|
91
|
+
- It has no Python default
|
|
92
|
+
- It has no server_default (DB-side default)
|
|
93
|
+
- It is NOT a primary key (PKs are handled separately)
|
|
94
|
+
"""
|
|
95
|
+
return (
|
|
96
|
+
not col.nullable
|
|
97
|
+
and col.default is None
|
|
98
|
+
and col.server_default is None
|
|
99
|
+
and not col.primary_key
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def model_display_name(obj: Any) -> str:
|
|
104
|
+
"""Return a human-readable label for an ORM object.
|
|
105
|
+
|
|
106
|
+
Uses the model's ``__str__`` if it has a custom implementation.
|
|
107
|
+
Falls back to ``name``, ``title``, or ``ClassName:pk``.
|
|
108
|
+
"""
|
|
109
|
+
if type(obj).__str__ is not object.__str__:
|
|
110
|
+
return str(obj)
|
|
111
|
+
label = getattr(obj, "name", None) or getattr(obj, "title", None)
|
|
112
|
+
if label is not None:
|
|
113
|
+
return str(label)
|
|
114
|
+
pk = getattr(obj, "id", None)
|
|
115
|
+
return (
|
|
116
|
+
f"{type(obj).__name__}:{pk}" if pk is not None else type(obj).__name__
|
|
117
|
+
)
|