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,83 @@
|
|
|
1
|
+
"""TOTP-based two-factor authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import secrets
|
|
9
|
+
import struct
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_secret() -> str:
|
|
14
|
+
"""Generate a TOTP secret key (base32 encoded)."""
|
|
15
|
+
return base64.b32encode(secrets.token_bytes(20)).decode("utf-8").rstrip("=")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_totp_uri(secret: str, email: str, issuer: str = "FastAPI Admin Kit") -> str:
|
|
19
|
+
"""Generate an otpauth:// URI for QR code generation."""
|
|
20
|
+
import urllib.parse
|
|
21
|
+
params = urllib.parse.urlencode({
|
|
22
|
+
"secret": secret,
|
|
23
|
+
"issuer": issuer,
|
|
24
|
+
"digits": 6,
|
|
25
|
+
"period": 30,
|
|
26
|
+
})
|
|
27
|
+
return f"otpauth://totp/{urllib.parse.quote(issuer)}:{urllib.parse.quote(email)}?{params}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _generate_hotp(secret: str, counter: int) -> str:
|
|
31
|
+
"""Generate a HOTP code for a given counter value."""
|
|
32
|
+
key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8))
|
|
33
|
+
counter_bytes = struct.pack(">Q", counter)
|
|
34
|
+
hmac_digest = hmac.new(key, counter_bytes, hashlib.sha1).digest()
|
|
35
|
+
offset = hmac_digest[-1] & 0x0F
|
|
36
|
+
code_int = struct.unpack(">I", hmac_digest[offset:offset + 4])[0] & 0x7FFFFFFF
|
|
37
|
+
return str(code_int % 1000000).zfill(6)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_totp(secret: str, code: str, window: int = 1) -> bool:
|
|
41
|
+
"""Verify a TOTP code with a ±window tolerance.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
secret: Base32-encoded TOTP secret
|
|
45
|
+
code: 6-digit code to verify
|
|
46
|
+
window: Number of time steps to check in each direction (default 1 = ±30s)
|
|
47
|
+
"""
|
|
48
|
+
if not code or len(code) != 6 or not code.isdigit():
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
current_counter = int(time.time()) // 30
|
|
52
|
+
for offset in range(-window, window + 1):
|
|
53
|
+
expected = _generate_hotp(secret, current_counter + offset)
|
|
54
|
+
if hmac.compare_digest(code, expected):
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def generate_backup_codes(count: int = 10) -> list[str]:
|
|
60
|
+
"""Generate one-time-use backup codes (8 alphanumeric chars each)."""
|
|
61
|
+
codes = []
|
|
62
|
+
for _ in range(count):
|
|
63
|
+
code = secrets.token_urlsafe(6)[:8]
|
|
64
|
+
codes.append(code.upper())
|
|
65
|
+
return codes
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def hash_backup_code(code: str) -> str:
|
|
69
|
+
"""Hash a backup code for storage."""
|
|
70
|
+
return hashlib.sha256(code.encode()).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def verify_backup_code(code: str, hashed_codes: list[str]) -> bool:
|
|
74
|
+
"""Verify a backup code against a list of hashed codes.
|
|
75
|
+
|
|
76
|
+
Returns True if the code matches and removes it from the list (in-place).
|
|
77
|
+
"""
|
|
78
|
+
code_hash = hash_backup_code(code)
|
|
79
|
+
for i, h in enumerate(hashed_codes):
|
|
80
|
+
if hmac.compare_digest(code_hash, h):
|
|
81
|
+
hashed_codes.pop(i)
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Auth views — login, logout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from fastapi import (
|
|
10
|
+
APIRouter,
|
|
11
|
+
Depends,
|
|
12
|
+
Form,
|
|
13
|
+
Request,
|
|
14
|
+
status,
|
|
15
|
+
)
|
|
16
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
from fastapi_admin_kit.auth.csrf import CSRF_COOKIE_NAME, require_csrf_token
|
|
20
|
+
from fastapi_admin_kit.auth.dependencies import _get_db_session, get_session
|
|
21
|
+
from fastapi_admin_kit.auth.ratelimit import (
|
|
22
|
+
RateLimiter,
|
|
23
|
+
_client_ip,
|
|
24
|
+
check_rate_limit,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
router = APIRouter()
|
|
28
|
+
|
|
29
|
+
_login_rate_limiter = RateLimiter(max_attempts=5, window_seconds=900)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_safe_url(url: str | None) -> bool:
|
|
33
|
+
"""Return True if the URL is relative (no scheme or netloc)."""
|
|
34
|
+
if not url:
|
|
35
|
+
return False
|
|
36
|
+
parsed = urlparse(url)
|
|
37
|
+
return not (parsed.scheme or parsed.netloc)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/login", response_class=HTMLResponse)
|
|
41
|
+
async def login_get(
|
|
42
|
+
request: Request,
|
|
43
|
+
next: str | None = None,
|
|
44
|
+
session_payload: dict[str, Any] | None = Depends(get_session),
|
|
45
|
+
) -> HTMLResponse:
|
|
46
|
+
"""GET /admin/login — show login page, redirect if already logged in."""
|
|
47
|
+
if session_payload is not None:
|
|
48
|
+
target = next if _is_safe_url(next) else "/admin/"
|
|
49
|
+
return RedirectResponse(url=target, status_code=status.HTTP_302_FOUND)
|
|
50
|
+
|
|
51
|
+
jinja_env = request.app.state.admin_jinja_env
|
|
52
|
+
template = jinja_env.get_template("pages/login.html")
|
|
53
|
+
csrf_token = getattr(request.state, "csrf_token", "")
|
|
54
|
+
return HTMLResponse(
|
|
55
|
+
template.render(
|
|
56
|
+
{
|
|
57
|
+
"request": request,
|
|
58
|
+
"csrf_token": csrf_token,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.post("/login", response_model=None)
|
|
65
|
+
async def login_post(
|
|
66
|
+
request: Request,
|
|
67
|
+
email: str = Form(...),
|
|
68
|
+
password: str = Form(...),
|
|
69
|
+
next: str | None = Form(None),
|
|
70
|
+
session: AsyncSession = Depends(_get_db_session),
|
|
71
|
+
_csrf: bool = Depends(require_csrf_token),
|
|
72
|
+
) -> HTMLResponse | RedirectResponse:
|
|
73
|
+
"""POST /admin/login — process login form."""
|
|
74
|
+
client_ip = _client_ip(request)
|
|
75
|
+
check_rate_limit(_login_rate_limiter, client_ip)
|
|
76
|
+
|
|
77
|
+
auth_backend = request.app.state.admin_auth_backend
|
|
78
|
+
user = await auth_backend.authenticate(email, password, session)
|
|
79
|
+
|
|
80
|
+
if user is not None:
|
|
81
|
+
_login_rate_limiter.reset(client_ip)
|
|
82
|
+
user.last_login = datetime.now(UTC)
|
|
83
|
+
await session.merge(user)
|
|
84
|
+
await session.flush()
|
|
85
|
+
|
|
86
|
+
session_backend = request.app.state.admin_session_backend
|
|
87
|
+
session_data = {"user_id": user.id}
|
|
88
|
+
token = session_backend.encode(session_data)
|
|
89
|
+
|
|
90
|
+
if next and _is_safe_url(next):
|
|
91
|
+
redirect_url = next
|
|
92
|
+
else:
|
|
93
|
+
redirect_url = "/admin/"
|
|
94
|
+
|
|
95
|
+
samesite = getattr(
|
|
96
|
+
request.app.state.admin_state, "session_samesite", "strict"
|
|
97
|
+
)
|
|
98
|
+
response = RedirectResponse(
|
|
99
|
+
url=redirect_url, status_code=status.HTTP_302_FOUND
|
|
100
|
+
)
|
|
101
|
+
response.set_cookie(
|
|
102
|
+
key=session_backend.cookie_name,
|
|
103
|
+
value=token,
|
|
104
|
+
max_age=session_backend._session_ttl,
|
|
105
|
+
path="/",
|
|
106
|
+
secure=session_backend.secure,
|
|
107
|
+
httponly=True,
|
|
108
|
+
samesite=samesite,
|
|
109
|
+
)
|
|
110
|
+
return response
|
|
111
|
+
|
|
112
|
+
_login_rate_limiter.record_attempt(client_ip)
|
|
113
|
+
|
|
114
|
+
jinja_env = request.app.state.admin_jinja_env
|
|
115
|
+
template = jinja_env.get_template("pages/login.html")
|
|
116
|
+
csrf_token = getattr(request.state, "csrf_token", "")
|
|
117
|
+
remaining = _login_rate_limiter.remaining_seconds(client_ip)
|
|
118
|
+
error_msg = "Invalid email or password. Please try again."
|
|
119
|
+
if _login_rate_limiter.is_rate_limited(client_ip):
|
|
120
|
+
error_msg = (
|
|
121
|
+
f"Too many failed attempts. Try again in {remaining} seconds."
|
|
122
|
+
)
|
|
123
|
+
return HTMLResponse(
|
|
124
|
+
template.render(
|
|
125
|
+
{
|
|
126
|
+
"request": request,
|
|
127
|
+
"error": error_msg,
|
|
128
|
+
"csrf_token": csrf_token,
|
|
129
|
+
}
|
|
130
|
+
),
|
|
131
|
+
status_code=status.HTTP_200_OK,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.post("/logout")
|
|
136
|
+
async def logout_post(
|
|
137
|
+
request: Request,
|
|
138
|
+
session_payload: dict[str, Any] | None = Depends(get_session),
|
|
139
|
+
_csrf: bool = Depends(require_csrf_token),
|
|
140
|
+
) -> RedirectResponse:
|
|
141
|
+
"""POST /admin/logout — clear session and redirect to login."""
|
|
142
|
+
if session_payload is not None:
|
|
143
|
+
auth_backend = request.app.state.admin_auth_backend
|
|
144
|
+
if hasattr(auth_backend, "on_logout"):
|
|
145
|
+
await auth_backend.on_logout(session_payload.get("user_id"))
|
|
146
|
+
|
|
147
|
+
session_backend = request.app.state.admin_session_backend
|
|
148
|
+
samesite = getattr(
|
|
149
|
+
request.app.state.admin_state, "session_samesite", "strict"
|
|
150
|
+
)
|
|
151
|
+
response = RedirectResponse(
|
|
152
|
+
url="/admin/login", status_code=status.HTTP_302_FOUND
|
|
153
|
+
)
|
|
154
|
+
response.delete_cookie(
|
|
155
|
+
key=session_backend.cookie_name,
|
|
156
|
+
path="/",
|
|
157
|
+
secure=session_backend.secure,
|
|
158
|
+
httponly=True,
|
|
159
|
+
samesite=samesite,
|
|
160
|
+
)
|
|
161
|
+
response.delete_cookie(
|
|
162
|
+
key=CSRF_COOKIE_NAME,
|
|
163
|
+
path="/",
|
|
164
|
+
)
|
|
165
|
+
return response
|
fastapi_admin_kit/cli.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""CLI commands for FastAPI Admin Kit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve_database_url(url: str | None = None) -> str:
|
|
12
|
+
"""Resolve database URL from argument, env var, or default."""
|
|
13
|
+
if url:
|
|
14
|
+
return url
|
|
15
|
+
env_url = os.getenv("DATABASE_URL")
|
|
16
|
+
if env_url:
|
|
17
|
+
return env_url
|
|
18
|
+
print(
|
|
19
|
+
"Error: --database-url not specified and DATABASE_URL env var not set."
|
|
20
|
+
)
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _create_superuser(args: argparse.Namespace) -> None:
|
|
25
|
+
"""Create a superuser."""
|
|
26
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
27
|
+
from sqlalchemy.orm import sessionmaker
|
|
28
|
+
|
|
29
|
+
from fastapi_admin_kit.auth.backend import pwd_context
|
|
30
|
+
from fastapi_admin_kit.auth.models import AdminUser
|
|
31
|
+
from fastapi_admin_kit.models.base import Base
|
|
32
|
+
|
|
33
|
+
database_url = _resolve_database_url(args.database_url)
|
|
34
|
+
engine = create_async_engine(database_url)
|
|
35
|
+
|
|
36
|
+
async with engine.begin() as conn:
|
|
37
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
38
|
+
|
|
39
|
+
async_session = sessionmaker(
|
|
40
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async with async_session() as session:
|
|
44
|
+
from sqlalchemy import select
|
|
45
|
+
|
|
46
|
+
result = await session.execute(
|
|
47
|
+
select(AdminUser).where(AdminUser.email == args.email)
|
|
48
|
+
)
|
|
49
|
+
existing = result.scalar_one_or_none()
|
|
50
|
+
if existing:
|
|
51
|
+
print(f"Error: User with email '{args.email}' already exists.")
|
|
52
|
+
await engine.dispose()
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
hashed_password = pwd_context.hash(args.password)
|
|
56
|
+
user = AdminUser(
|
|
57
|
+
email=args.email,
|
|
58
|
+
hashed_password=hashed_password,
|
|
59
|
+
full_name=args.name or "",
|
|
60
|
+
is_superuser=True,
|
|
61
|
+
is_active=True,
|
|
62
|
+
)
|
|
63
|
+
session.add(user)
|
|
64
|
+
await session.commit()
|
|
65
|
+
await session.refresh(user)
|
|
66
|
+
|
|
67
|
+
print("Superuser created successfully!")
|
|
68
|
+
print(f" Email: {user.email}")
|
|
69
|
+
print(f" Name: {user.full_name or '(none)'}")
|
|
70
|
+
print(f" ID: {user.id}")
|
|
71
|
+
|
|
72
|
+
await engine.dispose()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _list_users(args: argparse.Namespace) -> None:
|
|
76
|
+
"""List all admin users."""
|
|
77
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
78
|
+
from sqlalchemy.orm import sessionmaker
|
|
79
|
+
|
|
80
|
+
from fastapi_admin_kit.auth.models import AdminUser
|
|
81
|
+
from fastapi_admin_kit.models.base import Base
|
|
82
|
+
database_url = _resolve_database_url(args.database_url)
|
|
83
|
+
engine = create_async_engine(database_url)
|
|
84
|
+
|
|
85
|
+
async with engine.begin() as conn:
|
|
86
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
87
|
+
|
|
88
|
+
async_session = sessionmaker(
|
|
89
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async with async_session() as session:
|
|
93
|
+
from sqlalchemy import select
|
|
94
|
+
|
|
95
|
+
result = await session.execute(select(AdminUser))
|
|
96
|
+
users = result.scalars().all()
|
|
97
|
+
|
|
98
|
+
if not users:
|
|
99
|
+
print("No admin users found.")
|
|
100
|
+
await engine.dispose()
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
print(
|
|
104
|
+
f"{'ID':<6} {'Email':<30} {'Name':<20} {'Superuser':<10} {'Active':<8}"
|
|
105
|
+
)
|
|
106
|
+
print("-" * 74)
|
|
107
|
+
for user in users:
|
|
108
|
+
print(
|
|
109
|
+
f"{user.id:<6} {user.email:<30} {(user.full_name or ''):<20} "
|
|
110
|
+
f"{'Yes' if user.is_superuser else 'No':<10} "
|
|
111
|
+
f"{'Yes' if user.is_active else 'No':<8}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
await engine.dispose()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _change_password(args: argparse.Namespace) -> None:
|
|
118
|
+
"""Change password for an existing user."""
|
|
119
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
120
|
+
from sqlalchemy.orm import sessionmaker
|
|
121
|
+
|
|
122
|
+
from fastapi_admin_kit.auth.backend import pwd_context
|
|
123
|
+
from fastapi_admin_kit.auth.models import AdminUser
|
|
124
|
+
from fastapi_admin_kit.models.base import Base
|
|
125
|
+
|
|
126
|
+
database_url = _resolve_database_url(args.database_url)
|
|
127
|
+
engine = create_async_engine(database_url)
|
|
128
|
+
|
|
129
|
+
async with engine.begin() as conn:
|
|
130
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
131
|
+
|
|
132
|
+
async_session = sessionmaker(
|
|
133
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
async with async_session() as session:
|
|
137
|
+
from sqlalchemy import select
|
|
138
|
+
|
|
139
|
+
result = await session.execute(
|
|
140
|
+
select(AdminUser).where(AdminUser.email == args.email)
|
|
141
|
+
)
|
|
142
|
+
user = result.scalar_one_or_none()
|
|
143
|
+
|
|
144
|
+
if not user:
|
|
145
|
+
print(f"Error: User with email '{args.email}' not found.")
|
|
146
|
+
await engine.dispose()
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
user.hashed_password = pwd_context.hash(args.password)
|
|
150
|
+
await session.commit()
|
|
151
|
+
|
|
152
|
+
print(f"Password changed successfully for '{user.email}'!")
|
|
153
|
+
|
|
154
|
+
await engine.dispose()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main() -> None:
|
|
158
|
+
"""Main CLI entry point."""
|
|
159
|
+
parser = argparse.ArgumentParser(
|
|
160
|
+
description="FastAPI Admin Kit CLI — manage admin users and database.",
|
|
161
|
+
)
|
|
162
|
+
subparsers = parser.add_subparsers(
|
|
163
|
+
dest="command", help="Available commands"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# create-superuser
|
|
167
|
+
create_parser = subparsers.add_parser(
|
|
168
|
+
"createsuperuser", help="Create a new superuser"
|
|
169
|
+
)
|
|
170
|
+
create_parser.add_argument(
|
|
171
|
+
"-e", "--email", required=True, help="Email address for the superuser"
|
|
172
|
+
)
|
|
173
|
+
create_parser.add_argument(
|
|
174
|
+
"-p", "--password", required=True, help="Password for the superuser"
|
|
175
|
+
)
|
|
176
|
+
create_parser.add_argument(
|
|
177
|
+
"-n", "--name", default="", help="Full name for the superuser"
|
|
178
|
+
)
|
|
179
|
+
create_parser.add_argument(
|
|
180
|
+
"-d",
|
|
181
|
+
"--database-url",
|
|
182
|
+
default=None,
|
|
183
|
+
help="Database URL (or set DATABASE_URL env var)",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# list-users
|
|
187
|
+
list_parser = subparsers.add_parser(
|
|
188
|
+
"users", help="List all admin users"
|
|
189
|
+
)
|
|
190
|
+
list_parser.add_argument(
|
|
191
|
+
"-d",
|
|
192
|
+
"--database-url",
|
|
193
|
+
default=None,
|
|
194
|
+
help="Database URL (or set DATABASE_URL env var)",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# changepassword
|
|
198
|
+
pw_parser = subparsers.add_parser(
|
|
199
|
+
"changepassword", help="Change password for an existing user"
|
|
200
|
+
)
|
|
201
|
+
pw_parser.add_argument(
|
|
202
|
+
"-e", "--email", required=True, help="Email of the user"
|
|
203
|
+
)
|
|
204
|
+
pw_parser.add_argument(
|
|
205
|
+
"-p", "--password", required=True, help="New password"
|
|
206
|
+
)
|
|
207
|
+
pw_parser.add_argument(
|
|
208
|
+
"-d",
|
|
209
|
+
"--database-url",
|
|
210
|
+
default=None,
|
|
211
|
+
help="Database URL (or set DATABASE_URL env var)",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
args = parser.parse_args()
|
|
215
|
+
|
|
216
|
+
if args.command is None:
|
|
217
|
+
parser.print_help()
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
if args.command == "createsuperuser":
|
|
221
|
+
asyncio.run(_create_superuser(args))
|
|
222
|
+
elif args.command == "users":
|
|
223
|
+
asyncio.run(_list_users(args))
|
|
224
|
+
elif args.command == "changepassword":
|
|
225
|
+
asyncio.run(_change_password(args))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Configuration classes for FastAPI Admin Kit."""
|
|
2
|
+
|
|
3
|
+
from fastapi_admin_kit.config.audit import AuditConfig
|
|
4
|
+
from fastapi_admin_kit.config.auth import AuthConfig
|
|
5
|
+
from fastapi_admin_kit.config.behavior import BehaviorConfig
|
|
6
|
+
from fastapi_admin_kit.config.nav import NavConfig
|
|
7
|
+
from fastapi_admin_kit.config.storage import StorageConfig
|
|
8
|
+
from fastapi_admin_kit.config.theme import ThemeConfig
|
|
9
|
+
from fastapi_admin_kit.config.ui import UIConfig
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuthConfig",
|
|
13
|
+
"AuditConfig",
|
|
14
|
+
"UIConfig",
|
|
15
|
+
"BehaviorConfig",
|
|
16
|
+
"StorageConfig",
|
|
17
|
+
"NavConfig",
|
|
18
|
+
"ThemeConfig",
|
|
19
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Audit configuration."""
|
|
2
|
+
|
|
3
|
+
from fastapi_admin_kit.exceptions import ConfigError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuditConfig:
|
|
7
|
+
"""Audit configuration."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
audit_retention_days: int = 365,
|
|
12
|
+
):
|
|
13
|
+
self.audit_retention_days = audit_retention_days
|
|
14
|
+
|
|
15
|
+
def validate_audit_config(self) -> None:
|
|
16
|
+
"""Validate audit configuration."""
|
|
17
|
+
if self.audit_retention_days < 0:
|
|
18
|
+
raise ConfigError("audit_retention_days must be non-negative")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Authentication configuration."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from fastapi_admin_kit.exceptions import ConfigError
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthConfig:
|
|
12
|
+
"""Authentication configuration."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
auth_model: type | None = None,
|
|
17
|
+
auth_backend: Any | None = None,
|
|
18
|
+
session_ttl: int = 28800,
|
|
19
|
+
session_cookie_name: str = "admin_session",
|
|
20
|
+
session_secure: bool = False,
|
|
21
|
+
superuser_emails: list[str] | None = None,
|
|
22
|
+
password_min_length: int = 12,
|
|
23
|
+
password_require_uppercase: bool = True,
|
|
24
|
+
password_require_lowercase: bool = True,
|
|
25
|
+
password_require_digit: bool = True,
|
|
26
|
+
password_require_special: bool = True,
|
|
27
|
+
session_samesite: str = "strict",
|
|
28
|
+
):
|
|
29
|
+
self.auth_model = auth_model
|
|
30
|
+
self.auth_backend = auth_backend
|
|
31
|
+
self.session_ttl = session_ttl
|
|
32
|
+
self.session_cookie_name = session_cookie_name
|
|
33
|
+
self.session_secure = session_secure
|
|
34
|
+
self.superuser_emails = superuser_emails or []
|
|
35
|
+
self.password_min_length = password_min_length
|
|
36
|
+
self.password_require_uppercase = password_require_uppercase
|
|
37
|
+
self.password_require_lowercase = password_require_lowercase
|
|
38
|
+
self.password_require_digit = password_require_digit
|
|
39
|
+
self.password_require_special = password_require_special
|
|
40
|
+
self.session_samesite = session_samesite
|
|
41
|
+
|
|
42
|
+
def validate_auth_model(self) -> None:
|
|
43
|
+
"""Validate that auth_model satisfies AdminUserProtocol."""
|
|
44
|
+
model = self.auth_model
|
|
45
|
+
if model is None:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
required_attrs = ["id", "email", "is_active", "is_superuser", "roles"]
|
|
49
|
+
missing = [attr for attr in required_attrs if not hasattr(model, attr)]
|
|
50
|
+
if missing:
|
|
51
|
+
raise ConfigError(
|
|
52
|
+
f"auth_model {model.__name__!r} does not satisfy AdminUserProtocol. "
|
|
53
|
+
f"Missing attributes: {', '.join(missing)}"
|
|
54
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Behavior configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BehaviorConfig:
|
|
9
|
+
"""Behavior configuration."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
auto_discover: bool = True,
|
|
14
|
+
dashboard_stats: list[str] | None = None,
|
|
15
|
+
dashboard_charts: bool = True,
|
|
16
|
+
dashboard_callback: str | None = None,
|
|
17
|
+
dashboard_components: list[Any] | None = None,
|
|
18
|
+
):
|
|
19
|
+
self.auto_discover = auto_discover
|
|
20
|
+
self.dashboard_stats = dashboard_stats or []
|
|
21
|
+
self.dashboard_charts = dashboard_charts
|
|
22
|
+
self.dashboard_callback = dashboard_callback
|
|
23
|
+
self.dashboard_components = dashboard_components or []
|
|
24
|
+
|
|
25
|
+
def validate_behavior_config(self) -> None:
|
|
26
|
+
"""Validate behavior configuration."""
|
|
27
|
+
pass
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Navigation configuration."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi_admin_kit.exceptions import ConfigError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NavConfig:
|
|
9
|
+
"""Navigation configuration."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
nav_groups: list[Any] | None = None,
|
|
14
|
+
sidebar_builder: Any | None = None,
|
|
15
|
+
require_tags: bool = False,
|
|
16
|
+
site_dropdown: list[dict[str, str]] | None = None,
|
|
17
|
+
dashboard_permission: str | None = None,
|
|
18
|
+
settings_permission: str | None = None,
|
|
19
|
+
):
|
|
20
|
+
self.nav_groups = nav_groups or []
|
|
21
|
+
self.sidebar_builder = sidebar_builder
|
|
22
|
+
self.require_tags = require_tags
|
|
23
|
+
self.site_dropdown = site_dropdown or []
|
|
24
|
+
self.dashboard_permission = dashboard_permission
|
|
25
|
+
self.settings_permission = settings_permission
|
|
26
|
+
|
|
27
|
+
def validate_nav_config(self) -> None:
|
|
28
|
+
"""Validate navigation configuration."""
|
|
29
|
+
if self.require_tags and not self.nav_groups:
|
|
30
|
+
raise ConfigError(
|
|
31
|
+
"require_tags=True requires nav_groups to be configured"
|
|
32
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Storage configuration."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi_admin_kit.exceptions import ConfigError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StorageConfig:
|
|
9
|
+
"""Storage configuration."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
storage: Any | None = None,
|
|
14
|
+
uploads_url: str = "/uploads",
|
|
15
|
+
):
|
|
16
|
+
self.storage = storage
|
|
17
|
+
self.uploads_url = uploads_url
|
|
18
|
+
|
|
19
|
+
def validate_storage_config(self) -> None:
|
|
20
|
+
"""Validate storage configuration."""
|
|
21
|
+
if self.uploads_url and not self.uploads_url.startswith("/"):
|
|
22
|
+
raise ConfigError("uploads_url must start with '/' or be empty")
|