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,159 @@
1
+ """Create and edit form handler factories.
2
+
3
+ Backward-compatible wrappers — delegate to CreateView/EditView classes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from fastapi import Request
11
+ from starlette.datastructures import UploadFile
12
+
13
+ from fastapi_admin_kit.form.pipeline import build_form_context
14
+ from fastapi_admin_kit.registry import RegisteredModel
15
+ from fastapi_admin_kit.types import PermissionSet
16
+ from fastapi_admin_kit.views.sidebar import inject_sidebar_context
17
+ from fastapi_admin_kit.widgets.inputs import FileUploadWidget, ImageUploadWidget
18
+
19
+ # Widgets that handle file uploads
20
+ _FILE_WIDGET_TYPES = (FileUploadWidget, ImageUploadWidget)
21
+
22
+
23
+ def _get_storage(request: Request):
24
+ """Get the storage backend from app.state, or None."""
25
+ return getattr(request.app.state, "admin_storage", None)
26
+
27
+
28
+ async def _handle_file_field(
29
+ request: Request,
30
+ widget: Any,
31
+ field_meta: Any,
32
+ form_data: Any,
33
+ obj: Any | None,
34
+ action: str | None,
35
+ parsed: dict[str, Any],
36
+ errors: dict[str, list[str]],
37
+ ) -> None:
38
+ """Handle a file upload field during form submission.
39
+
40
+ For create: always save the new upload.
41
+ For edit: respect the ``action`` parameter:
42
+ - ``keep`` (default): keep existing file path unchanged
43
+ - ``replace``: save new upload, delete old file
44
+ - ``clear``: delete old file, set value to None
45
+ """
46
+ storage = _get_storage(request)
47
+ field_name = field_meta.name
48
+ raw = form_data.get(field_name)
49
+
50
+ if isinstance(raw, UploadFile) and raw.filename:
51
+ # New file uploaded
52
+ if widget.max_size_mb is not None:
53
+ content = await raw.read()
54
+ max_bytes = int(widget.max_size_mb * 1024 * 1024)
55
+ if len(content) > max_bytes:
56
+ errors[field_name] = [
57
+ f"File size exceeds maximum allowed size ({widget.max_size_mb} MB)."
58
+ ]
59
+ # Reset file position for potential re-read
60
+ await raw.seek(0)
61
+ return
62
+ # Reset file position after size check
63
+ await raw.seek(0)
64
+
65
+ if storage is None:
66
+ errors[field_name] = ["No storage backend configured."]
67
+ return
68
+
69
+ try:
70
+ path = await storage.save(raw, directory=field_meta.name)
71
+ except ValueError as exc:
72
+ errors[field_name] = [str(exc)]
73
+ return
74
+
75
+ # Delete old file if replacing
76
+ if action == "replace" and obj is not None:
77
+ old_path = getattr(obj, field_name, None)
78
+ if old_path:
79
+ await storage.delete(old_path)
80
+
81
+ parsed[field_name] = path
82
+
83
+ elif action == "clear":
84
+ # User wants to remove the file
85
+ if storage is not None and obj is not None:
86
+ old_path = getattr(obj, field_name, None)
87
+ if old_path:
88
+ await storage.delete(old_path)
89
+ parsed[field_name] = None
90
+
91
+ elif action == "keep" or action is None:
92
+ # Keep existing value
93
+ if obj is not None:
94
+ parsed[field_name] = getattr(obj, field_name, None)
95
+
96
+ else:
97
+ # No new upload, no explicit action — keep existing
98
+ if obj is not None:
99
+ parsed[field_name] = getattr(obj, field_name, None)
100
+
101
+
102
+ def create_form_factory(registered: RegisteredModel):
103
+ async def create_form(request: Request, _: Any = None):
104
+ templates = request.app.state.admin_jinja_env
105
+ ctx = build_form_context(registered, is_create=True)
106
+ context = await inject_sidebar_context(request, {
107
+ "form_context": ctx,
108
+ "is_create": True,
109
+ "permissions": PermissionSet(
110
+ can_view=True, can_create=True, can_edit=True, can_delete=True
111
+ ),
112
+ })
113
+ return templates.TemplateResponse(
114
+ request, "pages/form.html", context
115
+ )
116
+ create_form.__name__ = f"create_form_{registered.table_name}"
117
+ return create_form
118
+
119
+
120
+ def create_submit_factory(registered: RegisteredModel):
121
+ """Create form submission handler — delegates to CreateView.html_response."""
122
+ from fastapi_admin_kit.views.class_views import CreateView, _resolve_view_class
123
+
124
+ view_class = _resolve_view_class(registered.admin, "create_view_class", CreateView)
125
+ view_instance = view_class(registered)
126
+
127
+ async def _handler(request: Request, **kwargs: Any):
128
+ return await view_instance.html_response(request, **kwargs)
129
+
130
+ _handler.__name__ = f"create_submit_{registered.table_name}"
131
+ return _handler
132
+
133
+
134
+ def edit_form_factory(registered: RegisteredModel):
135
+ """Edit form display handler — delegates to EditView.html_response."""
136
+ from fastapi_admin_kit.views.class_views import EditView, _resolve_view_class
137
+
138
+ view_class = _resolve_view_class(registered.admin, "edit_view_class", EditView)
139
+ view_instance = view_class(registered)
140
+
141
+ async def _handler(request: Request, **kwargs: Any):
142
+ return await view_instance.html_response(request, **kwargs)
143
+
144
+ _handler.__name__ = f"edit_form_{registered.table_name}"
145
+ return _handler
146
+
147
+
148
+ def edit_submit_factory(registered: RegisteredModel):
149
+ """Edit form submission handler — delegates to EditView.html_response."""
150
+ from fastapi_admin_kit.views.class_views import EditView, _resolve_view_class
151
+
152
+ view_class = _resolve_view_class(registered.admin, "edit_view_class", EditView)
153
+ view_instance = view_class(registered)
154
+
155
+ async def _handler(request: Request, **kwargs: Any):
156
+ return await view_instance.html_response(request, **kwargs)
157
+
158
+ _handler.__name__ = f"edit_submit_{registered.table_name}"
159
+ return _handler
@@ -0,0 +1,28 @@
1
+ """List view handler factory for registered models.
2
+
3
+ Backward-compatible wrapper — delegates to ListView class.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from fastapi_admin_kit.registry import RegisteredModel
11
+
12
+
13
+ def list_view_factory(registered: RegisteredModel):
14
+ """Create a list view handler — delegates to ListView.html_response."""
15
+ from fastapi_admin_kit.views.class_views import ListView, _resolve_view_class
16
+
17
+ view_class = _resolve_view_class(registered.admin, "list_view_class", ListView)
18
+ view_instance = view_class(registered)
19
+
20
+ async def _handler(request: Request, **kwargs: Any):
21
+ return await view_instance.html_response(request, **kwargs)
22
+
23
+ _handler.__name__ = f"list_{registered.table_name}"
24
+ return _handler
25
+
26
+
27
+ # Need Request for type annotation in the wrapper
28
+ from fastapi import Request # noqa: E402
@@ -0,0 +1,219 @@
1
+ """Profile views — password change and profile editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+
7
+ from fastapi import APIRouter, Depends, Request
8
+ from fastapi.responses import HTMLResponse, RedirectResponse
9
+ from sqlalchemy import select
10
+
11
+ from fastapi_admin_kit.auth.csrf import require_csrf_token
12
+ from fastapi_admin_kit.auth.dependencies import get_current_admin_user
13
+ from fastapi_admin_kit.auth.protocol import AdminUserProtocol
14
+ from fastapi_admin_kit.db import get_db_session
15
+ from fastapi_admin_kit.views.sidebar import inject_sidebar_context
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ @router.get("/profile", response_class=HTMLResponse)
21
+ async def profile_view(
22
+ request: Request,
23
+ user: AdminUserProtocol = Depends(get_current_admin_user),
24
+ ):
25
+ """Show current user profile."""
26
+ templates = request.app.state.admin_jinja_env
27
+ return templates.TemplateResponse(
28
+ request,
29
+ "pages/profile/profile.html",
30
+ await inject_sidebar_context(
31
+ request,
32
+ {
33
+ "profile_user": user,
34
+ },
35
+ ),
36
+ )
37
+
38
+
39
+ @router.post("/profile")
40
+ async def profile_update(
41
+ request: Request,
42
+ user: AdminUserProtocol = Depends(get_current_admin_user),
43
+ _csrf: bool = Depends(require_csrf_token),
44
+ ):
45
+ """Update profile (full_name, email)."""
46
+ from fastapi_admin_kit.auth.backend import pwd_context
47
+
48
+ session = get_db_session(request)
49
+ form = await request.form()
50
+
51
+ email = form.get("email", "").strip()
52
+ full_name = form.get("full_name", "").strip()
53
+ password = form.get("password", "")
54
+
55
+ if not password:
56
+ templates = request.app.state.admin_jinja_env
57
+ return templates.TemplateResponse(
58
+ request,
59
+ "pages/profile/profile.html",
60
+ await inject_sidebar_context(
61
+ request,
62
+ {
63
+ "profile_user": user,
64
+ "error": "Password is required to save changes.",
65
+ },
66
+ ),
67
+ )
68
+
69
+ if not pwd_context.verify(password, user.hashed_password):
70
+ templates = request.app.state.admin_jinja_env
71
+ return templates.TemplateResponse(
72
+ request,
73
+ "pages/profile/profile.html",
74
+ await inject_sidebar_context(
75
+ request,
76
+ {
77
+ "profile_user": user,
78
+ "error": "Incorrect password.",
79
+ },
80
+ ),
81
+ )
82
+
83
+ if email:
84
+ existing = await session.execute(
85
+ select(type(user)).where(
86
+ type(user).email == email, type(user).id != user.id
87
+ )
88
+ )
89
+ if existing.scalar_one_or_none():
90
+ templates = request.app.state.admin_jinja_env
91
+ return templates.TemplateResponse(
92
+ request,
93
+ "pages/profile/profile.html",
94
+ await inject_sidebar_context(
95
+ request,
96
+ {
97
+ "profile_user": user,
98
+ "error": "Email already in use.",
99
+ },
100
+ ),
101
+ )
102
+ user.email = email
103
+
104
+ user.full_name = full_name
105
+ await session.flush()
106
+
107
+ return RedirectResponse(url="/admin/profile", status_code=302)
108
+
109
+
110
+ @router.get("/profile/password", response_class=HTMLResponse)
111
+ async def password_change_view(
112
+ request: Request,
113
+ _: AdminUserProtocol = Depends(get_current_admin_user),
114
+ ):
115
+ """Show change password form."""
116
+ templates = request.app.state.admin_jinja_env
117
+ return templates.TemplateResponse(
118
+ request,
119
+ "pages/profile/password.html",
120
+ await inject_sidebar_context(request, {}),
121
+ )
122
+
123
+
124
+ @router.post("/profile/password")
125
+ async def password_change_post(
126
+ request: Request,
127
+ user: AdminUserProtocol = Depends(get_current_admin_user),
128
+ _csrf: bool = Depends(require_csrf_token),
129
+ ):
130
+ """Handle password change."""
131
+ from fastapi_admin_kit.auth.backend import pwd_context
132
+ from fastapi_admin_kit.auth.password import validate_password_strength
133
+
134
+ session = get_db_session(request)
135
+ form = await request.form()
136
+
137
+ current_password = form.get("current_password", "")
138
+ new_password = form.get("new_password", "")
139
+ confirm_password = form.get("confirm_password", "")
140
+
141
+ if not pwd_context.verify(current_password, user.hashed_password):
142
+ templates = request.app.state.admin_jinja_env
143
+ return templates.TemplateResponse(
144
+ request,
145
+ "pages/profile/password.html",
146
+ await inject_sidebar_context(
147
+ request,
148
+ {
149
+ "error": "Current password is incorrect.",
150
+ },
151
+ ),
152
+ )
153
+
154
+ if new_password != confirm_password:
155
+ templates = request.app.state.admin_jinja_env
156
+ return templates.TemplateResponse(
157
+ request,
158
+ "pages/profile/password.html",
159
+ await inject_sidebar_context(
160
+ request,
161
+ {
162
+ "error": "New passwords do not match.",
163
+ },
164
+ ),
165
+ )
166
+
167
+ password_errors = validate_password_strength(new_password)
168
+ if password_errors:
169
+ templates = request.app.state.admin_jinja_env
170
+ return templates.TemplateResponse(
171
+ request,
172
+ "pages/profile/password.html",
173
+ await inject_sidebar_context(
174
+ request,
175
+ {
176
+ "error": password_errors[0],
177
+ },
178
+ ),
179
+ )
180
+
181
+ user.hashed_password = pwd_context.hash(new_password)
182
+ user.password_changed_at = datetime.now(UTC)
183
+ await session.flush()
184
+
185
+ # Revoke all refresh tokens for this user
186
+ from sqlalchemy import update
187
+
188
+ from fastapi_admin_kit.auth.models import AdminRefreshToken
189
+
190
+ await session.execute(
191
+ update(AdminRefreshToken)
192
+ .where(
193
+ AdminRefreshToken.user_id == user.id,
194
+ AdminRefreshToken.revoked_at.is_(None),
195
+ )
196
+ .values(revoked_at=datetime.now(UTC))
197
+ )
198
+ await session.flush()
199
+
200
+ # Clear session and redirect to login
201
+ from fastapi_admin_kit.auth.csrf import CSRF_COOKIE_NAME
202
+
203
+ response = RedirectResponse(url="/admin/login", status_code=302)
204
+ session_backend = request.app.state.admin_session_backend
205
+ samesite = getattr(
206
+ request.app.state.admin_state, "session_samesite", "strict"
207
+ )
208
+ response.delete_cookie(
209
+ key=session_backend.cookie_name,
210
+ path="/",
211
+ secure=session_backend.secure,
212
+ httponly=True,
213
+ samesite=samesite,
214
+ )
215
+ response.delete_cookie(
216
+ key=CSRF_COOKIE_NAME,
217
+ path="/",
218
+ )
219
+ return response
@@ -0,0 +1,54 @@
1
+ """Protocol interfaces for SOLID class-based views.
2
+
3
+ Each protocol has a single responsibility (ISP).
4
+ View classes depend on these abstractions (DIP).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+ from fastapi import Request
12
+ from fastapi.responses import Response
13
+
14
+
15
+ @runtime_checkable
16
+ class QueryProvider(Protocol):
17
+ """Single responsibility: build and execute database queries."""
18
+
19
+ async def get_list(
20
+ self, request: Request, q: str, page: int
21
+ ) -> tuple[list[Any], int, int, int]:
22
+ """Return (items, total, page, per_page)."""
23
+ ...
24
+
25
+ async def get_object(self, request: Request, id: Any) -> Any | None:
26
+ """Return a single object or None."""
27
+ ...
28
+
29
+
30
+ @runtime_checkable
31
+ class FormParser(Protocol):
32
+ """Single responsibility: parse and validate form/request data."""
33
+
34
+ async def parse(
35
+ self, request: Request, obj: Any | None = None
36
+ ) -> tuple[dict[str, Any], dict[str, list[str]]]:
37
+ """Return (parsed_values, errors)."""
38
+ ...
39
+
40
+
41
+ @runtime_checkable
42
+ class HTMLRenderer(Protocol):
43
+ """Single responsibility: return an HTML TemplateResponse."""
44
+
45
+ async def render(
46
+ self, request: Request, context: dict[str, Any]
47
+ ) -> Response: ...
48
+
49
+
50
+ @runtime_checkable
51
+ class APIRenderer(Protocol):
52
+ """Single responsibility: return a JSON Response."""
53
+
54
+ async def render(self, request: Request, data: Any) -> Response: ...