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,375 @@
|
|
|
1
|
+
"""ModelAdmin base class — configuration + lifecycle hooks + validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from fastapi_admin_kit.admin.decorators import column
|
|
8
|
+
from fastapi_admin_kit.types import ExtraField, FieldMeta
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sqlalchemy.orm import Session
|
|
12
|
+
|
|
13
|
+
from fastapi_admin_kit.nav import NavItemConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelAdmin:
|
|
17
|
+
"""Base class for model admin configuration.
|
|
18
|
+
|
|
19
|
+
Subclass this to customise how a model is displayed, filtered, and edited.
|
|
20
|
+
All fields are optional — unset fields fall back to auto-detected values.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# List view config
|
|
24
|
+
list_display: list[str] | None = None
|
|
25
|
+
list_filter: list[str] | None = None
|
|
26
|
+
search_fields: list[str] | None = None
|
|
27
|
+
ordering: list[str] | None = None
|
|
28
|
+
per_page: int = 20
|
|
29
|
+
pagination: Any = None # OffsetPagination | CursorPagination | DynamicPagination
|
|
30
|
+
list_filter_options: dict[str, dict[str, Any]] = {}
|
|
31
|
+
list_filter_horizontal: bool = False
|
|
32
|
+
|
|
33
|
+
# Actions config
|
|
34
|
+
actions_list: list[str] = []
|
|
35
|
+
actions_row: list[str] = []
|
|
36
|
+
actions_detail: list[str] = []
|
|
37
|
+
actions_submit_line: list[str] = []
|
|
38
|
+
actions_list_hide_default: bool = False
|
|
39
|
+
|
|
40
|
+
# Form config
|
|
41
|
+
fields: list[str] | None = None
|
|
42
|
+
exclude: list[str] | None = None
|
|
43
|
+
readonly_fields: list[str] | None = None
|
|
44
|
+
formfield_overrides: dict[str, Any] = {}
|
|
45
|
+
extra_fields: list[ExtraField] = []
|
|
46
|
+
fieldsets: list[
|
|
47
|
+
Any
|
|
48
|
+
] = [] # FieldsetSpec accepted but not strictly enforced here
|
|
49
|
+
field_placeholders: dict[str, str] = {} # {field_name: placeholder_text}
|
|
50
|
+
|
|
51
|
+
# Conditional fields
|
|
52
|
+
conditional_fields: dict[str, dict[str, Any]] = {}
|
|
53
|
+
|
|
54
|
+
# Form UX config
|
|
55
|
+
warn_unsaved_form: bool = True
|
|
56
|
+
compressed_fields: bool = True
|
|
57
|
+
change_form_show_cancel_button: bool = True
|
|
58
|
+
|
|
59
|
+
# Labels and display
|
|
60
|
+
verbose_name: str | None = None
|
|
61
|
+
verbose_name_plural: str | None = None
|
|
62
|
+
icon: str | None = None
|
|
63
|
+
tag: str | None = None
|
|
64
|
+
tags: list[str] | None = None
|
|
65
|
+
nav_order: int = 999
|
|
66
|
+
nav_children: list[NavItemConfig] | None = None
|
|
67
|
+
|
|
68
|
+
# Route generation
|
|
69
|
+
skip_auto_routes: bool = False
|
|
70
|
+
|
|
71
|
+
# Custom display functions (dict-based fallback)
|
|
72
|
+
display_functions: dict[str, Any] | None = None
|
|
73
|
+
|
|
74
|
+
# Decorator for custom column display
|
|
75
|
+
column = staticmethod(column)
|
|
76
|
+
|
|
77
|
+
# Badge hook — return str e.g. "12" or None
|
|
78
|
+
def get_nav_badge(self, request: Any = None) -> str | None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
# Object display
|
|
82
|
+
def __str__(self, obj: Any) -> str:
|
|
83
|
+
"""How to display an object in dropdowns/links."""
|
|
84
|
+
return str(
|
|
85
|
+
getattr(obj, "name", None)
|
|
86
|
+
or getattr(obj, "title", None)
|
|
87
|
+
or f"#{getattr(obj, 'id', '?')}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def get_model(self) -> Any:
|
|
91
|
+
return self.model
|
|
92
|
+
|
|
93
|
+
# ── Query hooks ──────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def get_queryset(self, session: Session, request: Any = None) -> Any:
|
|
96
|
+
"""Override to filter records globally (e.g. soft-delete filter)."""
|
|
97
|
+
return session.query(self.model) # type: ignore[attr-defined]
|
|
98
|
+
|
|
99
|
+
def get_object(self, session: Session, id: Any) -> Any:
|
|
100
|
+
"""Override for custom PK lookup."""
|
|
101
|
+
return session.get(self.model, id) # type: ignore[attr-defined]
|
|
102
|
+
|
|
103
|
+
# ── Lifecycle hooks (stubs) ─────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def prepare_create_data(self, data: dict[str, Any], request: Any = None) -> dict[str, Any]:
|
|
106
|
+
"""Strip extra fields and return only model-column data for INSERT."""
|
|
107
|
+
extra_names = {f.name for f in self.extra_fields}
|
|
108
|
+
return {k: v for k, v in data.items() if k not in extra_names}
|
|
109
|
+
|
|
110
|
+
def prepare_update_data(self, data: dict[str, Any], request: Any = None) -> dict[str, Any]:
|
|
111
|
+
"""Strip extra fields and return only model-column data for UPDATE."""
|
|
112
|
+
extra_names = {f.name for f in self.extra_fields}
|
|
113
|
+
return {k: v for k, v in data.items() if k not in extra_names}
|
|
114
|
+
|
|
115
|
+
def on_create(self, obj: Any, request: Any = None) -> None:
|
|
116
|
+
"""Called before INSERT. Mutate *obj* as needed."""
|
|
117
|
+
|
|
118
|
+
def after_create(self, obj: Any, request: Any = None) -> None:
|
|
119
|
+
"""Called after INSERT commit."""
|
|
120
|
+
|
|
121
|
+
def on_update(
|
|
122
|
+
self, obj: Any, data: dict[str, Any], request: Any = None
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Called before UPDATE. *data* contains the incoming form values."""
|
|
125
|
+
|
|
126
|
+
def after_update(self, obj: Any, request: Any = None) -> None:
|
|
127
|
+
"""Called after UPDATE commit."""
|
|
128
|
+
|
|
129
|
+
def on_delete(self, obj: Any, request: Any = None) -> None:
|
|
130
|
+
"""Called before DELETE."""
|
|
131
|
+
|
|
132
|
+
def after_delete(self, obj: Any, request: Any = None) -> None:
|
|
133
|
+
"""Called after DELETE commit."""
|
|
134
|
+
|
|
135
|
+
# ── Validation hooks (stubs) ────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def validate_create(
|
|
138
|
+
self, data: dict[str, Any], request: Any = None
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""Validate and/or transform form data before create.
|
|
141
|
+
|
|
142
|
+
Return the (possibly modified) data dict. Raise ``ValueError``
|
|
143
|
+
with a user-facing message to abort the operation.
|
|
144
|
+
"""
|
|
145
|
+
return data
|
|
146
|
+
|
|
147
|
+
def validate_update(
|
|
148
|
+
self, obj: Any, data: dict[str, Any], request: Any = None
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Validate and/or transform form data before update.
|
|
151
|
+
|
|
152
|
+
Return the (possibly modified) data dict. Raise ``ValueError``
|
|
153
|
+
with a user-facing message to abort the operation.
|
|
154
|
+
"""
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
# ── Form data processing hooks ──────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def process_form_data(self, data: dict[str, Any], request: Any = None) -> dict[str, Any]:
|
|
160
|
+
"""Process form data before save. Override for custom processing.
|
|
161
|
+
|
|
162
|
+
Called after form parsing and validation. Use this to transform
|
|
163
|
+
form data, extract special fields, or prepare data for saving.
|
|
164
|
+
"""
|
|
165
|
+
return data
|
|
166
|
+
|
|
167
|
+
def save_model(
|
|
168
|
+
self, obj: Any, data: dict[str, Any], request: Any = None, is_create: bool = False
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Custom save logic. Override for full control over save flow.
|
|
171
|
+
|
|
172
|
+
When overridden, this method is called instead of the default
|
|
173
|
+
save logic. The object is already added to the session but not
|
|
174
|
+
committed yet. Call session.commit() yourself if needed.
|
|
175
|
+
"""
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# ── Form context hooks ──────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
def get_form_context(
|
|
181
|
+
self, context: dict[str, Any], obj: Any = None, request: Any = None
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
"""Customize form template context. Return modified context.
|
|
184
|
+
|
|
185
|
+
Called when building the form context for create/edit views.
|
|
186
|
+
Override to add custom template variables.
|
|
187
|
+
"""
|
|
188
|
+
return context
|
|
189
|
+
|
|
190
|
+
# ── Dynamic field hooks ─────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def get_readonly_fields(self, obj: Any = None, request: Any = None) -> list[str]:
|
|
193
|
+
"""Return list of readonly field names. Override for dynamic readonly.
|
|
194
|
+
|
|
195
|
+
Called during form field generation. Fields returned here are
|
|
196
|
+
in addition to the static readonly_fields list.
|
|
197
|
+
"""
|
|
198
|
+
return self.readonly_fields or []
|
|
199
|
+
|
|
200
|
+
def get_hidden_fields(self, obj: Any = None, request: Any = None) -> list[str]:
|
|
201
|
+
"""Return list of hidden field names. Override for dynamic hidden.
|
|
202
|
+
|
|
203
|
+
Called during form field generation. Hidden fields are excluded
|
|
204
|
+
from the form entirely.
|
|
205
|
+
"""
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
# ── Form field helper ───────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
def get_form_fields(
|
|
211
|
+
self,
|
|
212
|
+
obj: Any = None,
|
|
213
|
+
request: Any = None,
|
|
214
|
+
columns: list[Any] | None = None,
|
|
215
|
+
relationships: list[Any] | None = None,
|
|
216
|
+
) -> list[FieldMeta]:
|
|
217
|
+
"""Return ordered list of FieldMeta objects for the create/edit form."""
|
|
218
|
+
from fastapi_admin_kit.inspection import auto_label, is_required
|
|
219
|
+
|
|
220
|
+
columns = columns or []
|
|
221
|
+
relationships = relationships or []
|
|
222
|
+
form_fields: list[FieldMeta] = []
|
|
223
|
+
|
|
224
|
+
raw = []
|
|
225
|
+
if self.fields is not None:
|
|
226
|
+
names = set(self.fields)
|
|
227
|
+
raw = [
|
|
228
|
+
c for c in columns if c.name in names and not c.primary_key
|
|
229
|
+
] + [
|
|
230
|
+
r
|
|
231
|
+
for r in relationships
|
|
232
|
+
if r.name in names
|
|
233
|
+
and r.direction in ("MANYTOONE", "MANYTOMANY")
|
|
234
|
+
]
|
|
235
|
+
else:
|
|
236
|
+
raw = [c for c in columns if not c.primary_key] + [
|
|
237
|
+
r
|
|
238
|
+
for r in relationships
|
|
239
|
+
if r.direction in ("MANYTOONE", "MANYTOMANY")
|
|
240
|
+
]
|
|
241
|
+
if self.exclude:
|
|
242
|
+
raw = [x for x in raw if x.name not in self.exclude]
|
|
243
|
+
|
|
244
|
+
# Exclude FK columns that have a corresponding relationship
|
|
245
|
+
# to avoid duplicate fields (e.g., user_id + user both showing)
|
|
246
|
+
from sqlalchemy import inspect as sa_inspect
|
|
247
|
+
|
|
248
|
+
rel_fk_cols: set[str] = set()
|
|
249
|
+
try:
|
|
250
|
+
mapper = sa_inspect(self.model)
|
|
251
|
+
for r in relationships:
|
|
252
|
+
if r.direction in ("MANYTOONE", "MANYTOMANY"):
|
|
253
|
+
rel_prop = mapper.relationships.get(r.name)
|
|
254
|
+
if rel_prop is not None:
|
|
255
|
+
for local_col in rel_prop.local_columns:
|
|
256
|
+
rel_fk_cols.add(local_col.key)
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
raw = [x for x in raw if not (hasattr(x, "foreign_keys") and x.name in rel_fk_cols)]
|
|
260
|
+
|
|
261
|
+
dynamic_readonly = set(self.get_readonly_fields())
|
|
262
|
+
dynamic_hidden = set(self.get_hidden_fields())
|
|
263
|
+
|
|
264
|
+
for item in raw:
|
|
265
|
+
name = item.name
|
|
266
|
+
if name in dynamic_hidden:
|
|
267
|
+
continue
|
|
268
|
+
readonly = name in (self.readonly_fields or []) or name in dynamic_readonly
|
|
269
|
+
required = is_required(item) if hasattr(item, "nullable") else False
|
|
270
|
+
label = auto_label(name)
|
|
271
|
+
placeholder = self.field_placeholders.get(
|
|
272
|
+
name, f"Enter {label.lower()}..."
|
|
273
|
+
)
|
|
274
|
+
form_fields.append(
|
|
275
|
+
FieldMeta(
|
|
276
|
+
name=name,
|
|
277
|
+
label=label,
|
|
278
|
+
required=required,
|
|
279
|
+
readonly=readonly,
|
|
280
|
+
placeholder=placeholder,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
for extra in self.extra_fields:
|
|
285
|
+
form_fields.append(
|
|
286
|
+
FieldMeta(
|
|
287
|
+
name=extra.name,
|
|
288
|
+
label=extra.label or auto_label(extra.name),
|
|
289
|
+
required=extra.required,
|
|
290
|
+
readonly=False,
|
|
291
|
+
extra={
|
|
292
|
+
"extra_field": True,
|
|
293
|
+
"widget": extra.widget,
|
|
294
|
+
"required_on_create": extra.required_on_create,
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Respect fieldsets ordering if defined
|
|
300
|
+
if self.fieldsets:
|
|
301
|
+
ordered: list[FieldMeta] = []
|
|
302
|
+
seen: set[str] = set()
|
|
303
|
+
for fs in self.fieldsets:
|
|
304
|
+
for fname in fs.fields:
|
|
305
|
+
for fm in form_fields:
|
|
306
|
+
if fm.name == fname and fname not in seen:
|
|
307
|
+
ordered.append(fm)
|
|
308
|
+
seen.add(fname)
|
|
309
|
+
break
|
|
310
|
+
for fm in form_fields:
|
|
311
|
+
if fm.name not in seen:
|
|
312
|
+
ordered.append(fm)
|
|
313
|
+
return ordered
|
|
314
|
+
|
|
315
|
+
return form_fields
|
|
316
|
+
|
|
317
|
+
# ── Action helpers ─────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
def get_actions_for_location(self, location: str) -> list[Any]:
|
|
320
|
+
"""Get resolved Action instances for a given location (list/row/detail/submit_line)."""
|
|
321
|
+
from fastapi_admin_kit.actions.base import Action
|
|
322
|
+
|
|
323
|
+
action_names = getattr(self, f"actions_{location}", [])
|
|
324
|
+
resolved = []
|
|
325
|
+
for name in action_names:
|
|
326
|
+
action_fn = getattr(self, name, None)
|
|
327
|
+
if not action_fn:
|
|
328
|
+
continue
|
|
329
|
+
if hasattr(action_fn, "_admin_action"):
|
|
330
|
+
resolved.append(action_fn._admin_action())
|
|
331
|
+
elif callable(action_fn):
|
|
332
|
+
label = name.replace("_", " ").title()
|
|
333
|
+
_fn = action_fn
|
|
334
|
+
_admin = self
|
|
335
|
+
|
|
336
|
+
class _FallbackAction(Action):
|
|
337
|
+
def __init__(self):
|
|
338
|
+
super().__init__(name=name, label=label)
|
|
339
|
+
|
|
340
|
+
async def execute(self, objects, request):
|
|
341
|
+
import inspect
|
|
342
|
+
|
|
343
|
+
if inspect.iscoroutinefunction(_fn):
|
|
344
|
+
await _fn(_admin, objects, request)
|
|
345
|
+
else:
|
|
346
|
+
_fn(_admin, objects, request)
|
|
347
|
+
|
|
348
|
+
resolved.append(_FallbackAction())
|
|
349
|
+
return resolved
|
|
350
|
+
|
|
351
|
+
def get_list_actions(self) -> list[Any]:
|
|
352
|
+
return self.get_actions_for_location("list")
|
|
353
|
+
|
|
354
|
+
def get_row_actions(self) -> list[Any]:
|
|
355
|
+
return self.get_actions_for_location("row")
|
|
356
|
+
|
|
357
|
+
def get_detail_actions(self) -> list[Any]:
|
|
358
|
+
return self.get_actions_for_location("detail")
|
|
359
|
+
|
|
360
|
+
def get_submit_line_actions(self) -> list[Any]:
|
|
361
|
+
return self.get_actions_for_location("submit_line")
|
|
362
|
+
|
|
363
|
+
# ── Permission helpers ───────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
def has_view_permission(self, request: Any = None) -> bool:
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
def has_create_permission(self, request: Any = None) -> bool:
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
def has_edit_permission(self, request: Any = None) -> bool:
|
|
372
|
+
return True
|
|
373
|
+
|
|
374
|
+
def has_delete_permission(self, request: Any = None) -> bool:
|
|
375
|
+
return True
|
fastapi_admin_kit/nav.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Navigation tag & group system for the admin sidebar.
|
|
2
|
+
|
|
3
|
+
Implements the full tag system from TAG_SPEC.md:
|
|
4
|
+
- NavGroupConfig / NavItemConfig — developer-provided config
|
|
5
|
+
- BuiltNavGroup / BuiltNavItem — resolved output of SidebarBuilder
|
|
6
|
+
- DefaultSidebarBuilder / SidebarBuilderProtocol
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from fastapi_admin_kit.registry import RegisteredModel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Public config types (passed by the developer)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class NavItemConfig:
|
|
26
|
+
"""A custom nav item placed inside a tag group (independent of models)."""
|
|
27
|
+
|
|
28
|
+
label: str
|
|
29
|
+
url: str
|
|
30
|
+
icon: str | None = None
|
|
31
|
+
order: int = 999
|
|
32
|
+
permission: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class NavGroupConfig:
|
|
37
|
+
"""Developer-provided configuration for one sidebar tag group."""
|
|
38
|
+
|
|
39
|
+
tag: str
|
|
40
|
+
label: str | None = None
|
|
41
|
+
icon: str | None = None
|
|
42
|
+
order: int = 999
|
|
43
|
+
collapsed_by_default: bool = False
|
|
44
|
+
permission: str | None = None
|
|
45
|
+
extra_items: list[NavItemConfig] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Built types (produced by SidebarBuilder at startup — read-only in templates)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class BuiltNavItem:
|
|
55
|
+
label: str
|
|
56
|
+
url: str
|
|
57
|
+
icon: str | None = None
|
|
58
|
+
order: int = 999
|
|
59
|
+
badge_fn: Callable | None = None
|
|
60
|
+
permission_table: str | None = None
|
|
61
|
+
children: list[BuiltNavItem] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class BuiltNavGroup:
|
|
66
|
+
tag: str
|
|
67
|
+
label: str
|
|
68
|
+
icon: str | None = None
|
|
69
|
+
order: int = 999
|
|
70
|
+
collapsed_by_default: bool = False
|
|
71
|
+
permission_table: str | None = None
|
|
72
|
+
items: list[BuiltNavItem] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# SidebarBuilder protocol + default implementation
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@runtime_checkable
|
|
81
|
+
class SidebarBuilder(Protocol):
|
|
82
|
+
"""Protocol that all sidebar builders must satisfy."""
|
|
83
|
+
|
|
84
|
+
def build(
|
|
85
|
+
self,
|
|
86
|
+
registry: list[RegisteredModel],
|
|
87
|
+
nav_group_configs: list[NavGroupConfig],
|
|
88
|
+
admin_path: str = "/admin",
|
|
89
|
+
) -> list[BuiltNavGroup]: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DefaultSidebarBuilder:
|
|
93
|
+
"""Default implementation: bucket registered models by tag, honour configs."""
|
|
94
|
+
|
|
95
|
+
def build(
|
|
96
|
+
self,
|
|
97
|
+
registry: list[RegisteredModel],
|
|
98
|
+
nav_group_configs: list[NavGroupConfig],
|
|
99
|
+
admin_path: str = "/admin",
|
|
100
|
+
) -> list[BuiltNavGroup]:
|
|
101
|
+
buckets: dict[str, list[BuiltNavItem]] = {}
|
|
102
|
+
for registered in registry:
|
|
103
|
+
tags = self._get_tags(registered)
|
|
104
|
+
for tag in tags:
|
|
105
|
+
buckets.setdefault(tag, []).append(
|
|
106
|
+
BuiltNavItem(
|
|
107
|
+
label=registered.verbose_name_plural,
|
|
108
|
+
url=f"{admin_path}/{registered.table_name}/",
|
|
109
|
+
icon=getattr(registered.admin, "icon", None),
|
|
110
|
+
order=getattr(registered.admin, "nav_order", 999),
|
|
111
|
+
badge_fn=getattr(
|
|
112
|
+
registered.admin, "get_nav_badge", None
|
|
113
|
+
),
|
|
114
|
+
permission_table=registered.table_name,
|
|
115
|
+
children=self._build_children(registered),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
groups: list[BuiltNavGroup] = []
|
|
120
|
+
seen: set[str] = set()
|
|
121
|
+
|
|
122
|
+
# Process configured tags in order first
|
|
123
|
+
configured_lower = {cfg.tag.lower(): cfg for cfg in nav_group_configs}
|
|
124
|
+
for cfg in sorted(nav_group_configs, key=lambda c: c.order):
|
|
125
|
+
tag = cfg.tag
|
|
126
|
+
if tag in seen:
|
|
127
|
+
continue
|
|
128
|
+
seen.add(tag)
|
|
129
|
+
items = sorted(
|
|
130
|
+
buckets.get(tag, []),
|
|
131
|
+
key=lambda i: (i.order, i.label),
|
|
132
|
+
)
|
|
133
|
+
extra_items = self._build_extra_items(cfg.extra_items)
|
|
134
|
+
all_items = sorted(items + extra_items, key=lambda i: i.order)
|
|
135
|
+
groups.append(
|
|
136
|
+
BuiltNavGroup(
|
|
137
|
+
tag=tag,
|
|
138
|
+
label=cfg.label or tag.title(),
|
|
139
|
+
icon=cfg.icon,
|
|
140
|
+
order=cfg.order,
|
|
141
|
+
collapsed_by_default=cfg.collapsed_by_default,
|
|
142
|
+
permission_table=cfg.permission,
|
|
143
|
+
items=all_items,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Process any remaining buckets (no explicit config)
|
|
148
|
+
for tag, items in buckets.items():
|
|
149
|
+
if tag in seen:
|
|
150
|
+
continue
|
|
151
|
+
seen.add(tag)
|
|
152
|
+
cfg = configured_lower.get(tag.lower())
|
|
153
|
+
groups.append(
|
|
154
|
+
BuiltNavGroup(
|
|
155
|
+
tag=tag,
|
|
156
|
+
label=cfg.label if cfg else tag.title(),
|
|
157
|
+
icon=cfg.icon if cfg else None,
|
|
158
|
+
order=cfg.order if cfg else 999,
|
|
159
|
+
collapsed_by_default=cfg.collapsed_by_default
|
|
160
|
+
if cfg
|
|
161
|
+
else False,
|
|
162
|
+
permission_table=cfg.permission if cfg else None,
|
|
163
|
+
items=sorted(items, key=lambda i: (i.order, i.label)),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
groups.sort(key=lambda g: (g.order, g.label))
|
|
168
|
+
return groups
|
|
169
|
+
|
|
170
|
+
def _get_tags(self, registered: RegisteredModel) -> list[str]:
|
|
171
|
+
admin = registered.admin
|
|
172
|
+
tags = getattr(admin, "tags", None)
|
|
173
|
+
if tags:
|
|
174
|
+
return list(tags)
|
|
175
|
+
tag = getattr(admin, "tag", None)
|
|
176
|
+
if tag:
|
|
177
|
+
return [tag]
|
|
178
|
+
return ["Other"]
|
|
179
|
+
|
|
180
|
+
def _build_children(
|
|
181
|
+
self, registered: RegisteredModel
|
|
182
|
+
) -> list[BuiltNavItem]:
|
|
183
|
+
children_configs = getattr(registered.admin, "nav_children", None) or []
|
|
184
|
+
result: list[BuiltNavItem] = []
|
|
185
|
+
for child_cfg in children_configs:
|
|
186
|
+
result.append(
|
|
187
|
+
BuiltNavItem(
|
|
188
|
+
label=child_cfg.label,
|
|
189
|
+
url=child_cfg.url,
|
|
190
|
+
icon=getattr(child_cfg, "icon", None),
|
|
191
|
+
permission_table=getattr(child_cfg, "permission", None),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
def _build_extra_items(
|
|
197
|
+
self, extras: list[NavItemConfig]
|
|
198
|
+
) -> list[BuiltNavItem]:
|
|
199
|
+
return [
|
|
200
|
+
BuiltNavItem(
|
|
201
|
+
label=e.label,
|
|
202
|
+
url=e.url,
|
|
203
|
+
icon=e.icon,
|
|
204
|
+
order=e.order,
|
|
205
|
+
permission_table=e.permission,
|
|
206
|
+
)
|
|
207
|
+
for e in extras
|
|
208
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Pagination strategies for FastAPI Admin Kit."""
|
|
2
|
+
|
|
3
|
+
from fastapi_admin_kit.pagination.base import BasePagination, PaginationResult
|
|
4
|
+
from fastapi_admin_kit.pagination.cursor import CursorPagination
|
|
5
|
+
from fastapi_admin_kit.pagination.dynamic import DynamicPagination
|
|
6
|
+
from fastapi_admin_kit.pagination.offset import OffsetPagination
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BasePagination",
|
|
10
|
+
"PaginationResult",
|
|
11
|
+
"OffsetPagination",
|
|
12
|
+
"CursorPagination",
|
|
13
|
+
"DynamicPagination",
|
|
14
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Base pagination classes and result dataclass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PaginationResult:
|
|
12
|
+
"""Unified result from any pagination strategy."""
|
|
13
|
+
|
|
14
|
+
items: list[Any]
|
|
15
|
+
total: int
|
|
16
|
+
per_page: int
|
|
17
|
+
page: int | None = None
|
|
18
|
+
total_pages: int | None = None
|
|
19
|
+
next_cursor: str | None = None
|
|
20
|
+
has_next: bool = False
|
|
21
|
+
mode: str = "offset"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BasePagination(ABC):
|
|
25
|
+
"""Abstract base for all pagination strategies."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def paginate(
|
|
29
|
+
self,
|
|
30
|
+
stmt: Any,
|
|
31
|
+
session: Any,
|
|
32
|
+
per_page: int,
|
|
33
|
+
page: int = 1,
|
|
34
|
+
after: str | None = None,
|
|
35
|
+
before: str | None = None,
|
|
36
|
+
pk_col: Any = None,
|
|
37
|
+
model: Any = None,
|
|
38
|
+
) -> PaginationResult:
|
|
39
|
+
"""Execute paginated query and return results."""
|
|
40
|
+
...
|