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.
Files changed (163) hide show
  1. fastapi_admin_kit/__init__.py +73 -0
  2. fastapi_admin_kit/actions/__init__.py +63 -0
  3. fastapi_admin_kit/actions/base.py +68 -0
  4. fastapi_admin_kit/actions/registry.py +43 -0
  5. fastapi_admin_kit/admin/__init__.py +17 -0
  6. fastapi_admin_kit/admin/admin_config.py +95 -0
  7. fastapi_admin_kit/admin/admin_database.py +138 -0
  8. fastapi_admin_kit/admin/admin_router.py +74 -0
  9. fastapi_admin_kit/admin/admin_template.py +203 -0
  10. fastapi_admin_kit/admin/builtin_models.py +284 -0
  11. fastapi_admin_kit/admin/core.py +1036 -0
  12. fastapi_admin_kit/admin/decorators.py +70 -0
  13. fastapi_admin_kit/admin/state.py +76 -0
  14. fastapi_admin_kit/admin.py +728 -0
  15. fastapi_admin_kit/api/__init__.py +44 -0
  16. fastapi_admin_kit/api/auth.py +342 -0
  17. fastapi_admin_kit/api/crud.py +128 -0
  18. fastapi_admin_kit/api/deps.py +79 -0
  19. fastapi_admin_kit/api/roles.py +128 -0
  20. fastapi_admin_kit/api/schema_generator.py +171 -0
  21. fastapi_admin_kit/api/schemas.py +81 -0
  22. fastapi_admin_kit/api/search.py +132 -0
  23. fastapi_admin_kit/audit/__init__.py +36 -0
  24. fastapi_admin_kit/audit/context.py +62 -0
  25. fastapi_admin_kit/audit/diff.py +77 -0
  26. fastapi_admin_kit/audit/event_bus.py +96 -0
  27. fastapi_admin_kit/audit/events.py +48 -0
  28. fastapi_admin_kit/audit/listener.py +159 -0
  29. fastapi_admin_kit/audit/logger.py +28 -0
  30. fastapi_admin_kit/audit/middleware.py +39 -0
  31. fastapi_admin_kit/audit/models.py +53 -0
  32. fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
  33. fastapi_admin_kit/auth/__init__.py +34 -0
  34. fastapi_admin_kit/auth/backend.py +95 -0
  35. fastapi_admin_kit/auth/csrf.py +240 -0
  36. fastapi_admin_kit/auth/dependencies.py +150 -0
  37. fastapi_admin_kit/auth/identity.py +181 -0
  38. fastapi_admin_kit/auth/models.py +246 -0
  39. fastapi_admin_kit/auth/password.py +35 -0
  40. fastapi_admin_kit/auth/permissions.py +205 -0
  41. fastapi_admin_kit/auth/protocol.py +22 -0
  42. fastapi_admin_kit/auth/ratelimit.py +88 -0
  43. fastapi_admin_kit/auth/router.py +10 -0
  44. fastapi_admin_kit/auth/session.py +79 -0
  45. fastapi_admin_kit/auth/totp.py +83 -0
  46. fastapi_admin_kit/auth/views.py +165 -0
  47. fastapi_admin_kit/cli.py +229 -0
  48. fastapi_admin_kit/config/__init__.py +19 -0
  49. fastapi_admin_kit/config/audit.py +18 -0
  50. fastapi_admin_kit/config/auth.py +54 -0
  51. fastapi_admin_kit/config/behavior.py +27 -0
  52. fastapi_admin_kit/config/nav.py +32 -0
  53. fastapi_admin_kit/config/storage.py +22 -0
  54. fastapi_admin_kit/config/theme.py +215 -0
  55. fastapi_admin_kit/config/ui.py +147 -0
  56. fastapi_admin_kit/dashboard/__init__.py +64 -0
  57. fastapi_admin_kit/db.py +133 -0
  58. fastapi_admin_kit/exceptions.py +5 -0
  59. fastapi_admin_kit/field_types.py +81 -0
  60. fastapi_admin_kit/filters/__init__.py +21 -0
  61. fastapi_admin_kit/filters/base.py +170 -0
  62. fastapi_admin_kit/filters/registry.py +68 -0
  63. fastapi_admin_kit/flash.py +45 -0
  64. fastapi_admin_kit/form/__init__.py +1 -0
  65. fastapi_admin_kit/form/pipeline.py +106 -0
  66. fastapi_admin_kit/inspection/__init__.py +117 -0
  67. fastapi_admin_kit/inspection/registry.py +253 -0
  68. fastapi_admin_kit/inspection.py +115 -0
  69. fastapi_admin_kit/modeladmin.py +375 -0
  70. fastapi_admin_kit/models/__init__.py +7 -0
  71. fastapi_admin_kit/models/base.py +7 -0
  72. fastapi_admin_kit/nav.py +208 -0
  73. fastapi_admin_kit/pagination/__init__.py +14 -0
  74. fastapi_admin_kit/pagination/base.py +40 -0
  75. fastapi_admin_kit/pagination/cursor.py +97 -0
  76. fastapi_admin_kit/pagination/dynamic.py +48 -0
  77. fastapi_admin_kit/pagination/offset.py +42 -0
  78. fastapi_admin_kit/plugins/__init__.py +1 -0
  79. fastapi_admin_kit/py.typed +0 -0
  80. fastapi_admin_kit/registry/__init__.py +5 -0
  81. fastapi_admin_kit/registry/core.py +287 -0
  82. fastapi_admin_kit/registry/validation.py +107 -0
  83. fastapi_admin_kit/registry.py +15 -0
  84. fastapi_admin_kit/router.py +335 -0
  85. fastapi_admin_kit/static/css/admin.css +4736 -0
  86. fastapi_admin_kit/static/css/presets.css +317 -0
  87. fastapi_admin_kit/static/css/tokens.css +217 -0
  88. fastapi_admin_kit/static/css/variables.css +74 -0
  89. fastapi_admin_kit/static/icons/heroicons.svg +160 -0
  90. fastapi_admin_kit/static/js/admin.js +692 -0
  91. fastapi_admin_kit/static/js/htmx-config.js +42 -0
  92. fastapi_admin_kit/storage/__init__.py +6 -0
  93. fastapi_admin_kit/storage/base.py +48 -0
  94. fastapi_admin_kit/storage/local.py +73 -0
  95. fastapi_admin_kit/templates/base.html +142 -0
  96. fastapi_admin_kit/templates/macros/form_fields.html +660 -0
  97. fastapi_admin_kit/templates/macros/icons.html +50 -0
  98. fastapi_admin_kit/templates/macros/table.html +108 -0
  99. fastapi_admin_kit/templates/macros/widgets.html +159 -0
  100. fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
  101. fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
  102. fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
  103. fastapi_admin_kit/templates/pages/audit_log.html +102 -0
  104. fastapi_admin_kit/templates/pages/dashboard.html +295 -0
  105. fastapi_admin_kit/templates/pages/detail.html +183 -0
  106. fastapi_admin_kit/templates/pages/form.html +119 -0
  107. fastapi_admin_kit/templates/pages/list.html +277 -0
  108. fastapi_admin_kit/templates/pages/login.html +85 -0
  109. fastapi_admin_kit/templates/pages/profile/password.html +78 -0
  110. fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
  111. fastapi_admin_kit/templates/pages/role_form.html +75 -0
  112. fastapi_admin_kit/templates/pages/roles/form.html +117 -0
  113. fastapi_admin_kit/templates/pages/roles/list.html +69 -0
  114. fastapi_admin_kit/templates/pages/roles.html +77 -0
  115. fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
  116. fastapi_admin_kit/templates/pages/users/form.html +229 -0
  117. fastapi_admin_kit/templates/pages/users/list.html +83 -0
  118. fastapi_admin_kit/templates/partials/command_palette.html +52 -0
  119. fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
  120. fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
  121. fastapi_admin_kit/templates/partials/head.html +21 -0
  122. fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
  123. fastapi_admin_kit/templates/partials/list_table.html +178 -0
  124. fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
  125. fastapi_admin_kit/templates/partials/pagination.html +82 -0
  126. fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
  127. fastapi_admin_kit/templates/partials/scripts.html +13 -0
  128. fastapi_admin_kit/templates/partials/sidebar.html +94 -0
  129. fastapi_admin_kit/templates/partials/topbar.html +95 -0
  130. fastapi_admin_kit/types.py +145 -0
  131. fastapi_admin_kit/validation.py +43 -0
  132. fastapi_admin_kit/views/__init__.py +78 -0
  133. fastapi_admin_kit/views/audit.py +134 -0
  134. fastapi_admin_kit/views/bulk.py +28 -0
  135. fastapi_admin_kit/views/class_views.py +1040 -0
  136. fastapi_admin_kit/views/context.py +588 -0
  137. fastapi_admin_kit/views/dashboard.py +162 -0
  138. fastapi_admin_kit/views/delete.py +31 -0
  139. fastapi_admin_kit/views/extra.py +65 -0
  140. fastapi_admin_kit/views/factory.py +667 -0
  141. fastapi_admin_kit/views/form.py +159 -0
  142. fastapi_admin_kit/views/list.py +28 -0
  143. fastapi_admin_kit/views/profile.py +219 -0
  144. fastapi_admin_kit/views/protocols.py +54 -0
  145. fastapi_admin_kit/views/renderers.py +634 -0
  146. fastapi_admin_kit/views/roles.py +230 -0
  147. fastapi_admin_kit/views/search.py +31 -0
  148. fastapi_admin_kit/views/settings.py +31 -0
  149. fastapi_admin_kit/views/sidebar.py +101 -0
  150. fastapi_admin_kit/views/totp.py +249 -0
  151. fastapi_admin_kit/views/users.py +347 -0
  152. fastapi_admin_kit/views.py +117 -0
  153. fastapi_admin_kit/widgets/__init__.py +44 -0
  154. fastapi_admin_kit/widgets/base.py +44 -0
  155. fastapi_admin_kit/widgets/inputs.py +363 -0
  156. fastapi_admin_kit/widgets/registry.py +110 -0
  157. fastapi_admin_kit/widgets/relation.py +70 -0
  158. fastapi_admin_kit/widgets/resolver.py +102 -0
  159. fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
  160. fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
  161. fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
  162. fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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,5 @@
1
+ """Registry package — AdminRegistry and related components."""
2
+
3
+ from fastapi_admin_kit.registry.core import AdminRegistry, RegisteredModel
4
+
5
+ __all__ = ["AdminRegistry", "RegisteredModel"]
@@ -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"]