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,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
@@ -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")