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,128 @@
1
+ """API endpoints for role management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request
8
+ from pydantic import BaseModel
9
+ from sqlalchemy import select
10
+
11
+ from fastapi_admin_kit.api.deps import require_api_superuser
12
+ from fastapi_admin_kit.auth.models import AdminRole
13
+ from fastapi_admin_kit.db import get_db_session
14
+
15
+ router = APIRouter(prefix="/roles", tags=["api-roles"])
16
+
17
+
18
+ class RoleCreate(BaseModel):
19
+ name: str
20
+ description: str = ""
21
+
22
+
23
+ class RoleUpdate(BaseModel):
24
+ name: str | None = None
25
+ description: str | None = None
26
+
27
+
28
+ class RoleResponse(BaseModel):
29
+ id: int
30
+ name: str
31
+ description: str | None = None
32
+ user_count: int = 0
33
+
34
+
35
+ @router.get("/", response_model=list[RoleResponse])
36
+ async def list_roles(
37
+ request: Request,
38
+ user: dict[str, Any] = Depends(require_api_superuser()),
39
+ ) -> list[RoleResponse]:
40
+ """GET /api/roles/ — list all roles (superuser only)."""
41
+ db_session = get_db_session(request)
42
+ result = await db_session.execute(select(AdminRole))
43
+ roles = result.scalars().all()
44
+ return [
45
+ RoleResponse(
46
+ id=r.id,
47
+ name=r.name,
48
+ description=r.description,
49
+ user_count=len(r.users),
50
+ )
51
+ for r in roles
52
+ ]
53
+
54
+
55
+ @router.post("/", response_model=RoleResponse, status_code=201)
56
+ async def create_role(
57
+ request: Request,
58
+ body: RoleCreate,
59
+ user: dict[str, Any] = Depends(require_api_superuser()),
60
+ ) -> RoleResponse:
61
+ """POST /api/roles/ — create a role (superuser only)."""
62
+ db_session = get_db_session(request)
63
+
64
+ existing = await db_session.execute(
65
+ select(AdminRole).where(AdminRole.name == body.name)
66
+ )
67
+ if existing.scalar_one_or_none():
68
+ raise HTTPException(status_code=400, detail="Role name already exists.")
69
+
70
+ role = AdminRole(name=body.name, description=body.description)
71
+ db_session.add(role)
72
+ await db_session.flush()
73
+ await db_session.refresh(role)
74
+
75
+ return RoleResponse(
76
+ id=role.id, name=role.name, description=role.description
77
+ )
78
+
79
+
80
+ @router.put("/{role_id}", response_model=RoleResponse)
81
+ async def update_role(
82
+ request: Request,
83
+ role_id: int,
84
+ body: RoleUpdate,
85
+ user: dict[str, Any] = Depends(require_api_superuser()),
86
+ ) -> RoleResponse:
87
+ """PUT /api/roles/{id} — update a role (superuser only)."""
88
+ db_session = get_db_session(request)
89
+ role = await db_session.get(AdminRole, role_id)
90
+ if role is None:
91
+ raise HTTPException(status_code=404, detail="Role not found.")
92
+
93
+ if body.name is not None:
94
+ role.name = body.name
95
+ if body.description is not None:
96
+ role.description = body.description
97
+
98
+ await db_session.flush()
99
+ await db_session.refresh(role)
100
+
101
+ return RoleResponse(
102
+ id=role.id,
103
+ name=role.name,
104
+ description=role.description,
105
+ user_count=len(role.users),
106
+ )
107
+
108
+
109
+ @router.delete("/{role_id}", status_code=204)
110
+ async def delete_role(
111
+ request: Request,
112
+ role_id: int,
113
+ user: dict[str, Any] = Depends(require_api_superuser()),
114
+ ) -> None:
115
+ """DELETE /api/roles/{id} — delete a role (superuser only)."""
116
+ db_session = get_db_session(request)
117
+ role = await db_session.get(AdminRole, role_id)
118
+ if role is None:
119
+ raise HTTPException(status_code=404, detail="Role not found.")
120
+
121
+ if len(role.users) > 0:
122
+ raise HTTPException(
123
+ status_code=400,
124
+ detail=f"Cannot delete role. {len(role.users)} user(s) are still assigned.",
125
+ )
126
+
127
+ await db_session.delete(role)
128
+ await db_session.flush()
@@ -0,0 +1,171 @@
1
+ """Dynamic Pydantic schema generation from SQLAlchemy models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field, create_model
9
+
10
+
11
+ def _sa_type_to_python(sa_type: Any) -> type:
12
+ """Map a SQLAlchemy column type to a Python type for Pydantic."""
13
+ from sqlalchemy import (
14
+ Boolean,
15
+ Date,
16
+ DateTime,
17
+ Enum,
18
+ Float,
19
+ Integer,
20
+ LargeBinary,
21
+ Numeric,
22
+ String,
23
+ Text,
24
+ Time,
25
+ )
26
+
27
+ type_cls = type(sa_type)
28
+
29
+ if type_cls in (Integer,):
30
+ return int
31
+ if type_cls in (Float, Numeric):
32
+ return float
33
+ if type_cls in (Boolean,):
34
+ return bool
35
+ if type_cls in (String, Text):
36
+ return str
37
+ if type_cls in (DateTime,):
38
+ return datetime.datetime
39
+ if type_cls in (Date,):
40
+ return datetime.date
41
+ if type_cls in (Time,):
42
+ return datetime.time
43
+ if type_cls in (LargeBinary,):
44
+ return bytes
45
+ if type_cls is Enum:
46
+ return str
47
+ return Any
48
+
49
+
50
+ def _get_column_python_type(col: Any) -> type:
51
+ """Get the Python type for a column, handling ForeignKey."""
52
+ if col.foreign_keys:
53
+ return int
54
+ return _sa_type_to_python(col.type)
55
+
56
+
57
+ def _collect_fields(registered: Any, *, exclude_pk: bool = False) -> list[Any]:
58
+ """Collect columns to include in a schema, respecting ModelAdmin config."""
59
+ admin = registered.admin
60
+ columns = list(registered.columns)
61
+
62
+ if exclude_pk:
63
+ columns = [c for c in columns if not c.primary_key]
64
+
65
+ if admin.fields is not None:
66
+ field_names = set(admin.fields)
67
+ columns = [c for c in columns if c.name in field_names]
68
+
69
+ if admin.exclude:
70
+ columns = [c for c in columns if c.name not in admin.exclude]
71
+
72
+ return columns
73
+
74
+
75
+ def build_create_schema(registered: Any) -> type[BaseModel]:
76
+ """Build a Pydantic model for create requests.
77
+
78
+ Excludes PK, readonly fields, and server-default-only fields.
79
+ """
80
+ admin = registered.admin
81
+ readonly = set(admin.readonly_fields or [])
82
+ columns = _collect_fields(registered, exclude_pk=True)
83
+
84
+ fields: dict[str, Any] = {}
85
+ for col in columns:
86
+ if col.name in readonly:
87
+ continue
88
+ if col.server_default is not None and col.default is None:
89
+ continue
90
+
91
+ python_type = _get_column_python_type(col)
92
+ if col.nullable:
93
+ field_info = (python_type | None, Field(default=None))
94
+ else:
95
+ field_info = (python_type, Field(...))
96
+
97
+ fields[col.name] = field_info
98
+
99
+ model_name = f"{registered.verbose_name.replace(' ', '')}Create"
100
+ return create_model(model_name, __config__=None, **fields)
101
+
102
+
103
+ def build_update_schema(registered: Any) -> type[BaseModel]:
104
+ """Build a Pydantic model for update requests.
105
+
106
+ All fields optional. Excludes PK and readonly fields.
107
+ """
108
+ admin = registered.admin
109
+ readonly = set(admin.readonly_fields or [])
110
+ columns = _collect_fields(registered, exclude_pk=True)
111
+
112
+ fields: dict[str, Any] = {}
113
+ for col in columns:
114
+ if col.name in readonly:
115
+ continue
116
+
117
+ python_type = _get_column_python_type(col)
118
+ field_info = (python_type | None, Field(default=None))
119
+ fields[col.name] = field_info
120
+
121
+ model_name = f"{registered.verbose_name.replace(' ', '')}Update"
122
+ return create_model(model_name, __config__=None, **fields)
123
+
124
+
125
+ def build_response_schema(registered: Any) -> type[BaseModel]:
126
+ """Build a Pydantic model for response output."""
127
+ columns = list(registered.columns)
128
+
129
+ fields: dict[str, Any] = {}
130
+ for col in columns:
131
+ python_type = _get_column_python_type(col)
132
+ if col.nullable:
133
+ field_info = (python_type | None, Field(default=None))
134
+ else:
135
+ field_info = (python_type, Field(...))
136
+ fields[col.name] = field_info
137
+
138
+ model_name = f"{registered.verbose_name.replace(' ', '')}Response"
139
+ return create_model(model_name, __config__=None, **fields)
140
+
141
+
142
+ def build_list_response_schema(registered: Any) -> type[BaseModel]:
143
+ """Build a paginated list response schema wrapping the response schema."""
144
+ item_schema = build_response_schema(registered)
145
+
146
+ model_name = f"{registered.verbose_name.replace(' ', '')}ListResponse"
147
+ return create_model(
148
+ model_name,
149
+ items=(list[item_schema], Field(...)),
150
+ total=(int, Field(...)),
151
+ page=(int | None, Field(default=None)),
152
+ per_page=(int, Field(...)),
153
+ total_pages=(int | None, Field(default=None)),
154
+ next_cursor=(str | None, Field(default=None)),
155
+ has_next=(bool, Field(default=False)),
156
+ )
157
+
158
+
159
+ def get_or_build_schemas(registered: Any) -> dict[str, type[BaseModel]]:
160
+ """Get or generate and cache schemas for a registered model."""
161
+ if hasattr(registered, "_schemas") and registered._schemas is not None:
162
+ return registered._schemas
163
+
164
+ schemas = {
165
+ "create": build_create_schema(registered),
166
+ "update": build_update_schema(registered),
167
+ "response": build_response_schema(registered),
168
+ "list_response": build_list_response_schema(registered),
169
+ }
170
+ registered._schemas = schemas
171
+ return schemas
@@ -0,0 +1,81 @@
1
+ """Pydantic schemas for the Admin JSON API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class TokenRequest(BaseModel):
11
+ """Request body for token authentication."""
12
+
13
+ email: str
14
+ password: str
15
+
16
+
17
+ class TokenResponse(BaseModel):
18
+ """Response containing access and refresh tokens."""
19
+
20
+ access_token: str
21
+ refresh_token: str = ""
22
+ token_type: str = "bearer"
23
+ expires_in: int = 0
24
+
25
+
26
+ class RefreshRequest(BaseModel):
27
+ """Request body for token refresh."""
28
+
29
+ refresh_token: str
30
+
31
+
32
+ class RefreshResponse(BaseModel):
33
+ """Response containing refreshed access and refresh tokens."""
34
+
35
+ access_token: str
36
+ refresh_token: str
37
+ token_type: str = "bearer"
38
+ expires_in: int = 0
39
+
40
+
41
+ class PaginationParams(BaseModel):
42
+ """Pagination query parameters."""
43
+
44
+ page: int = 1
45
+ per_page: int = 25
46
+ q: str = ""
47
+ order: str = ""
48
+ after: str | None = None
49
+ before: str | None = None
50
+
51
+
52
+ class PaginatedResponse(BaseModel):
53
+ """Paginated list response."""
54
+
55
+ items: list[Any]
56
+ total: int
57
+ page: int | None = None
58
+ per_page: int
59
+ total_pages: int | None = None
60
+ next_cursor: str | None = None
61
+ has_next: bool = False
62
+
63
+
64
+ class ErrorResponse(BaseModel):
65
+ """Error response."""
66
+
67
+ detail: str
68
+
69
+
70
+ class TwoFARequiredResponse(BaseModel):
71
+ """Response when 2FA is required."""
72
+
73
+ requires_2fa: bool = True
74
+ temp_token: str
75
+
76
+
77
+ class TwoFAVerifyRequest(BaseModel):
78
+ """Request body for 2FA verification."""
79
+
80
+ temp_token: str
81
+ code: str
@@ -0,0 +1,132 @@
1
+ """Global search API — returns model and field suggestions for the topbar search bar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
8
+
9
+ router = APIRouter(tags=["api-search"])
10
+
11
+
12
+ async def require_any_auth(request: Request) -> Any:
13
+ """Accept either JWT Bearer token or session cookie.
14
+
15
+ Only validates the credential — no DB lookup (pure metadata search needs
16
+ no user object, just proof of authentication).
17
+ """
18
+ # Try JWT first (Authorization: Bearer <token>)
19
+ auth_header = request.headers.get("Authorization", "")
20
+ if auth_header.startswith("Bearer "):
21
+ from fastapi_admin_kit.api.auth import _get_secret_key, decode_access_token
22
+
23
+ token = auth_header[7:]
24
+ secret_key = _get_secret_key(request)
25
+ payload = decode_access_token(token, secret_key)
26
+ if payload is not None:
27
+ return payload
28
+
29
+ # Fall back to session cookie — just verify it decodes, no DB hit
30
+ session_backend = getattr(request.app.state, "admin_session_backend", None)
31
+ if session_backend is not None:
32
+ token = request.cookies.get(session_backend.cookie_name)
33
+ if token:
34
+ session = session_backend.decode(token)
35
+ if session and session.get("user_id") is not None:
36
+ return session
37
+
38
+ raise HTTPException(status_code=401, detail="Not authenticated.")
39
+
40
+
41
+ @router.get("/search/suggestions")
42
+ async def get_search_suggestions(
43
+ request: Request,
44
+ q: str = Query("", min_length=0),
45
+ _user: Any = Depends(require_any_auth),
46
+ ) -> dict[str, Any]:
47
+ """GET /admin/search/suggestions?q=... — return model and field suggestions.
48
+
49
+ Pure metadata scan — no DB queries, extremely fast.
50
+ Results are categorised into:
51
+ - ``model`` suggestions (verbose model name matches the query)
52
+ - ``field`` suggestions (field label / name matches the query)
53
+ """
54
+
55
+ registry = getattr(request.app.state, "admin_registry", None)
56
+ if registry is None or not q.strip():
57
+ return {"suggestions": [], "query": q}
58
+
59
+ query_lower = q.strip().lower()
60
+ suggestions: list[dict[str, Any]] = []
61
+
62
+ for registered in registry.all():
63
+ if getattr(registered.admin, "skip_auto_routes", False):
64
+ continue
65
+ table_name: str = registered.table_name
66
+ verbose_name: str = registered.verbose_name
67
+ verbose_name_plural: str = registered.verbose_name_plural
68
+ admin = registered.admin
69
+
70
+ # ── Model-level match ─────────────────────────────────────────────
71
+ if (
72
+ query_lower in verbose_name.lower()
73
+ or query_lower in verbose_name_plural.lower()
74
+ or query_lower in table_name.lower()
75
+ ):
76
+ suggestions.append(
77
+ {
78
+ "type": "model",
79
+ "model": table_name,
80
+ "label": verbose_name_plural,
81
+ "sublabel": table_name,
82
+ "url": f"/admin/{table_name}",
83
+ }
84
+ )
85
+
86
+ # ── Field-level matches ───────────────────────────────────────────
87
+ field_entries: list[tuple[str, str]] = [] # (field_name, human_label)
88
+
89
+ for col in registered.columns:
90
+ if col.primary_key:
91
+ continue
92
+ name = col.name
93
+ if name.endswith("_id"):
94
+ label = name[:-3].replace("_", " ").title()
95
+ else:
96
+ label = name.replace("_", " ").title()
97
+ field_entries.append((name, label))
98
+
99
+ for rel in getattr(registered, "relationships", []):
100
+ label = rel.name.replace("_", " ").title()
101
+ field_entries.append((rel.name, label))
102
+
103
+ extra_fields: list[str] = list(
104
+ set(
105
+ (getattr(admin, "search_fields", None) or [])
106
+ + (getattr(admin, "list_display", None) or [])
107
+ )
108
+ )
109
+ existing_names = {fe[0] for fe in field_entries}
110
+ for fname in extra_fields:
111
+ if fname not in existing_names:
112
+ field_entries.append((fname, fname.replace("_", " ").title()))
113
+
114
+ for field_name, field_label in field_entries:
115
+ if query_lower in field_label.lower() or query_lower in field_name.lower():
116
+ suggestions.append(
117
+ {
118
+ "type": "field",
119
+ "model": table_name,
120
+ "field": field_name,
121
+ "label": f"{verbose_name_plural} → {field_label}",
122
+ "sublabel": f"{table_name}.{field_name}",
123
+ "url": f"/admin/{table_name}?q={q}",
124
+ }
125
+ )
126
+
127
+ # ── Rank: model matches first, then field matches; cap at 15 ──────────
128
+ model_hits = [s for s in suggestions if s["type"] == "model"]
129
+ field_hits = [s for s in suggestions if s["type"] == "field"]
130
+ ranked = (model_hits + field_hits)[:15]
131
+
132
+ return {"suggestions": ranked, "query": q}
@@ -0,0 +1,36 @@
1
+ """Audit module — AuditLog model, SQLAlchemy event listener, diff, context, middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi_admin_kit.audit.context import (
6
+ AuditContext,
7
+ clear_audit_context,
8
+ get_audit_context,
9
+ set_audit_context,
10
+ )
11
+ from fastapi_admin_kit.audit.diff import compute_diff, snapshot
12
+ from fastapi_admin_kit.audit.event_bus import AuditEventBus
13
+ from fastapi_admin_kit.audit.events import AuditEvent
14
+ from fastapi_admin_kit.audit.listener import (
15
+ attach_audit_listener,
16
+ is_registered_model,
17
+ )
18
+ from fastapi_admin_kit.audit.logger import AuditLogger
19
+ from fastapi_admin_kit.audit.middleware import AuditContextMiddleware
20
+ from fastapi_admin_kit.audit.models import AuditLog
21
+
22
+ __all__ = [
23
+ "AuditContext",
24
+ "AuditContextMiddleware",
25
+ "AuditEvent",
26
+ "AuditEventBus",
27
+ "AuditLog",
28
+ "AuditLogger",
29
+ "attach_audit_listener",
30
+ "clear_audit_context",
31
+ "compute_diff",
32
+ "get_audit_context",
33
+ "is_registered_model",
34
+ "set_audit_context",
35
+ "snapshot",
36
+ ]
@@ -0,0 +1,62 @@
1
+ """Audit context — thread-local storage for audit information."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar
6
+ from typing import Any
7
+
8
+ # Context variable to store audit context (user, IP, user_agent, etc.)
9
+ _current_audit_context: ContextVar[dict] = ContextVar("_current_audit_context", default={})
10
+
11
+
12
+ def get_audit_context() -> dict:
13
+ """Get the current audit context."""
14
+ return _current_audit_context.get()
15
+
16
+
17
+ def set_audit_context(data: dict) -> None:
18
+ """Set the audit context (merges with existing)."""
19
+ current = get_audit_context()
20
+ current.update(data)
21
+ _current_audit_context.set(current)
22
+
23
+
24
+ def clear_audit_context() -> None:
25
+ """Clear the audit context."""
26
+ _current_audit_context.set({})
27
+
28
+
29
+ class AuditContext:
30
+ """Manages the current audit context for a request lifecycle.
31
+
32
+ Wraps the ContextVar-based functions in a class interface that
33
+ can be injected into the event bus and listener.
34
+ """
35
+
36
+ def set_context(self, user: Any = None, request: Any = None) -> None:
37
+ """Set audit context from a user object and/or request.
38
+
39
+ Args:
40
+ user: An object with 'id' and 'email' attributes (e.g. AdminUser)
41
+ request: A Starlette/FastAPI Request with client and headers
42
+ """
43
+ data: dict[str, Any] = {}
44
+ if user is not None:
45
+ data["user_id"] = getattr(user, "id", None)
46
+ data["user_email"] = getattr(user, "email", None)
47
+ if request is not None:
48
+ if request.client is not None:
49
+ data["ip_address"] = request.client.host
50
+ user_agent = request.headers.get("user-agent")
51
+ if user_agent:
52
+ data["user_agent"] = user_agent
53
+ if data:
54
+ set_audit_context(data)
55
+
56
+ def get_context(self) -> dict:
57
+ """Get the current audit context."""
58
+ return get_audit_context()
59
+
60
+ def clear_context(self) -> None:
61
+ """Clear the audit context."""
62
+ clear_audit_context()
@@ -0,0 +1,77 @@
1
+ """Audit diff utilities — snapshot and diff computation for SQLAlchemy models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import decimal
7
+ import enum
8
+ from typing import Any
9
+ from uuid import UUID
10
+
11
+ from sqlalchemy.inspection import inspect as sqlalchemy_inspect
12
+
13
+
14
+ def serialize_value(val: Any) -> Any:
15
+ """Convert a value to a JSON-serializable form.
16
+
17
+ Handles:
18
+ - datetime.datetime -> ISO string
19
+ - datetime.date -> ISO string
20
+ - datetime.time -> ISO string
21
+ - decimal.Decimal -> string
22
+ - UUID -> string
23
+ - bytes -> base64 string? (but we'll keep as is for now, or maybe hex?)
24
+ - Enum -> value or name? We'll use value.
25
+ - Other types returned as-is if they are JSON serializable (int, float, str, bool, None)
26
+ """
27
+ if val is None:
28
+ return None
29
+ if isinstance(val, (datetime.datetime, datetime.date, datetime.time)):
30
+ return val.isoformat()
31
+ if isinstance(val, decimal.Decimal):
32
+ return str(val)
33
+ if isinstance(val, UUID):
34
+ return str(val)
35
+ if isinstance(val, bytes):
36
+ # For simplicity, we'll return the hex representation.
37
+ # Alternatively, we could use base64, but hex is simpler.
38
+ return val.hex()
39
+ if isinstance(val, enum.Enum):
40
+ return val.value
41
+ # For other types, we assume they are JSON serializable (or let JSON encoder handle it)
42
+ return val
43
+
44
+
45
+ def snapshot(obj: Any) -> dict[str, Any]:
46
+ """Snapshot all mapped columns of a SQLAlchemy model instance.
47
+
48
+ Returns a dict mapping column name to serialized value.
49
+ """
50
+ if not hasattr(obj, "__table__"):
51
+ raise ValueError("Object is not a SQLAlchemy model instance")
52
+
53
+ mapper = sqlalchemy_inspect(obj.__class__)
54
+ data = {}
55
+ for column in mapper.columns:
56
+ # Skip foreign key columns that are represented by relationships?
57
+ # We'll include all columns for simplicity.
58
+ value = getattr(obj, column.key)
59
+ data[column.key] = serialize_value(value)
60
+ return data
61
+
62
+
63
+ def compute_diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
64
+ """Compute the difference between two snapshots.
65
+
66
+ Returns a dict of changed fields, each containing:
67
+ {"old": <value>, "new": <value>}
68
+ Only fields that have changed are included.
69
+ """
70
+ diff = {}
71
+ all_keys = set(before.keys()) | set(after.keys())
72
+ for key in all_keys:
73
+ old_val = before.get(key)
74
+ new_val = after.get(key)
75
+ if old_val != new_val:
76
+ diff[key] = {"old": old_val, "new": new_val}
77
+ return diff