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,253 @@
|
|
|
1
|
+
"""ModelInspector — inspects SQLAlchemy models and extracts metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import inspect
|
|
9
|
+
|
|
10
|
+
from fastapi_admin_kit.types import ColumnMeta, RelationMeta
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelInspector:
|
|
17
|
+
"""Inspects SQLAlchemy models and extracts column/relationship metadata.
|
|
18
|
+
|
|
19
|
+
This class centralizes all model inspection logic, making it testable
|
|
20
|
+
and separable from the registry's storage concerns.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def inspect_model(
|
|
24
|
+
self, model: type
|
|
25
|
+
) -> tuple[list[ColumnMeta], list[RelationMeta]]:
|
|
26
|
+
"""Inspect a SQLAlchemy or SQLModel model and return column + relationship metadata.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
model: A SQLAlchemy or SQLModel declarative model class.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A tuple of (columns, relationships) metadata.
|
|
33
|
+
"""
|
|
34
|
+
mapper = inspect(model)
|
|
35
|
+
columns: list[ColumnMeta] = []
|
|
36
|
+
relationships: list[RelationMeta] = []
|
|
37
|
+
|
|
38
|
+
# Check if this is a SQLModel
|
|
39
|
+
is_sqlmodel = self._is_sqlmodel(model)
|
|
40
|
+
|
|
41
|
+
for col in mapper.columns:
|
|
42
|
+
# For SQLModel, we may need to extract type info from Pydantic fields
|
|
43
|
+
col_type = col.type
|
|
44
|
+
if is_sqlmodel:
|
|
45
|
+
col_type = self._resolve_sqlmodel_type(model, col.key, col.type)
|
|
46
|
+
|
|
47
|
+
columns.append(
|
|
48
|
+
ColumnMeta(
|
|
49
|
+
name=col.key,
|
|
50
|
+
type=col_type,
|
|
51
|
+
nullable=col.nullable,
|
|
52
|
+
primary_key=col.primary_key,
|
|
53
|
+
foreign_keys=list(col.foreign_keys),
|
|
54
|
+
default=col.default,
|
|
55
|
+
server_default=col.server_default,
|
|
56
|
+
index=col.index,
|
|
57
|
+
unique=col.unique,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
for rel in mapper.relationships:
|
|
62
|
+
try:
|
|
63
|
+
relationships.append(
|
|
64
|
+
RelationMeta(
|
|
65
|
+
name=rel.key,
|
|
66
|
+
direction=rel.direction.name,
|
|
67
|
+
target_model=rel.mapper.class_,
|
|
68
|
+
uselist=rel.uselist,
|
|
69
|
+
back_populates=rel.back_populates,
|
|
70
|
+
secondary=rel.secondary,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
except Exception:
|
|
74
|
+
# Skip relationships that fail to configure (e.g. SQLModel
|
|
75
|
+
# relationships with incomplete FK resolution)
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
return columns, relationships
|
|
79
|
+
|
|
80
|
+
def _is_sqlmodel(self, model: type) -> bool:
|
|
81
|
+
"""Check if a model is a SQLModel instance."""
|
|
82
|
+
try:
|
|
83
|
+
from sqlmodel import SQLModel
|
|
84
|
+
|
|
85
|
+
return isinstance(model, type) and issubclass(model, SQLModel)
|
|
86
|
+
except ImportError:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def _resolve_sqlmodel_type(self, model: type, field_name: str, default_type: Any) -> Any:
|
|
90
|
+
"""Resolve the column type for a SQLModel field.
|
|
91
|
+
|
|
92
|
+
SQLModel may expose Python types (int, str, etc.) instead of SQLAlchemy types.
|
|
93
|
+
This method maps them to equivalent SQLAlchemy types.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
from sqlmodel import SQLModel
|
|
97
|
+
|
|
98
|
+
if not (isinstance(model, type) and issubclass(model, SQLModel)):
|
|
99
|
+
return default_type
|
|
100
|
+
|
|
101
|
+
# Get SQLModel field info
|
|
102
|
+
sqlmodel_fields = getattr(model, "__sqlmodel_fields__", {})
|
|
103
|
+
if field_name not in sqlmodel_fields:
|
|
104
|
+
return default_type
|
|
105
|
+
|
|
106
|
+
field_info = sqlmodel_fields[field_name]
|
|
107
|
+
annotation = getattr(field_info, "annotation", None)
|
|
108
|
+
|
|
109
|
+
if annotation is None:
|
|
110
|
+
return default_type
|
|
111
|
+
|
|
112
|
+
# Map Python types to SQLAlchemy types
|
|
113
|
+
type_map = {
|
|
114
|
+
int: self._get_sa_type("Integer"),
|
|
115
|
+
str: self._get_sa_type("String"),
|
|
116
|
+
float: self._get_sa_type("Float"),
|
|
117
|
+
bool: self._get_sa_type("Boolean"),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Handle Optional types
|
|
121
|
+
origin = getattr(annotation, "__origin__", None)
|
|
122
|
+
if origin is not None:
|
|
123
|
+
args = getattr(annotation, "__args__", ())
|
|
124
|
+
if args:
|
|
125
|
+
inner = args[0]
|
|
126
|
+
if inner in type_map:
|
|
127
|
+
return type_map[inner]
|
|
128
|
+
|
|
129
|
+
if annotation in type_map:
|
|
130
|
+
return type_map[annotation]
|
|
131
|
+
|
|
132
|
+
return default_type
|
|
133
|
+
except Exception:
|
|
134
|
+
return default_type
|
|
135
|
+
|
|
136
|
+
def _get_sa_type(self, type_name: str) -> Any:
|
|
137
|
+
"""Get a SQLAlchemy type by name."""
|
|
138
|
+
import sqlalchemy as sa
|
|
139
|
+
|
|
140
|
+
return getattr(sa, type_name, None)
|
|
141
|
+
|
|
142
|
+
def validate_model(self, model: type) -> None:
|
|
143
|
+
"""Validate that a model is suitable for admin registration.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
model: A SQLAlchemy declarative model class.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If the model is not a valid SQLAlchemy model.
|
|
150
|
+
"""
|
|
151
|
+
if not hasattr(model, "__tablename__"):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"{model.__name__} is not a SQLAlchemy model (no __tablename__)"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def extract_metadata(
|
|
157
|
+
self,
|
|
158
|
+
model: type,
|
|
159
|
+
columns: list[ColumnMeta],
|
|
160
|
+
relationships: list[RelationMeta],
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
"""Extract additional metadata from a model.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
model: A SQLAlchemy declarative model class.
|
|
166
|
+
columns: The extracted column metadata.
|
|
167
|
+
relationships: The extracted relationship metadata.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A dictionary of extracted metadata.
|
|
171
|
+
"""
|
|
172
|
+
pk_field = self.get_pk_field(model)
|
|
173
|
+
table_name = model.__tablename__
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"table_name": table_name,
|
|
177
|
+
"pk_field": pk_field,
|
|
178
|
+
"columns": columns,
|
|
179
|
+
"relationships": relationships,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def is_abstract(self, model: type) -> bool:
|
|
183
|
+
"""Check if a model is abstract and should be skipped during auto-discovery.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
model: A SQLAlchemy declarative model class.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
True if the model is abstract, False otherwise.
|
|
190
|
+
"""
|
|
191
|
+
return getattr(model, "__abstract__", False)
|
|
192
|
+
|
|
193
|
+
def get_pk_field(self, model: type) -> str | tuple[str, ...] | None:
|
|
194
|
+
"""Get the primary key field name for a model.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
model: A SQLAlchemy declarative model class.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The single PK field name for simple PKs,
|
|
201
|
+
a tuple of names for composite PKs,
|
|
202
|
+
or None if no primary key is found.
|
|
203
|
+
"""
|
|
204
|
+
mapper = inspect(model)
|
|
205
|
+
pk_cols = mapper.primary_key
|
|
206
|
+
if not pk_cols:
|
|
207
|
+
return None
|
|
208
|
+
if len(pk_cols) == 1:
|
|
209
|
+
return pk_cols[0].key
|
|
210
|
+
return tuple(col.key for col in pk_cols)
|
|
211
|
+
|
|
212
|
+
def auto_label(self, name: str) -> str:
|
|
213
|
+
"""Auto-generate a human-readable label from a field name.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
name: The field name to convert.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
A human-readable label.
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
"category_id" → "Category"
|
|
223
|
+
"is_active" → "Is Active"
|
|
224
|
+
"created_at" → "Created At"
|
|
225
|
+
"skuCode" → "Sku Code"
|
|
226
|
+
"""
|
|
227
|
+
label = name
|
|
228
|
+
if label.endswith("_id"):
|
|
229
|
+
label = label[:-3]
|
|
230
|
+
label = re.sub(r"([A-Z])", r" \1", label)
|
|
231
|
+
return label.replace("_", " ").strip().title()
|
|
232
|
+
|
|
233
|
+
def is_required(self, col: ColumnMeta) -> bool:
|
|
234
|
+
"""Determine if a column is required (NOT NULL with no default).
|
|
235
|
+
|
|
236
|
+
A column is required if:
|
|
237
|
+
- It is NOT NULL
|
|
238
|
+
- It has no Python default
|
|
239
|
+
- It has no server_default (DB-side default)
|
|
240
|
+
- It is NOT a primary key (PKs are handled separately)
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
col: The column metadata to check.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
True if the column is required, False otherwise.
|
|
247
|
+
"""
|
|
248
|
+
return (
|
|
249
|
+
not col.nullable
|
|
250
|
+
and col.default is None
|
|
251
|
+
and col.server_default is None
|
|
252
|
+
and not col.primary_key
|
|
253
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
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 ``ClassName:pk`` when ``__str__`` is the default
|
|
108
|
+
``object.__str__``.
|
|
109
|
+
"""
|
|
110
|
+
if type(obj).__str__ is not object.__str__:
|
|
111
|
+
return str(obj)
|
|
112
|
+
pk = getattr(obj, "id", None)
|
|
113
|
+
return (
|
|
114
|
+
f"{type(obj).__name__}:{pk}" if pk is not None else type(obj).__name__
|
|
115
|
+
)
|