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,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: ...
|