fastadmin 0.5.0__tar.gz → 0.6.0__tar.gz
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.
- {fastadmin-0.5.0 → fastadmin-0.6.0}/PKG-INFO +1 -1
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/app/api.py +7 -1
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/fastapi/api.py +7 -1
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/fastapi/app.py +3 -1
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/flask/api.py +7 -1
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/helpers.py +2 -9
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/service.py +78 -23
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/base.py +47 -6
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/ponyorm.py +39 -30
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/sqlalchemy.py +32 -12
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/settings.py +13 -1
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/index.min.js +115 -115
- {fastadmin-0.5.0 → fastadmin-0.6.0}/pyproject.toml +1 -1
- {fastadmin-0.5.0 → fastadmin-0.6.0}/LICENSE.md +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/README.md +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/exceptions.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/app/urls.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/app/views.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/fastapi/views.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/flask/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/flask/app.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/flask/views.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/schemas.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/decorators.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/helpers.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/__init__.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/django.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/tortoise.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/schemas.py +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/images/favicon.png +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/images/header-logo.svg +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/images/sign-in-logo.svg +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/index.html +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/index.min.css +0 -0
- {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/templates/index.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastadmin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin.
|
|
5
5
|
Keywords: admin,dashboard,fastapi,flask,django,tortoise-orm,sqlalchemy,pony,admin-dashboard,django-admin
|
|
6
6
|
Author: Seva D
|
|
@@ -78,7 +78,13 @@ async def sign_in(request: HttpRequest) -> JsonResponse:
|
|
|
78
78
|
)
|
|
79
79
|
|
|
80
80
|
response = JsonResponse({})
|
|
81
|
-
response.set_cookie(
|
|
81
|
+
response.set_cookie(
|
|
82
|
+
settings.ADMIN_SESSION_ID_KEY,
|
|
83
|
+
value=session_id,
|
|
84
|
+
httponly=True,
|
|
85
|
+
secure=settings.ADMIN_SESSION_COOKIE_SECURE,
|
|
86
|
+
samesite=settings.ADMIN_SESSION_COOKIE_SAMESITE.capitalize(),
|
|
87
|
+
)
|
|
82
88
|
return response
|
|
83
89
|
|
|
84
90
|
except AdminApiException as e:
|
|
@@ -49,7 +49,13 @@ async def sign_in(
|
|
|
49
49
|
request=request,
|
|
50
50
|
)
|
|
51
51
|
|
|
52
|
-
response.set_cookie(
|
|
52
|
+
response.set_cookie(
|
|
53
|
+
settings.ADMIN_SESSION_ID_KEY,
|
|
54
|
+
value=session_id,
|
|
55
|
+
httponly=True,
|
|
56
|
+
secure=settings.ADMIN_SESSION_COOKIE_SECURE,
|
|
57
|
+
samesite=settings.ADMIN_SESSION_COOKIE_SAMESITE, # ty: ignore[invalid-argument-type]
|
|
58
|
+
)
|
|
53
59
|
except AdminApiException as e:
|
|
54
60
|
raise HTTPException(e.status_code, detail=e.detail) from None
|
|
55
61
|
|
|
@@ -25,7 +25,9 @@ app.include_router(views_router)
|
|
|
25
25
|
|
|
26
26
|
@app.exception_handler(Exception)
|
|
27
27
|
async def exception_handler(_: Request, exc: Exception):
|
|
28
|
+
# Log the real error server-side but never leak internals to the client.
|
|
29
|
+
logger.error("Unhandled admin error: %s", exc)
|
|
28
30
|
return JSONResponse(
|
|
29
31
|
status_code=500,
|
|
30
|
-
content={"exception":
|
|
32
|
+
content={"exception": "Internal server error."},
|
|
31
33
|
)
|
|
@@ -42,7 +42,13 @@ async def sign_in() -> Response:
|
|
|
42
42
|
request=request,
|
|
43
43
|
)
|
|
44
44
|
response = make_response({})
|
|
45
|
-
response.set_cookie(
|
|
45
|
+
response.set_cookie(
|
|
46
|
+
settings.ADMIN_SESSION_ID_KEY,
|
|
47
|
+
value=session_id,
|
|
48
|
+
httponly=True,
|
|
49
|
+
secure=settings.ADMIN_SESSION_COOKIE_SECURE,
|
|
50
|
+
samesite=settings.ADMIN_SESSION_COOKIE_SAMESITE.capitalize(),
|
|
51
|
+
)
|
|
46
52
|
return response
|
|
47
53
|
except AdminApiException as e:
|
|
48
54
|
http_exception = HTTPException(e.detail)
|
|
@@ -84,15 +84,8 @@ def is_valid_id(id: UUID | int | str) -> bool:
|
|
|
84
84
|
:return: True if id is a valid id, False otherwise.
|
|
85
85
|
"""
|
|
86
86
|
if isinstance(id, str):
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if is_valid_uuid(id):
|
|
90
|
-
return True
|
|
91
|
-
try:
|
|
92
|
-
int(id)
|
|
93
|
-
return True
|
|
94
|
-
except ValueError:
|
|
95
|
-
return True # string PK (e.g. non-numeric)
|
|
87
|
+
# Any non-empty string is a valid PK (UUID, numeric, or opaque string PK).
|
|
88
|
+
return bool(id)
|
|
96
89
|
if is_valid_uuid(str(id)):
|
|
97
90
|
return True
|
|
98
91
|
try:
|
|
@@ -75,13 +75,47 @@ async def get_user_id_from_session_id(session_id: str | None) -> UUID | int | No
|
|
|
75
75
|
if not user_id:
|
|
76
76
|
return None
|
|
77
77
|
|
|
78
|
-
if not
|
|
78
|
+
if not await admin_model.get_obj(user_id):
|
|
79
79
|
return None
|
|
80
80
|
|
|
81
81
|
return user_id
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
# Filter lookups a request may use. Anything else (e.g. regex, startswith, or a
|
|
85
|
+
# relation-spanning "groups__name__icontains") is rejected so it cannot become a
|
|
86
|
+
# side-channel oracle over columns the admin never displays.
|
|
87
|
+
ALLOWED_FILTER_CONDITIONS = frozenset({"exact", "in", "lt", "lte", "gt", "gte", "contains", "icontains"})
|
|
88
|
+
|
|
89
|
+
|
|
84
90
|
class ApiService:
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _clamp_query_limits(query_params: ListQuerySchema) -> None:
|
|
93
|
+
"""Bound offset/limit so a crafted request cannot dump/DoS a table."""
|
|
94
|
+
if query_params.offset is None or query_params.offset < 0:
|
|
95
|
+
query_params.offset = 0
|
|
96
|
+
max_limit = settings.ADMIN_QUERY_MAX_LIMIT
|
|
97
|
+
if query_params.limit is None or query_params.limit < 0 or query_params.limit > max_limit:
|
|
98
|
+
query_params.limit = max_limit
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _validate_filters(admin_model: Any, filters: dict, exclude_filter_fields: tuple, fields: set) -> None:
|
|
102
|
+
"""Validate filter keys against an allowlist and permitted lookups.
|
|
103
|
+
|
|
104
|
+
The filterable field set is ``list_filter`` when the admin defines it,
|
|
105
|
+
otherwise the serialized field set; the lookup suffix must be one of
|
|
106
|
+
ALLOWED_FILTER_CONDITIONS.
|
|
107
|
+
"""
|
|
108
|
+
list_filter = getattr(admin_model, "list_filter", ())
|
|
109
|
+
allowlist = set(list_filter) if list_filter else fields
|
|
110
|
+
for k in filters:
|
|
111
|
+
if k in exclude_filter_fields:
|
|
112
|
+
continue
|
|
113
|
+
field, _, condition = k.partition("__")
|
|
114
|
+
if condition and condition not in ALLOWED_FILTER_CONDITIONS:
|
|
115
|
+
raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
|
|
116
|
+
if field not in allowlist:
|
|
117
|
+
raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
|
|
118
|
+
|
|
85
119
|
@staticmethod
|
|
86
120
|
def _bind_admin_context(
|
|
87
121
|
admin_model: ModelAdmin | InlineModelAdmin | Any, request: Any | None, user: Any | None
|
|
@@ -102,6 +136,19 @@ class ApiService:
|
|
|
102
136
|
)
|
|
103
137
|
return current_user_id, current_user
|
|
104
138
|
|
|
139
|
+
@staticmethod
|
|
140
|
+
async def _require_permission(admin_model: Any, permission: str, user_id: UUID | int | None) -> None:
|
|
141
|
+
"""Enforce a model-admin permission hook server-side.
|
|
142
|
+
|
|
143
|
+
The has_*_permission hooks gate the UI, but every mutating endpoint must
|
|
144
|
+
also enforce them so a crafted request cannot bypass a disabled action.
|
|
145
|
+
"""
|
|
146
|
+
check = getattr(admin_model, permission, None)
|
|
147
|
+
if check is None:
|
|
148
|
+
return
|
|
149
|
+
if not await check(user_id=user_id):
|
|
150
|
+
raise AdminApiException(403, detail="You do not have permission to perform this action.")
|
|
151
|
+
|
|
105
152
|
async def sign_in(
|
|
106
153
|
self,
|
|
107
154
|
session_id: str | None,
|
|
@@ -167,6 +214,7 @@ class ApiService:
|
|
|
167
214
|
offset=offset,
|
|
168
215
|
limit=limit,
|
|
169
216
|
)
|
|
217
|
+
self._clamp_query_limits(query_params)
|
|
170
218
|
|
|
171
219
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
172
220
|
if not admin_model:
|
|
@@ -184,12 +232,7 @@ class ApiService:
|
|
|
184
232
|
exclude_filter_fields = ("search", "sort_by", "offset", "limit")
|
|
185
233
|
query_filters: dict[tuple[str, str], bool | str | None | list] | None = None
|
|
186
234
|
if query_params.filters:
|
|
187
|
-
|
|
188
|
-
if k in exclude_filter_fields:
|
|
189
|
-
continue
|
|
190
|
-
field = k.split("__", 1)[0]
|
|
191
|
-
if field not in fields:
|
|
192
|
-
raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
|
|
235
|
+
self._validate_filters(admin_model, query_params.filters, exclude_filter_fields, fields)
|
|
193
236
|
query_filters = {
|
|
194
237
|
sanitize_filter_key(k, admin_model.get_model_fields_with_widget_types()): sanitize_filter_value(v)
|
|
195
238
|
for k, v in query_params.filters.items()
|
|
@@ -235,7 +278,7 @@ class ApiService:
|
|
|
235
278
|
obj = await admin_model.get_obj(id)
|
|
236
279
|
except Exception as e:
|
|
237
280
|
logger.error("Error getting %s %s: %s", model, id, e)
|
|
238
|
-
raise AdminApiException(500, detail=f"Error getting {model}
|
|
281
|
+
raise AdminApiException(500, detail=f"Error getting {model}.") from e
|
|
239
282
|
if not obj:
|
|
240
283
|
raise AdminApiException(404, detail=f"{model} not found.")
|
|
241
284
|
return obj
|
|
@@ -247,17 +290,20 @@ class ApiService:
|
|
|
247
290
|
payload: dict,
|
|
248
291
|
request: Any | None = None,
|
|
249
292
|
) -> dict:
|
|
250
|
-
|
|
293
|
+
current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
251
294
|
|
|
252
295
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
253
296
|
if not admin_model:
|
|
254
297
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
255
298
|
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
299
|
+
await self._require_permission(admin_model, "has_add_permission", current_user_id)
|
|
256
300
|
try:
|
|
257
301
|
return await admin_model.save_model(None, payload) # ty: ignore[invalid-return-type]
|
|
302
|
+
except AdminApiException:
|
|
303
|
+
raise
|
|
258
304
|
except Exception as e:
|
|
259
305
|
logger.error("Error adding %s: %s", model, e)
|
|
260
|
-
raise AdminApiException(500, detail=f"Error adding {model}
|
|
306
|
+
raise AdminApiException(500, detail=f"Error adding {model}.") from e
|
|
261
307
|
|
|
262
308
|
async def change_password(
|
|
263
309
|
self,
|
|
@@ -266,13 +312,19 @@ class ApiService:
|
|
|
266
312
|
payload: dict,
|
|
267
313
|
request: Any | None = None,
|
|
268
314
|
) -> None:
|
|
269
|
-
|
|
315
|
+
current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
270
316
|
|
|
271
317
|
admin_model = get_admin_model(settings.ADMIN_USER_MODEL)
|
|
272
318
|
if not admin_model:
|
|
273
319
|
raise AdminApiException(404, detail=f"{settings.ADMIN_USER_MODEL} model is not registered.")
|
|
274
320
|
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
275
321
|
|
|
322
|
+
# Users may change their own password; changing anyone else's requires the
|
|
323
|
+
# user-model change permission. Without this a signed-in user could reset a
|
|
324
|
+
# superuser's password (account takeover).
|
|
325
|
+
if str(id) != str(current_user_id):
|
|
326
|
+
await self._require_permission(admin_model, "has_change_permission", current_user_id)
|
|
327
|
+
|
|
276
328
|
payload = ChangePasswordInputSchema(**payload)
|
|
277
329
|
if payload.password != payload.confirm_password:
|
|
278
330
|
raise AdminApiException(422, detail="Passwords do not match")
|
|
@@ -285,7 +337,8 @@ class ApiService:
|
|
|
285
337
|
try:
|
|
286
338
|
user = await admin_model.get_obj(id)
|
|
287
339
|
except Exception as e:
|
|
288
|
-
|
|
340
|
+
logger.error("Error getting %s %s: %s", settings.ADMIN_USER_MODEL, id, e)
|
|
341
|
+
raise AdminApiException(500, detail=f"Error getting {settings.ADMIN_USER_MODEL}.") from e
|
|
289
342
|
if not user:
|
|
290
343
|
raise AdminApiException(404, detail=f"{settings.ADMIN_USER_MODEL} not found.")
|
|
291
344
|
|
|
@@ -306,18 +359,21 @@ class ApiService:
|
|
|
306
359
|
payload: dict,
|
|
307
360
|
request: Any | None = None,
|
|
308
361
|
) -> dict:
|
|
309
|
-
|
|
362
|
+
current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
310
363
|
|
|
311
364
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
312
365
|
if not admin_model:
|
|
313
366
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
314
367
|
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
368
|
+
await self._require_permission(admin_model, "has_change_permission", current_user_id)
|
|
315
369
|
|
|
316
370
|
try:
|
|
317
371
|
obj = await admin_model.save_model(id, payload)
|
|
372
|
+
except AdminApiException:
|
|
373
|
+
raise
|
|
318
374
|
except Exception as e:
|
|
319
375
|
logger.error("Error changing %s %s: %s", model, id, e)
|
|
320
|
-
raise AdminApiException(500, detail=f"Error changing {model}
|
|
376
|
+
raise AdminApiException(500, detail=f"Error changing {model}.") from e
|
|
321
377
|
if not obj:
|
|
322
378
|
raise AdminApiException(404, detail=f"{model} not found.")
|
|
323
379
|
return obj
|
|
@@ -358,7 +414,8 @@ class ApiService:
|
|
|
358
414
|
except AdminApiException:
|
|
359
415
|
raise
|
|
360
416
|
except Exception as e:
|
|
361
|
-
|
|
417
|
+
logger.error("Error uploading file for %s: %s", model, e)
|
|
418
|
+
raise AdminApiException(500, detail=f"Error uploading file for {model}.") from e
|
|
362
419
|
|
|
363
420
|
async def export(
|
|
364
421
|
self,
|
|
@@ -370,7 +427,7 @@ class ApiService:
|
|
|
370
427
|
filters: dict | None = None,
|
|
371
428
|
request: Any | None = None,
|
|
372
429
|
) -> tuple[str, str, StringIO | BytesIO | None]:
|
|
373
|
-
|
|
430
|
+
current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
374
431
|
|
|
375
432
|
query_params = ListQuerySchema(
|
|
376
433
|
search=search,
|
|
@@ -379,11 +436,13 @@ class ApiService:
|
|
|
379
436
|
offset=payload.offset,
|
|
380
437
|
limit=payload.limit,
|
|
381
438
|
)
|
|
439
|
+
self._clamp_query_limits(query_params)
|
|
382
440
|
|
|
383
441
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
384
442
|
if not admin_model:
|
|
385
443
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
386
444
|
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
445
|
+
await self._require_permission(admin_model, "has_export_permission", current_user_id)
|
|
387
446
|
|
|
388
447
|
# validations
|
|
389
448
|
fields = set(admin_model.get_fields_for_serialize())
|
|
@@ -396,12 +455,7 @@ class ApiService:
|
|
|
396
455
|
exclude_filter_fields = ("search", "sort_by", "offset", "limit")
|
|
397
456
|
query_filters: dict[tuple[str, str], bool | str | None | list] | None = None
|
|
398
457
|
if query_params.filters:
|
|
399
|
-
|
|
400
|
-
if k in exclude_filter_fields:
|
|
401
|
-
continue
|
|
402
|
-
field = k.split("__", 1)[0]
|
|
403
|
-
if field not in fields:
|
|
404
|
-
raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
|
|
458
|
+
self._validate_filters(admin_model, query_params.filters, exclude_filter_fields, fields)
|
|
405
459
|
query_filters = {
|
|
406
460
|
sanitize_filter_key(k, admin_model.get_model_fields_with_widget_types()): sanitize_filter_value(v)
|
|
407
461
|
for k, v in query_params.filters.items()
|
|
@@ -450,6 +504,7 @@ class ApiService:
|
|
|
450
504
|
if not admin_model:
|
|
451
505
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
452
506
|
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
507
|
+
await self._require_permission(admin_model, "has_delete_permission", current_user_id)
|
|
453
508
|
|
|
454
509
|
if str(current_user_id) == str(id) and model == settings.ADMIN_USER_MODEL:
|
|
455
510
|
raise AdminApiException(403, detail="You cannot delete yourself.")
|
|
@@ -457,7 +512,7 @@ class ApiService:
|
|
|
457
512
|
await admin_model.delete_model(id)
|
|
458
513
|
except Exception as e:
|
|
459
514
|
logger.error("Error deleting %s %s: %s", model, id, e)
|
|
460
|
-
raise AdminApiException(500, detail=f"Error deleting {model}
|
|
515
|
+
raise AdminApiException(500, detail=f"Error deleting {model}.") from e
|
|
461
516
|
return id
|
|
462
517
|
|
|
463
518
|
async def action(
|
|
@@ -16,6 +16,21 @@ from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
|
|
|
16
16
|
|
|
17
17
|
Model = Any
|
|
18
18
|
|
|
19
|
+
# Leading characters a spreadsheet interprets as the start of a formula.
|
|
20
|
+
_CSV_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _neutralize_csv_value(value: Any) -> Any:
|
|
24
|
+
"""Prevent CSV formula injection in exported data.
|
|
25
|
+
|
|
26
|
+
A cell like ``=HYPERLINK(...)`` or ``=cmd|...`` is executed by Excel/Sheets
|
|
27
|
+
when the export is opened. Prefix any string starting with a formula trigger
|
|
28
|
+
with a single quote so it is treated as text; non-strings pass through.
|
|
29
|
+
"""
|
|
30
|
+
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_PREFIXES:
|
|
31
|
+
return "'" + value
|
|
32
|
+
return value
|
|
33
|
+
|
|
19
34
|
|
|
20
35
|
class BaseModelAdmin:
|
|
21
36
|
"""Base class for model admin"""
|
|
@@ -358,6 +373,24 @@ class BaseModelAdmin:
|
|
|
358
373
|
fields_for_serialize |= set(self.list_display)
|
|
359
374
|
return fields_for_serialize
|
|
360
375
|
|
|
376
|
+
def get_writable_field_names(self) -> set[str]:
|
|
377
|
+
"""Model field names the admin form is allowed to write.
|
|
378
|
+
|
|
379
|
+
Enforced server-side in :meth:`save_model` so a crafted payload cannot
|
|
380
|
+
set fields hidden by ``fields``/``exclude`` or marked ``readonly_fields``
|
|
381
|
+
(e.g. ``is_superuser``). ``list_display``-only computed columns are not
|
|
382
|
+
included since they are not real model fields.
|
|
383
|
+
|
|
384
|
+
:return: A set of writable model field names.
|
|
385
|
+
"""
|
|
386
|
+
writable = {field.name for field in self.get_model_fields_with_widget_types()}
|
|
387
|
+
if self.fields:
|
|
388
|
+
writable &= set(self.fields)
|
|
389
|
+
if self.exclude:
|
|
390
|
+
writable -= set(self.exclude)
|
|
391
|
+
writable -= set(self.readonly_fields)
|
|
392
|
+
return writable
|
|
393
|
+
|
|
361
394
|
def resolve_sort_by(self, sort_by: str) -> str:
|
|
362
395
|
"""Resolve sort_by to the actual ORM ordering expression.
|
|
363
396
|
|
|
@@ -523,18 +556,19 @@ class BaseModelAdmin:
|
|
|
523
556
|
"""
|
|
524
557
|
fields = self.get_model_fields_with_widget_types(with_m2m=False)
|
|
525
558
|
m2m_fields = self.get_model_fields_with_widget_types(with_m2m=True)
|
|
559
|
+
writable = self.get_writable_field_names()
|
|
526
560
|
|
|
527
561
|
fields_payload = {
|
|
528
562
|
field.column_name: self.deserialize_value(field, payload[field.name])
|
|
529
563
|
for field in fields
|
|
530
|
-
if field.name in payload
|
|
564
|
+
if field.name in payload and field.name in writable
|
|
531
565
|
}
|
|
532
566
|
obj = await self.orm_save_obj(id, fields_payload)
|
|
533
567
|
if not obj:
|
|
534
568
|
return None
|
|
535
569
|
|
|
536
570
|
for m2m_field in m2m_fields:
|
|
537
|
-
if m2m_field.name in payload:
|
|
571
|
+
if m2m_field.name in payload and m2m_field.name in writable:
|
|
538
572
|
await self.orm_save_m2m_ids(obj, m2m_field.column_name, payload[m2m_field.name])
|
|
539
573
|
|
|
540
574
|
return await self._serialize_obj_after_save(obj)
|
|
@@ -630,7 +664,7 @@ class BaseModelAdmin:
|
|
|
630
664
|
writer.writeheader()
|
|
631
665
|
for obj in objs:
|
|
632
666
|
obj_dict = await self.serialize_obj(obj, list_view=True)
|
|
633
|
-
obj_dict = {k: v for k, v in obj_dict.items() if k in export_fields}
|
|
667
|
+
obj_dict = {k: _neutralize_csv_value(v) for k, v in obj_dict.items() if k in export_fields}
|
|
634
668
|
writer.writerow(obj_dict)
|
|
635
669
|
output.seek(0)
|
|
636
670
|
return output
|
|
@@ -643,8 +677,13 @@ class BaseModelAdmin:
|
|
|
643
677
|
except TypeError:
|
|
644
678
|
return str(o)
|
|
645
679
|
|
|
680
|
+
# Emit the same column set as CSV so the two formats are consistent.
|
|
681
|
+
rows = []
|
|
682
|
+
for obj in objs:
|
|
683
|
+
obj_dict = await self.serialize_obj(obj, list_view=True)
|
|
684
|
+
rows.append({k: v for k, v in obj_dict.items() if k in export_fields})
|
|
646
685
|
output = StringIO()
|
|
647
|
-
json.dump(
|
|
686
|
+
json.dump(rows, output, cls=JSONEncoder)
|
|
648
687
|
output.seek(0)
|
|
649
688
|
return output
|
|
650
689
|
case _:
|
|
@@ -761,8 +800,10 @@ class ModelAdmin(BaseModelAdmin):
|
|
|
761
800
|
obj = await super().save_model(id, payload)
|
|
762
801
|
fields = self.get_model_fields_with_widget_types(with_m2m=False)
|
|
763
802
|
password_fields = [field.name for field in fields if field.form_widget_type == WidgetType.PasswordInput]
|
|
764
|
-
if obj and
|
|
765
|
-
#
|
|
803
|
+
if obj and password_fields:
|
|
804
|
+
# Hash the submitted password via change_password on both create and
|
|
805
|
+
# edit. On edit the raw value was written by the base save_model, so
|
|
806
|
+
# hashing here overwrites it — the column never keeps a plaintext value.
|
|
766
807
|
pk_name = self.get_model_pk_name(self.model_cls)
|
|
767
808
|
pk = obj[pk_name]
|
|
768
809
|
password_values = [payload[field] for field in password_fields if field in payload]
|
|
@@ -12,6 +12,32 @@ from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
|
|
|
12
12
|
from fastadmin.settings import settings
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
def _pony_filter_expr(condition: str, field: str) -> str:
|
|
16
|
+
"""Build a Pony filter string that references the value via the local `fval`.
|
|
17
|
+
|
|
18
|
+
The caller binds ``fval`` (and ``fval.lower()`` for icontains) as a local so
|
|
19
|
+
Pony resolves it as a query parameter. ``field`` is a validated/allowlisted
|
|
20
|
+
path, so interpolating it is safe; the user value is never interpolated.
|
|
21
|
+
"""
|
|
22
|
+
match condition:
|
|
23
|
+
case "in":
|
|
24
|
+
return f"m.{field} in fval"
|
|
25
|
+
case "lte":
|
|
26
|
+
return f"m.{field} <= fval"
|
|
27
|
+
case "gte":
|
|
28
|
+
return f"m.{field} >= fval"
|
|
29
|
+
case "lt":
|
|
30
|
+
return f"m.{field} < fval"
|
|
31
|
+
case "gt":
|
|
32
|
+
return f"m.{field} > fval"
|
|
33
|
+
case "contains":
|
|
34
|
+
return f"fval in m.{field}"
|
|
35
|
+
case "icontains":
|
|
36
|
+
return f"fval.lower() in m.{field}.lower()"
|
|
37
|
+
case _: # exact
|
|
38
|
+
return f"m.{field} == fval"
|
|
39
|
+
|
|
40
|
+
|
|
15
41
|
class PonyORMMixin:
|
|
16
42
|
@staticmethod
|
|
17
43
|
def get_model_pk_name(orm_model_cls: Any) -> str:
|
|
@@ -235,42 +261,25 @@ class PonyORMMixin:
|
|
|
235
261
|
model_pk_name = self.get_model_pk_name(self.model_cls)
|
|
236
262
|
if field.endswith(f"_{model_pk_name}"):
|
|
237
263
|
field = field.replace(f"_{model_pk_name}", f".{model_pk_name}")
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
case "lte":
|
|
247
|
-
pony_condition = ">="
|
|
248
|
-
case "gte":
|
|
249
|
-
pony_condition = "<="
|
|
250
|
-
case "lt":
|
|
251
|
-
pony_condition = ">"
|
|
252
|
-
case "gt":
|
|
253
|
-
pony_condition = "<"
|
|
254
|
-
case "exact":
|
|
255
|
-
pony_condition = "=="
|
|
256
|
-
case "contains":
|
|
257
|
-
pony_condition = "in"
|
|
258
|
-
case "icontains":
|
|
259
|
-
# Pony string filter: value.lower() in m.field.lower() for case-insensitive
|
|
260
|
-
filter_expr = f'"{value.lower()}" in m.{field}.lower()'
|
|
261
|
-
qs = qs.filter(filter_expr)
|
|
262
|
-
continue
|
|
263
|
-
filter_expr = f""""{value}" {pony_condition} m.{field}"""
|
|
264
|
-
qs = qs.filter(filter_expr)
|
|
264
|
+
# Bind the user-supplied value as a local (`fval`) so Pony
|
|
265
|
+
# resolves it as a query parameter. Only the field path — built
|
|
266
|
+
# from the admin's validated/allowlisted field names — is
|
|
267
|
+
# interpolated; the value is never placed into the filter string
|
|
268
|
+
# (Pony evaluates filter strings as Python expressions).
|
|
269
|
+
fval = set(value) if condition == "in" else value # noqa: F841 (referenced by Pony filter string)
|
|
270
|
+
expr = _pony_filter_expr(condition, field)
|
|
271
|
+
qs = qs.filter(expr)
|
|
265
272
|
|
|
266
273
|
search_fields = list(self.search_fields)
|
|
267
274
|
if search and search_fields:
|
|
268
275
|
ids = []
|
|
276
|
+
# Bind the user-supplied search term as a local so Pony resolves it
|
|
277
|
+
# as a parameter. Only the field path (from the admin's trusted
|
|
278
|
+
# search_fields config) is interpolated — never the search value.
|
|
279
|
+
search_term = search.lower() # noqa: F841 (referenced by Pony filter string)
|
|
269
280
|
for search_field in search_fields:
|
|
270
281
|
pony_search_field = search_field.replace("__", ".")
|
|
271
|
-
|
|
272
|
-
filter_expr = f'"{search.lower()}" in m.{pony_search_field}.lower()'
|
|
273
|
-
qs_ids = qs.filter(filter_expr)
|
|
282
|
+
qs_ids = qs.filter(f"search_term in m.{pony_search_field}.lower()")
|
|
274
283
|
objs = list(qs_ids)
|
|
275
284
|
ids += [o.id for o in objs]
|
|
276
285
|
qs = qs.filter(lambda m: m.id in set(ids))
|
|
@@ -2,7 +2,7 @@ import contextlib
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
from uuid import UUID
|
|
4
4
|
|
|
5
|
-
from sqlalchemy import BIGINT, Integer, and_, func, inspect, or_, select
|
|
5
|
+
from sqlalchemy import BIGINT, Integer, and_, func, inspect, or_, select
|
|
6
6
|
from sqlalchemy.orm import selectinload
|
|
7
7
|
|
|
8
8
|
from fastadmin.models.base import InlineModelAdmin, ModelAdmin
|
|
@@ -11,6 +11,18 @@ from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
|
|
|
11
11
|
from fastadmin.settings import settings
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _escape_like(value: Any) -> Any:
|
|
15
|
+
"""Escape LIKE/ILIKE wildcards so user input matches literally.
|
|
16
|
+
|
|
17
|
+
Without this, ``%`` and ``_`` in a filter/search value act as wildcards
|
|
18
|
+
(matching anything / any single char), letting a caller widen a match or
|
|
19
|
+
force an expensive scan. Used with ``escape="\\"`` on the like/ilike call.
|
|
20
|
+
"""
|
|
21
|
+
if not isinstance(value, str):
|
|
22
|
+
return value
|
|
23
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
24
|
+
|
|
25
|
+
|
|
14
26
|
class SqlAlchemyMixin:
|
|
15
27
|
def _build_search_condition(self, field_path: str, search: str) -> Any | None:
|
|
16
28
|
"""Build SQLAlchemy search condition for simple and nested fields.
|
|
@@ -25,7 +37,7 @@ class SqlAlchemyMixin:
|
|
|
25
37
|
return None
|
|
26
38
|
|
|
27
39
|
if len(parts) == 1:
|
|
28
|
-
return attr.ilike(f"%{search}%")
|
|
40
|
+
return attr.ilike(f"%{_escape_like(search)}%", escape="\\")
|
|
29
41
|
|
|
30
42
|
rel_property = getattr(attr, "property", None)
|
|
31
43
|
if rel_property is None:
|
|
@@ -303,11 +315,15 @@ class SqlAlchemyMixin:
|
|
|
303
315
|
:return: A tuple of list of objects and total count.
|
|
304
316
|
"""
|
|
305
317
|
|
|
306
|
-
def
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
318
|
+
def order_column(ordering_field: str):
|
|
319
|
+
# Build an ordering expression from a real Column instead of raw
|
|
320
|
+
# text() SQL, so a sort key can never reach the SQL string.
|
|
321
|
+
resolved = self._resolve_ordering_field(ordering_field)
|
|
322
|
+
descending = resolved.startswith("-")
|
|
323
|
+
column = getattr(self.model_cls, resolved.lstrip("-"), None)
|
|
324
|
+
if column is None:
|
|
325
|
+
return None
|
|
326
|
+
return column.desc() if descending else column.asc()
|
|
311
327
|
|
|
312
328
|
sessionmaker = self.get_sessionmaker()
|
|
313
329
|
async with sessionmaker() as session:
|
|
@@ -340,9 +356,9 @@ class SqlAlchemyMixin:
|
|
|
340
356
|
value = [int(x) for x in value] # ty: ignore[not-iterable]
|
|
341
357
|
q.append(model_field.in_(value))
|
|
342
358
|
case "contains":
|
|
343
|
-
q.append(model_field.like(f"%{value}%"))
|
|
359
|
+
q.append(model_field.like(f"%{_escape_like(value)}%", escape="\\"))
|
|
344
360
|
case "icontains":
|
|
345
|
-
q.append(model_field.ilike(f"%{value}%"))
|
|
361
|
+
q.append(model_field.ilike(f"%{_escape_like(value)}%", escape="\\"))
|
|
346
362
|
qs = qs.where(and_(*q))
|
|
347
363
|
|
|
348
364
|
search_fields = list(self.search_fields)
|
|
@@ -356,11 +372,15 @@ class SqlAlchemyMixin:
|
|
|
356
372
|
return [], 0
|
|
357
373
|
qs = qs.where(or_(*q))
|
|
358
374
|
|
|
375
|
+
order_columns = []
|
|
359
376
|
if sort_by:
|
|
360
|
-
|
|
377
|
+
column = order_column(sort_by)
|
|
378
|
+
if column is not None:
|
|
379
|
+
order_columns.append(column)
|
|
361
380
|
elif self.ordering:
|
|
362
|
-
|
|
363
|
-
|
|
381
|
+
order_columns = [c for c in (order_column(f) for f in self.ordering) if c is not None]
|
|
382
|
+
if order_columns:
|
|
383
|
+
qs = qs.order_by(*order_columns)
|
|
364
384
|
|
|
365
385
|
count_stmt = select(func.count()).select_from(qs.subquery())
|
|
366
386
|
total = (await session.execute(count_stmt)).scalar_one()
|
|
@@ -29,7 +29,19 @@ class Settings:
|
|
|
29
29
|
ADMIN_SESSION_ID_KEY: str = os.getenv("ADMIN_SESSION_ID_KEY", "admin_session_id")
|
|
30
30
|
|
|
31
31
|
# This value is the expired_at period (in sec) for session id.
|
|
32
|
-
ADMIN_SESSION_EXPIRED_AT: int = os.getenv("ADMIN_SESSION_EXPIRED_AT", 144000) # in sec
|
|
32
|
+
ADMIN_SESSION_EXPIRED_AT: int = int(os.getenv("ADMIN_SESSION_EXPIRED_AT", 144000)) # in sec
|
|
33
|
+
|
|
34
|
+
# Set the Secure flag on the session cookie so it is only sent over HTTPS.
|
|
35
|
+
# Enabled by default; set ADMIN_SESSION_COOKIE_SECURE=false for local HTTP dev.
|
|
36
|
+
ADMIN_SESSION_COOKIE_SECURE: bool = os.getenv("ADMIN_SESSION_COOKIE_SECURE", "true").lower() != "false"
|
|
37
|
+
|
|
38
|
+
# SameSite policy for the session cookie ("lax", "strict" or "none").
|
|
39
|
+
# "lax" blocks the cross-site POST/PATCH/DELETE requests behind CSRF.
|
|
40
|
+
ADMIN_SESSION_COOKIE_SAMESITE: str = os.getenv("ADMIN_SESSION_COOKIE_SAMESITE", "lax")
|
|
41
|
+
|
|
42
|
+
# Hard upper bound on the number of rows a single list/export request may
|
|
43
|
+
# return. Caps memory/CPU use from a crafted limit=100000000 request.
|
|
44
|
+
ADMIN_QUERY_MAX_LIMIT: int = int(os.getenv("ADMIN_QUERY_MAX_LIMIT", 1000))
|
|
33
45
|
|
|
34
46
|
# This value is the date format for JS widgets.
|
|
35
47
|
ADMIN_DATE_FORMAT: str = os.getenv("ADMIN_DATE_FORMAT", "YYYY-MM-DD")
|