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,170 @@
1
+ """Filter ABCs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+
9
+ class Filter(ABC):
10
+ """Abstract base class for list view filters."""
11
+
12
+ def __init__(self, field_name: str, label: str = "") -> None:
13
+ self.field_name = field_name
14
+ self.label = label or field_name.replace("_", " ").title()
15
+
16
+ @abstractmethod
17
+ def apply(self, query: Any, value: str) -> Any:
18
+ """Apply the filter to a SQLAlchemy select query."""
19
+ ...
20
+
21
+ def get_choices(self, session: Any) -> list[tuple[str, str]]:
22
+ """Return available filter choices as (value, label) pairs."""
23
+ return []
24
+
25
+
26
+ class TextFilter(Filter):
27
+ """Simple text equality filter."""
28
+
29
+ def apply(self, query: Any, value: str) -> Any:
30
+ model = query.column_descriptions[0]["entity"]
31
+ if hasattr(model, self.field_name):
32
+ col = getattr(model, self.field_name)
33
+ return query.where(col == value)
34
+ return query
35
+
36
+
37
+ class BooleanFilter(Filter):
38
+ """Boolean filter — maps '1' to True, '0' to False."""
39
+
40
+ def apply(self, query: Any, value: str) -> Any:
41
+ model = query.column_descriptions[0]["entity"]
42
+ if hasattr(model, self.field_name):
43
+ col = getattr(model, self.field_name)
44
+ return query.where(col == (value == "1"))
45
+ return query
46
+
47
+
48
+ class RelationFilter(Filter):
49
+ """Filter by foreign key relationship."""
50
+
51
+ def apply(self, query: Any, value: str) -> Any:
52
+ model = query.column_descriptions[0]["entity"]
53
+ if hasattr(model, self.field_name):
54
+ col = getattr(model, self.field_name)
55
+ return query.where(col == value)
56
+ return query
57
+
58
+
59
+ class EnumFilter(Filter):
60
+ """Filter for enum columns."""
61
+
62
+ def __init__(
63
+ self,
64
+ field_name: str,
65
+ label: str = "",
66
+ choices: list[str] | None = None,
67
+ ) -> None:
68
+ super().__init__(field_name, label)
69
+ self._enum_choices = choices or []
70
+
71
+ def apply(self, query: Any, value: str) -> Any:
72
+ model = query.column_descriptions[0]["entity"]
73
+ if hasattr(model, self.field_name):
74
+ col = getattr(model, self.field_name)
75
+ return query.where(col == value)
76
+ return query
77
+
78
+ def get_choices(self, session: Any) -> list[tuple[str, str]]:
79
+ choices = [("", "All")]
80
+ for val in self._enum_choices:
81
+ choices.append((val, val.replace("_", " ").title()))
82
+ return choices
83
+
84
+
85
+ class NumericFilter(Filter):
86
+ """Numeric range filter (gte/lte)."""
87
+
88
+ def apply(self, query: Any, value: str) -> Any:
89
+ model = query.column_descriptions[0]["entity"]
90
+ if not hasattr(model, self.field_name):
91
+ return query
92
+ col = getattr(model, self.field_name)
93
+ if isinstance(value, dict):
94
+ if value.get("gte"):
95
+ query = query.where(col >= value["gte"])
96
+ if value.get("lte"):
97
+ query = query.where(col <= value["lte"])
98
+ return query
99
+
100
+
101
+ class DateRangeFilter(Filter):
102
+ """Date range filter (from/to)."""
103
+
104
+ def apply(self, query: Any, value: str) -> Any:
105
+ from datetime import date
106
+
107
+ model = query.column_descriptions[0]["entity"]
108
+ if not hasattr(model, self.field_name):
109
+ return query
110
+ col = getattr(model, self.field_name)
111
+ if isinstance(value, dict):
112
+ if value.get("from"):
113
+ try:
114
+ d = date.fromisoformat(value["from"])
115
+ query = query.where(col >= d)
116
+ except (ValueError, TypeError):
117
+ pass
118
+ if value.get("to"):
119
+ try:
120
+ d = date.fromisoformat(value["to"])
121
+ query = query.where(col <= d)
122
+ except (ValueError, TypeError):
123
+ pass
124
+ return query
125
+
126
+
127
+ class DatetimeRangeFilter(Filter):
128
+ """Datetime range filter (from/to)."""
129
+
130
+ def apply(self, query: Any, value: str) -> Any:
131
+ from datetime import datetime
132
+
133
+ model = query.column_descriptions[0]["entity"]
134
+ if not hasattr(model, self.field_name):
135
+ return query
136
+ col = getattr(model, self.field_name)
137
+ if isinstance(value, dict):
138
+ if value.get("from"):
139
+ try:
140
+ dt = datetime.fromisoformat(value["from"])
141
+ query = query.where(col >= dt)
142
+ except (ValueError, TypeError):
143
+ pass
144
+ if value.get("to"):
145
+ try:
146
+ dt = datetime.fromisoformat(value["to"])
147
+ query = query.where(col <= dt)
148
+ except (ValueError, TypeError):
149
+ pass
150
+ return query
151
+
152
+
153
+ class AutocompleteFilter(Filter):
154
+ """Autocomplete search filter for related fields."""
155
+
156
+ def __init__(
157
+ self,
158
+ field_name: str,
159
+ label: str = "",
160
+ search_fields: list[str] | None = None,
161
+ ) -> None:
162
+ super().__init__(field_name, label)
163
+ self.search_fields = search_fields or ["name"]
164
+
165
+ def apply(self, query: Any, value: str) -> Any:
166
+ model = query.column_descriptions[0]["entity"]
167
+ if not hasattr(model, self.field_name):
168
+ return query
169
+ col = getattr(model, self.field_name)
170
+ return query.where(col == value)
@@ -0,0 +1,68 @@
1
+ """Filter registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi_admin_kit.filters.base import (
8
+ BooleanFilter,
9
+ EnumFilter,
10
+ Filter,
11
+ RelationFilter,
12
+ TextFilter,
13
+ )
14
+
15
+
16
+ class FilterRegistry:
17
+ """Registry for custom filters per model."""
18
+
19
+ def __init__(self) -> None:
20
+ self._filters: dict[str, dict[str, Filter]] = {}
21
+
22
+ def register(self, model_name: str, filter_obj: Filter) -> None:
23
+ self._filters.setdefault(model_name, {})[filter_obj.field_name] = (
24
+ filter_obj
25
+ )
26
+
27
+ def get_filters(self, model_name: str) -> dict[str, Filter]:
28
+ return self._filters.get(model_name, {}).copy()
29
+
30
+ def auto_generate(
31
+ self, model: Any, columns: list[Any]
32
+ ) -> dict[str, Filter]:
33
+ from sqlalchemy import inspect as sa_inspect
34
+
35
+ mapper = sa_inspect(model)
36
+ filters: dict[str, Filter] = {}
37
+
38
+ for col_meta in columns:
39
+ field_name = col_meta.name
40
+ if field_name == "id":
41
+ continue
42
+
43
+ rel_names = {r.key for r in mapper.relationships}
44
+ if field_name in rel_names:
45
+ filters[field_name] = RelationFilter(field_name)
46
+ continue
47
+
48
+ for prop in mapper.column_attrs:
49
+ if prop.key != field_name:
50
+ continue
51
+ col = prop.columns[0] if prop.columns else None
52
+ if col is None:
53
+ break
54
+
55
+ type_name = col.type.__class__.__name__
56
+ if type_name == "Boolean":
57
+ filters[field_name] = BooleanFilter(field_name)
58
+ elif hasattr(col.type, "enums") and col.type.enums:
59
+ filters[field_name] = EnumFilter(
60
+ field_name, choices=list(col.type.enums)
61
+ )
62
+ elif col.foreign_keys:
63
+ filters[field_name] = RelationFilter(field_name)
64
+ else:
65
+ filters[field_name] = TextFilter(field_name)
66
+ break
67
+
68
+ return filters
@@ -0,0 +1,45 @@
1
+ """Flash message helpers for the admin UI.
2
+
3
+ Stored in a signed session cookie so they survive exactly one redirect-safe read.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from fastapi import Request
9
+
10
+ SESSION_KEY = "admin_flash"
11
+
12
+
13
+ async def add_flash(request: Request, level: str, message: str) -> None:
14
+ session_backend = request.app.state.admin_session_backend
15
+ cookie_name = getattr(session_backend, "cookie_name", "admin_session")
16
+ data: dict[str, list[dict[str, str]]] | None = None
17
+ raw = request.cookies.get(cookie_name)
18
+ if raw and hasattr(session_backend, "load"):
19
+ loaded = session_backend.load(raw)
20
+ if isinstance(loaded, dict):
21
+ data = loaded
22
+ if data is None:
23
+ data = {}
24
+ data.setdefault(SESSION_KEY, []).append({"level": level, "text": message})
25
+ if hasattr(session_backend, "save"):
26
+ from starlette.responses import Response
27
+ response = Response()
28
+ session_backend.save(response, data)
29
+
30
+
31
+ async def get_flash_messages(request: Request) -> list[dict[str, str]]:
32
+ session_backend = request.app.state.admin_session_backend
33
+ cookie_name = getattr(session_backend, "cookie_name", "admin_session")
34
+ raw = request.cookies.get(cookie_name)
35
+ if not raw or not hasattr(session_backend, "load"):
36
+ return []
37
+ data = session_backend.load(raw)
38
+ if not isinstance(data, dict):
39
+ return []
40
+ messages = data.pop(SESSION_KEY, []) if SESSION_KEY in data else []
41
+ if hasattr(session_backend, "save"):
42
+ from starlette.responses import Response
43
+ response = Response()
44
+ session_backend.save(response, data)
45
+ return messages
@@ -0,0 +1 @@
1
+ """Form generation and handling."""
@@ -0,0 +1,106 @@
1
+ """Build FormContext from a RegisteredModel + DB object + values + errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi_admin_kit.types import (
8
+ FieldRenderContext,
9
+ FieldsetContext,
10
+ FormContext,
11
+ PermissionSet,
12
+ )
13
+
14
+
15
+ def build_form_context(
16
+ registered: Any,
17
+ obj: Any | None = None,
18
+ values: dict[str, Any] | None = None,
19
+ errors: dict[str, list[str]] | None = None,
20
+ request: Any = None,
21
+ is_create: bool = False,
22
+ rel_labels: dict[str, str] | None = None,
23
+ ) -> FormContext:
24
+ values = values or {}
25
+ errors = errors or {}
26
+ rendered: list[FieldRenderContext] = []
27
+ fieldsets: list[FieldsetContext] = [FieldsetContext(fields=[])]
28
+
29
+ for field_meta in registered.form_fields:
30
+ col = next((c for c in registered.columns if c.name == field_meta.name), None)
31
+ rel = next((r for r in registered.relationships if r.name == field_meta.name), None)
32
+ widget = registered.get_widget(field_meta.name)
33
+
34
+ value = values.get(field_meta.name)
35
+ if value is None and obj is not None:
36
+ if hasattr(obj, "__dict__"):
37
+ value = obj.__dict__.get(field_meta.name)
38
+ # For M2M relationships, extract IDs from loaded collection
39
+ if value is None and rel is not None:
40
+ try:
41
+ from sqlalchemy import inspect as sa_inspect
42
+ mapper = sa_inspect(type(obj))
43
+ rel_prop = mapper.relationships.get(rel.name)
44
+ if rel_prop is not None:
45
+ if rel_prop.direction.name == "MANYTOMANY":
46
+ collection = getattr(obj, rel_prop.key, None)
47
+ if collection is not None:
48
+ value = [str(item.id) for item in collection]
49
+ else:
50
+ local_cols = [c.key for c in rel_prop.local_columns]
51
+ if local_cols:
52
+ value = getattr(obj, local_cols[0], None)
53
+ except Exception:
54
+ pass
55
+ # Handle case where __dict__ returned loaded M2M collection objects
56
+ if (
57
+ value is not None
58
+ and rel is not None
59
+ and isinstance(value, list)
60
+ and value
61
+ and hasattr(value[0], "id")
62
+ ):
63
+ try:
64
+ from sqlalchemy import inspect as sa_inspect
65
+ mapper = sa_inspect(type(obj))
66
+ rel_prop = mapper.relationships.get(rel.name)
67
+ if rel_prop is not None and rel_prop.direction.name == "MANYTOMANY":
68
+ value = [str(item.id) for item in value]
69
+ except Exception:
70
+ pass
71
+ elif value is None and col is not None:
72
+ value = getattr(obj, field_meta.name, None)
73
+
74
+ widget_macro = widget.macro_name
75
+ widget_ctx = widget.render_context(field_meta, value)
76
+ widget_ctx["is_create"] = is_create
77
+ if obj is not None:
78
+ widget_ctx["obj_id"] = getattr(obj, "id", "")
79
+ if rel is not None and rel_labels:
80
+ widget_ctx["label_text"] = rel_labels.get(rel.name, "")
81
+ field_errors = errors.get(field_meta.name, [])
82
+ rendered.append(
83
+ FieldRenderContext(
84
+ meta=field_meta,
85
+ widget_macro=widget_macro,
86
+ widget_context=widget_ctx,
87
+ errors=field_errors,
88
+ )
89
+ )
90
+
91
+ fieldsets[0].fields = rendered
92
+
93
+ return FormContext(
94
+ model_name=registered.table_name,
95
+ verbose_name=registered.verbose_name,
96
+ is_create=is_create,
97
+ obj=obj,
98
+ fieldsets=fieldsets,
99
+ errors=errors,
100
+ values=values,
101
+ action_url="",
102
+ list_url="",
103
+ can_delete=not is_create,
104
+ permissions=PermissionSet(),
105
+ readonly=False,
106
+ )
@@ -0,0 +1,117 @@
1
+ """Model inspection — SQLAlchemy model → ColumnMeta / RelationMeta."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from sqlalchemy import inspect
9
+
10
+ from fastapi_admin_kit.types import ColumnMeta, RelationMeta
11
+
12
+
13
+ def inspect_model(model: type) -> tuple[list[ColumnMeta], list[RelationMeta]]:
14
+ """Inspect a SQLAlchemy model and return column + relationship metadata."""
15
+ mapper = inspect(model)
16
+ columns: list[ColumnMeta] = []
17
+ relationships: list[RelationMeta] = []
18
+
19
+ for col in mapper.columns:
20
+ columns.append(
21
+ ColumnMeta(
22
+ name=col.key,
23
+ type=col.type,
24
+ nullable=col.nullable,
25
+ primary_key=col.primary_key,
26
+ foreign_keys=list(col.foreign_keys),
27
+ default=col.default,
28
+ server_default=col.server_default,
29
+ index=col.index,
30
+ unique=col.unique,
31
+ )
32
+ )
33
+
34
+ for rel in mapper.relationships:
35
+ relationships.append(
36
+ RelationMeta(
37
+ name=rel.key,
38
+ direction=rel.direction.name,
39
+ target_model=rel.mapper.class_,
40
+ uselist=rel.uselist,
41
+ back_populates=rel.back_populates,
42
+ secondary=rel.secondary,
43
+ )
44
+ )
45
+
46
+ return columns, relationships
47
+
48
+
49
+ def is_abstract(model: type) -> bool:
50
+ """Check if a model is abstract and should be skipped during auto-discovery."""
51
+ return getattr(model, "__abstract__", False)
52
+
53
+
54
+ def get_pk_field(model: type) -> str | None:
55
+ """Get the primary key field name for a model.
56
+
57
+ Returns the single PK field name for simple PKs,
58
+ or a tuple of names for composite PKs.
59
+ Returns None if no primary key is found.
60
+ """
61
+ mapper = inspect(model)
62
+ pk_cols = mapper.primary_key
63
+ if not pk_cols:
64
+ return None
65
+ if len(pk_cols) == 1:
66
+ return pk_cols[0].key
67
+ return tuple(col.key for col in pk_cols)
68
+
69
+
70
+ def auto_label(name: str) -> str:
71
+ """Auto-generate a human-readable label from a field name.
72
+
73
+ Examples:
74
+ "category_id" → "Category"
75
+ "is_active" → "Is Active"
76
+ "created_at" → "Created At"
77
+ "skuCode" → "Sku Code"
78
+ """
79
+ label = name
80
+ if label.endswith("_id"):
81
+ label = label[:-3]
82
+ label = re.sub(r"([A-Z])", r" \1", label)
83
+ return label.replace("_", " ").strip().title()
84
+
85
+
86
+ def is_required(col: ColumnMeta) -> bool:
87
+ """Determine if a column is required (NOT NULL with no default).
88
+
89
+ A column is required if:
90
+ - It is NOT NULL
91
+ - It has no Python default
92
+ - It has no server_default (DB-side default)
93
+ - It is NOT a primary key (PKs are handled separately)
94
+ """
95
+ return (
96
+ not col.nullable
97
+ and col.default is None
98
+ and col.server_default is None
99
+ and not col.primary_key
100
+ )
101
+
102
+
103
+ def model_display_name(obj: Any) -> str:
104
+ """Return a human-readable label for an ORM object.
105
+
106
+ Uses the model's ``__str__`` if it has a custom implementation.
107
+ Falls back to ``name``, ``title``, or ``ClassName:pk``.
108
+ """
109
+ if type(obj).__str__ is not object.__str__:
110
+ return str(obj)
111
+ label = getattr(obj, "name", None) or getattr(obj, "title", None)
112
+ if label is not None:
113
+ return str(label)
114
+ pk = getattr(obj, "id", None)
115
+ return (
116
+ f"{type(obj).__name__}:{pk}" if pk is not None else type(obj).__name__
117
+ )