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