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,97 @@
|
|
|
1
|
+
"""Cursor-based (keyset) pagination using base64-encoded cursors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import func, select
|
|
10
|
+
|
|
11
|
+
from fastapi_admin_kit.pagination.base import BasePagination, PaginationResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CursorPagination(BasePagination):
|
|
15
|
+
"""Keyset pagination using opaque base64-encoded cursors.
|
|
16
|
+
|
|
17
|
+
Uses a configurable column (default: primary key) for cursor values.
|
|
18
|
+
Supports forward (after) and backward (before) navigation.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, cursor_column: str | None = None):
|
|
22
|
+
self.cursor_column = cursor_column
|
|
23
|
+
|
|
24
|
+
def _decode_cursor(self, cursor: str) -> Any:
|
|
25
|
+
"""Decode base64 cursor to Python value."""
|
|
26
|
+
return json.loads(base64.b64decode(cursor))
|
|
27
|
+
|
|
28
|
+
def _encode_cursor(self, value: Any) -> str:
|
|
29
|
+
"""Encode Python value to base64 cursor string."""
|
|
30
|
+
return base64.b64encode(json.dumps(value).encode()).decode()
|
|
31
|
+
|
|
32
|
+
async def paginate(
|
|
33
|
+
self,
|
|
34
|
+
stmt: Any,
|
|
35
|
+
session: Any,
|
|
36
|
+
per_page: int,
|
|
37
|
+
page: int = 1,
|
|
38
|
+
after: str | None = None,
|
|
39
|
+
before: str | None = None,
|
|
40
|
+
pk_col: Any = None,
|
|
41
|
+
model: Any = None,
|
|
42
|
+
) -> PaginationResult:
|
|
43
|
+
# Determine cursor column
|
|
44
|
+
if self.cursor_column and model is not None:
|
|
45
|
+
col = getattr(model, self.cursor_column)
|
|
46
|
+
elif pk_col is not None:
|
|
47
|
+
col = pk_col
|
|
48
|
+
else:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"CursorPagination requires either cursor_column on a model "
|
|
51
|
+
"or pk_col to be provided."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Apply cursor filter
|
|
55
|
+
if after:
|
|
56
|
+
cursor_val = self._decode_cursor(after)
|
|
57
|
+
stmt = stmt.where(col > cursor_val)
|
|
58
|
+
elif before:
|
|
59
|
+
cursor_val = self._decode_cursor(before)
|
|
60
|
+
stmt = stmt.where(col < cursor_val)
|
|
61
|
+
# For backward pagination, we need to reverse order then flip results
|
|
62
|
+
from sqlalchemy import desc as sa_desc
|
|
63
|
+
|
|
64
|
+
# Check current ordering and reverse it
|
|
65
|
+
stmt = stmt.order_by(sa_desc(col))
|
|
66
|
+
|
|
67
|
+
# Count filtered total
|
|
68
|
+
count_q = select(func.count()).select_from(stmt.subquery())
|
|
69
|
+
total = (await session.execute(count_q)).scalar() or 0
|
|
70
|
+
|
|
71
|
+
# Fetch per_page + 1 to detect has_next
|
|
72
|
+
stmt = stmt.limit(per_page + 1)
|
|
73
|
+
result = await session.execute(stmt)
|
|
74
|
+
items = list(result.unique().scalars().all())
|
|
75
|
+
|
|
76
|
+
# For backward pagination, reverse back to natural order
|
|
77
|
+
if before:
|
|
78
|
+
items = list(reversed(items))
|
|
79
|
+
|
|
80
|
+
has_next = len(items) > per_page
|
|
81
|
+
if has_next:
|
|
82
|
+
items = items[:per_page]
|
|
83
|
+
|
|
84
|
+
# Build next cursor from last item
|
|
85
|
+
next_cursor = None
|
|
86
|
+
if has_next and items:
|
|
87
|
+
last_val = getattr(items[-1], self.cursor_column or "id")
|
|
88
|
+
next_cursor = self._encode_cursor(last_val)
|
|
89
|
+
|
|
90
|
+
return PaginationResult(
|
|
91
|
+
items=items,
|
|
92
|
+
total=total,
|
|
93
|
+
per_page=per_page,
|
|
94
|
+
next_cursor=next_cursor,
|
|
95
|
+
has_next=has_next,
|
|
96
|
+
mode="cursor",
|
|
97
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Dynamic pagination — auto-selects offset vs cursor based on data volume."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
|
|
9
|
+
from fastapi_admin_kit.pagination.base import BasePagination, PaginationResult
|
|
10
|
+
from fastapi_admin_kit.pagination.cursor import CursorPagination
|
|
11
|
+
from fastapi_admin_kit.pagination.offset import OffsetPagination
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DynamicPagination(BasePagination):
|
|
15
|
+
"""Automatically switches between offset and cursor pagination.
|
|
16
|
+
|
|
17
|
+
Uses offset for small datasets (fast page jumping),
|
|
18
|
+
cursor for large datasets (consistent performance at any depth).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
cursor_column: str | None = None,
|
|
24
|
+
threshold: int = 1000,
|
|
25
|
+
):
|
|
26
|
+
self.cursor_column = cursor_column
|
|
27
|
+
self.threshold = threshold
|
|
28
|
+
self._offset = OffsetPagination()
|
|
29
|
+
self._cursor = CursorPagination(cursor_column=cursor_column)
|
|
30
|
+
|
|
31
|
+
async def paginate(
|
|
32
|
+
self,
|
|
33
|
+
stmt: Any,
|
|
34
|
+
session: Any,
|
|
35
|
+
per_page: int,
|
|
36
|
+
**kw: Any,
|
|
37
|
+
) -> PaginationResult:
|
|
38
|
+
# Count total to decide strategy
|
|
39
|
+
count_q = select(func.count()).select_from(stmt.subquery())
|
|
40
|
+
total = (await session.execute(count_q)).scalar() or 0
|
|
41
|
+
|
|
42
|
+
if total <= self.threshold:
|
|
43
|
+
result = await self._offset.paginate(stmt, session, per_page, **kw)
|
|
44
|
+
else:
|
|
45
|
+
result = await self._cursor.paginate(stmt, session, per_page, **kw)
|
|
46
|
+
|
|
47
|
+
result.mode = "dynamic_offset" if total <= self.threshold else "dynamic_cursor"
|
|
48
|
+
return result
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Offset-based pagination (existing behavior)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import func, select
|
|
9
|
+
|
|
10
|
+
from fastapi_admin_kit.pagination.base import BasePagination, PaginationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OffsetPagination(BasePagination):
|
|
14
|
+
"""Traditional page-number pagination using OFFSET/LIMIT."""
|
|
15
|
+
|
|
16
|
+
async def paginate(
|
|
17
|
+
self,
|
|
18
|
+
stmt: Any,
|
|
19
|
+
session: Any,
|
|
20
|
+
per_page: int,
|
|
21
|
+
page: int = 1,
|
|
22
|
+
**kw: Any,
|
|
23
|
+
) -> PaginationResult:
|
|
24
|
+
count_q = select(func.count()).select_from(stmt.subquery())
|
|
25
|
+
total = (await session.execute(count_q)).scalar() or 0
|
|
26
|
+
|
|
27
|
+
total_pages = max(1, math.ceil(total / per_page))
|
|
28
|
+
page = max(1, min(page, total_pages))
|
|
29
|
+
offset = (page - 1) * per_page
|
|
30
|
+
|
|
31
|
+
stmt = stmt.offset(offset).limit(per_page)
|
|
32
|
+
result = await session.execute(stmt)
|
|
33
|
+
items = list(result.unique().scalars().all())
|
|
34
|
+
|
|
35
|
+
return PaginationResult(
|
|
36
|
+
items=items,
|
|
37
|
+
total=total,
|
|
38
|
+
per_page=per_page,
|
|
39
|
+
page=page,
|
|
40
|
+
total_pages=total_pages,
|
|
41
|
+
mode="offset",
|
|
42
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Plugin system for extending admin functionality."""
|
|
File without changes
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""AdminRegistry — singleton holding all registered models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from fastapi_admin_kit.inspection.registry import ModelInspector
|
|
9
|
+
from fastapi_admin_kit.registry.validation import ModelValidator
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from fastapi_admin_kit.views import ModelAdmin
|
|
13
|
+
from fastapi_admin_kit.widgets.base import Widget
|
|
14
|
+
from fastapi_admin_kit.widgets.resolver import WidgetResolver
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RegisteredModel:
|
|
19
|
+
"""Central dataclass holding a registered model and its admin config."""
|
|
20
|
+
|
|
21
|
+
model: type
|
|
22
|
+
admin: ModelAdmin
|
|
23
|
+
table_name: str
|
|
24
|
+
verbose_name: str
|
|
25
|
+
verbose_name_plural: str
|
|
26
|
+
columns: list = field(default_factory=list)
|
|
27
|
+
relationships: list = field(default_factory=list)
|
|
28
|
+
pk_field: str | tuple[str, ...] | None = "id"
|
|
29
|
+
_schemas: dict | None = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
# Find primary key
|
|
33
|
+
for col in self.columns:
|
|
34
|
+
if col.primary_key:
|
|
35
|
+
self.pk_field = col.name
|
|
36
|
+
break
|
|
37
|
+
# Ensure admin has reference to model for form field deduplication
|
|
38
|
+
if not hasattr(self.admin, "model") or self.admin.model is None:
|
|
39
|
+
self.admin.model = self.model
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def form_fields(self) -> list[Any]:
|
|
43
|
+
return self.admin.get_form_fields(
|
|
44
|
+
columns=self.columns,
|
|
45
|
+
relationships=self.relationships,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def list_fields(self) -> list[str]:
|
|
50
|
+
if self.admin.list_display:
|
|
51
|
+
valid = {c.name for c in self.columns}
|
|
52
|
+
return [f for f in self.admin.list_display if f in valid]
|
|
53
|
+
return [c.name for c in self.columns if not c.primary_key]
|
|
54
|
+
|
|
55
|
+
def get_widget(
|
|
56
|
+
self, field_name: str, resolver: WidgetResolver | None = None
|
|
57
|
+
) -> Widget:
|
|
58
|
+
from fastapi_admin_kit.inspection import auto_label
|
|
59
|
+
from fastapi_admin_kit.widgets.registry import widget_registry
|
|
60
|
+
from fastapi_admin_kit.widgets.relation import (
|
|
61
|
+
MultiRelationWidget,
|
|
62
|
+
RelationPickerWidget,
|
|
63
|
+
)
|
|
64
|
+
from fastapi_admin_kit.widgets.resolver import WidgetResolver
|
|
65
|
+
|
|
66
|
+
if resolver is None:
|
|
67
|
+
resolver = WidgetResolver(widget_registry)
|
|
68
|
+
|
|
69
|
+
overrides = getattr(self.admin, "formfield_overrides", {})
|
|
70
|
+
if field_name in overrides:
|
|
71
|
+
return overrides[field_name]
|
|
72
|
+
|
|
73
|
+
col = next((c for c in self.columns if c.name == field_name), None)
|
|
74
|
+
rel = next(
|
|
75
|
+
(r for r in self.relationships if r.name == field_name), None
|
|
76
|
+
)
|
|
77
|
+
if col is not None:
|
|
78
|
+
widget = resolver.resolve(col)
|
|
79
|
+
if (
|
|
80
|
+
isinstance(widget, RelationPickerWidget)
|
|
81
|
+
and not widget.related_table
|
|
82
|
+
and col.foreign_keys
|
|
83
|
+
):
|
|
84
|
+
fk = col.foreign_keys[0]
|
|
85
|
+
widget.related_table = fk.column.table.name
|
|
86
|
+
return widget
|
|
87
|
+
if rel is not None:
|
|
88
|
+
related_verbose = auto_label(rel.target_model.__tablename__)
|
|
89
|
+
if rel.direction == "MANYTOONE" or not rel.uselist:
|
|
90
|
+
return RelationPickerWidget(
|
|
91
|
+
related_table=rel.target_model.__tablename__,
|
|
92
|
+
related_verbose=related_verbose,
|
|
93
|
+
)
|
|
94
|
+
return MultiRelationWidget(
|
|
95
|
+
related_table=rel.target_model.__tablename__,
|
|
96
|
+
related_verbose=related_verbose,
|
|
97
|
+
)
|
|
98
|
+
return resolver.resolve( # type: ignore[arg-type]
|
|
99
|
+
type("_Col", (), {"type": type(None), "name": field_name})()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AdminRegistry:
|
|
104
|
+
"""Singleton registry for admin models.
|
|
105
|
+
|
|
106
|
+
Uses dependency injection for ModelInspector and ModelValidator,
|
|
107
|
+
making the registry testable and separable from inspection/validation concerns.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_instance: AdminRegistry | None = None
|
|
111
|
+
_models: dict[str, RegisteredModel] = {}
|
|
112
|
+
|
|
113
|
+
def __new__(cls) -> AdminRegistry:
|
|
114
|
+
if cls._instance is None:
|
|
115
|
+
cls._instance = super().__new__(cls)
|
|
116
|
+
cls._instance._models = {}
|
|
117
|
+
return cls._instance
|
|
118
|
+
|
|
119
|
+
def __init__(self) -> None:
|
|
120
|
+
"""Initialize the registry with default inspector and validator."""
|
|
121
|
+
if not hasattr(self, "_inspector"):
|
|
122
|
+
self._inspector = ModelInspector()
|
|
123
|
+
if not hasattr(self, "_validator"):
|
|
124
|
+
self._validator = ModelValidator(self)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def inspector(self) -> ModelInspector:
|
|
128
|
+
"""Get the model inspector."""
|
|
129
|
+
return self._inspector
|
|
130
|
+
|
|
131
|
+
@inspector.setter
|
|
132
|
+
def inspector(self, value: ModelInspector) -> None:
|
|
133
|
+
"""Set the model inspector."""
|
|
134
|
+
self._inspector = value
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def validator(self) -> ModelValidator:
|
|
138
|
+
"""Get the model validator."""
|
|
139
|
+
return self._validator
|
|
140
|
+
|
|
141
|
+
@validator.setter
|
|
142
|
+
def validator(self, value: ModelValidator) -> None:
|
|
143
|
+
"""Set the model validator."""
|
|
144
|
+
self._validator = value
|
|
145
|
+
|
|
146
|
+
def register(
|
|
147
|
+
self,
|
|
148
|
+
model: type,
|
|
149
|
+
admin_class: type[ModelAdmin] | None = None,
|
|
150
|
+
) -> RegisteredModel:
|
|
151
|
+
"""Register a model with the admin.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
model: A SQLAlchemy declarative model class.
|
|
155
|
+
admin_class: Optional ModelAdmin subclass for the model.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The registered model configuration.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: If the model is not a valid SQLAlchemy model.
|
|
162
|
+
"""
|
|
163
|
+
from fastapi_admin_kit.views import ModelAdmin
|
|
164
|
+
|
|
165
|
+
# Validate using the injected validator
|
|
166
|
+
self._validator.validate_model_registration(model, admin_class)
|
|
167
|
+
|
|
168
|
+
# Inspect using the injected inspector
|
|
169
|
+
columns, relationships = self._inspector.inspect_model(model)
|
|
170
|
+
|
|
171
|
+
admin = admin_class() if admin_class else ModelAdmin()
|
|
172
|
+
table_name = model.__tablename__
|
|
173
|
+
verbose_name = (
|
|
174
|
+
admin.verbose_name or table_name.replace("_", " ").title()
|
|
175
|
+
)
|
|
176
|
+
if admin.verbose_name_plural:
|
|
177
|
+
verbose_name_plural = admin.verbose_name_plural
|
|
178
|
+
elif (
|
|
179
|
+
verbose_name.endswith("y")
|
|
180
|
+
and len(verbose_name) > 1
|
|
181
|
+
and verbose_name[-2].lower() not in "aeiou"
|
|
182
|
+
):
|
|
183
|
+
verbose_name_plural = f"{verbose_name[:-1]}ies"
|
|
184
|
+
else:
|
|
185
|
+
verbose_name_plural = f"{verbose_name}s"
|
|
186
|
+
|
|
187
|
+
registered = RegisteredModel(
|
|
188
|
+
model=model,
|
|
189
|
+
admin=admin,
|
|
190
|
+
table_name=table_name,
|
|
191
|
+
verbose_name=verbose_name,
|
|
192
|
+
verbose_name_plural=verbose_name_plural,
|
|
193
|
+
columns=columns,
|
|
194
|
+
relationships=relationships,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
self._models[table_name] = registered
|
|
198
|
+
return registered
|
|
199
|
+
|
|
200
|
+
def get(self, table_name: str) -> RegisteredModel | None:
|
|
201
|
+
"""Get a registered model by table name.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
table_name: The table name to look up.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The registered model, or None if not found.
|
|
208
|
+
"""
|
|
209
|
+
return self._models.get(table_name)
|
|
210
|
+
|
|
211
|
+
def all(self) -> list[RegisteredModel]:
|
|
212
|
+
"""Get all registered models.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
A list of all registered models.
|
|
216
|
+
"""
|
|
217
|
+
return list(self._models.values())
|
|
218
|
+
|
|
219
|
+
def auto_discover(self) -> list[RegisteredModel]:
|
|
220
|
+
"""Scan all subclasses of DeclarativeBase and register unregistered ones.
|
|
221
|
+
|
|
222
|
+
Also discovers SQLModel subclasses if SQLModel is installed.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
A list of newly registered models.
|
|
226
|
+
"""
|
|
227
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
228
|
+
|
|
229
|
+
discovered: list[RegisteredModel] = []
|
|
230
|
+
seen: set[type] = set()
|
|
231
|
+
|
|
232
|
+
# Discover SQLAlchemy DeclarativeBase subclasses
|
|
233
|
+
for subclass in _all_declarative_subclasses(DeclarativeBase):
|
|
234
|
+
if hasattr(subclass, "registry"):
|
|
235
|
+
for mapper in subclass.registry.mappers:
|
|
236
|
+
cls = mapper.class_
|
|
237
|
+
if cls not in seen:
|
|
238
|
+
seen.add(cls)
|
|
239
|
+
if (
|
|
240
|
+
hasattr(cls, "__tablename__")
|
|
241
|
+
and cls.__tablename__ not in self._models
|
|
242
|
+
):
|
|
243
|
+
discovered.append(self.register(cls))
|
|
244
|
+
|
|
245
|
+
# Discover SQLModel subclasses (if installed)
|
|
246
|
+
try:
|
|
247
|
+
from sqlmodel import SQLModel
|
|
248
|
+
|
|
249
|
+
for subclass in _all_declarative_subclasses(SQLModel):
|
|
250
|
+
if hasattr(subclass, "registry"):
|
|
251
|
+
for mapper in subclass.registry.mappers:
|
|
252
|
+
cls = mapper.class_
|
|
253
|
+
if cls not in seen:
|
|
254
|
+
seen.add(cls)
|
|
255
|
+
if (
|
|
256
|
+
hasattr(cls, "__tablename__")
|
|
257
|
+
and cls.__tablename__ not in self._models
|
|
258
|
+
):
|
|
259
|
+
discovered.append(self.register(cls))
|
|
260
|
+
except ImportError:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
return discovered
|
|
264
|
+
|
|
265
|
+
def clear(self) -> None:
|
|
266
|
+
"""Clear all registrations (useful for testing)."""
|
|
267
|
+
self._models.clear()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _all_declarative_subclasses(base: type) -> set[type]:
|
|
271
|
+
"""Recursively collect all subclasses of *base*.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
base: The base class to find subclasses of.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
A set of all subclasses.
|
|
278
|
+
"""
|
|
279
|
+
result: set[type] = set()
|
|
280
|
+
work = [base]
|
|
281
|
+
while work:
|
|
282
|
+
cls = work.pop()
|
|
283
|
+
for sub in cls.__subclasses__():
|
|
284
|
+
if sub not in result:
|
|
285
|
+
result.add(sub)
|
|
286
|
+
work.append(sub)
|
|
287
|
+
return result
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""ModelValidator — validates model registration in AdminRegistry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from fastapi_admin_kit.registry.core import AdminRegistry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ModelValidator:
|
|
12
|
+
"""Validates model registration in AdminRegistry.
|
|
13
|
+
|
|
14
|
+
This class centralizes all validation logic, making it testable
|
|
15
|
+
and separable from the registry's storage and inspection concerns.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, registry: AdminRegistry) -> None:
|
|
19
|
+
"""Initialize the validator with a reference to the registry.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
registry: The AdminRegistry instance to validate against.
|
|
23
|
+
"""
|
|
24
|
+
self._registry = registry
|
|
25
|
+
|
|
26
|
+
def validate_model_registration(
|
|
27
|
+
self,
|
|
28
|
+
model: type,
|
|
29
|
+
admin_class: type | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Validate that a model can be registered with the admin.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
model: A SQLAlchemy declarative model class.
|
|
35
|
+
admin_class: Optional ModelAdmin subclass for the model.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If the model is not a valid SQLAlchemy model.
|
|
39
|
+
ValueError: If the model's table name conflicts with an existing registration.
|
|
40
|
+
"""
|
|
41
|
+
self._validate_is_sqlalchemy_model(model)
|
|
42
|
+
self._check_table_name_conflicts(model)
|
|
43
|
+
|
|
44
|
+
def _validate_is_sqlalchemy_model(self, model: type) -> None:
|
|
45
|
+
"""Validate that the model is a SQLAlchemy or SQLModel model.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
model: A class to validate.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If the model is not a valid ORM model.
|
|
52
|
+
"""
|
|
53
|
+
# SQLAlchemy models always have __tablename__
|
|
54
|
+
if hasattr(model, "__tablename__"):
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# SQLModel with table=True always has __tablename__ via metaclass
|
|
58
|
+
# SQLModel without table=True won't have it
|
|
59
|
+
try:
|
|
60
|
+
from sqlmodel import SQLModel
|
|
61
|
+
|
|
62
|
+
if isinstance(model, type) and issubclass(model, SQLModel):
|
|
63
|
+
# Check if it's a table model (has registry = table is created)
|
|
64
|
+
has_table = getattr(model, "table", False) or hasattr(
|
|
65
|
+
model, "metadata"
|
|
66
|
+
)
|
|
67
|
+
if not has_table:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"{model.__name__} is a SQLModel but has no table. "
|
|
70
|
+
f"Use SQLModel(table=True) to create a table model."
|
|
71
|
+
)
|
|
72
|
+
except ImportError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
if not hasattr(model, "__tablename__"):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"{model.__name__} is not a SQLAlchemy model (no __tablename__)"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _check_table_name_conflicts(self, model: type) -> None:
|
|
81
|
+
"""Check for table name conflicts with existing registrations.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
model: A SQLAlchemy declarative model class.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If the table name is already registered.
|
|
88
|
+
"""
|
|
89
|
+
table_name = model.__tablename__
|
|
90
|
+
existing = self._registry.get(table_name)
|
|
91
|
+
if existing is not None and existing.model is not model:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"Table name '{table_name}' is already registered for "
|
|
94
|
+
f"model {existing.model.__name__}. "
|
|
95
|
+
f"Cannot register {model.__name__} with the same table name."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def check_table_name_conflicts(self, table_name: str) -> bool:
|
|
99
|
+
"""Check if a table name is already registered.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
table_name: The table name to check.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True if the table name is already registered, False otherwise.
|
|
106
|
+
"""
|
|
107
|
+
return self._registry.get(table_name) is not None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""AdminRegistry — singleton holding all registered models.
|
|
2
|
+
|
|
3
|
+
This module provides backward-compatible imports for the registry package.
|
|
4
|
+
The actual implementation is in fastapi_admin_kit.registry.core.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastapi_admin_kit.registry.core import (
|
|
10
|
+
AdminRegistry,
|
|
11
|
+
RegisteredModel,
|
|
12
|
+
_all_declarative_subclasses,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = ["AdminRegistry", "RegisteredModel", "_all_declarative_subclasses"]
|