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