fastadmin 0.5.0__tar.gz → 0.7.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.
Files changed (50) hide show
  1. {fastadmin-0.5.0 → fastadmin-0.7.0}/PKG-INFO +14 -5
  2. {fastadmin-0.5.0 → fastadmin-0.7.0}/README.md +10 -3
  3. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/__init__.py +5 -0
  4. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/django/app/api.py +7 -1
  5. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/fastapi/api.py +7 -1
  6. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/fastapi/app.py +3 -1
  7. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/flask/api.py +7 -1
  8. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/helpers.py +2 -9
  9. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/service.py +78 -23
  10. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/base.py +47 -6
  11. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/orms/ponyorm.py +39 -30
  12. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/orms/sqlalchemy.py +32 -12
  13. fastadmin-0.7.0/fastadmin/models/orms/yaraorm.py +348 -0
  14. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/settings.py +13 -1
  15. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/index.min.js +131 -131
  16. {fastadmin-0.5.0 → fastadmin-0.7.0}/pyproject.toml +4 -1
  17. {fastadmin-0.5.0 → fastadmin-0.7.0}/LICENSE.md +0 -0
  18. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/__init__.py +0 -0
  19. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/exceptions.py +0 -0
  20. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/__init__.py +0 -0
  21. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/django/__init__.py +0 -0
  22. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
  23. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/django/app/urls.py +0 -0
  24. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/django/app/views.py +0 -0
  25. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
  26. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/fastapi/views.py +0 -0
  27. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/flask/__init__.py +0 -0
  28. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/flask/app.py +0 -0
  29. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/frameworks/flask/views.py +0 -0
  30. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/api/schemas.py +0 -0
  31. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/__init__.py +0 -0
  32. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/decorators.py +0 -0
  33. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/helpers.py +0 -0
  34. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/orms/__init__.py +0 -0
  35. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/orms/django.py +0 -0
  36. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/orms/tortoise.py +0 -0
  37. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/models/schemas.py +0 -0
  38. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
  39. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
  40. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
  41. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
  42. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
  43. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
  44. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
  45. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/images/favicon.png +0 -0
  46. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/images/header-logo.svg +0 -0
  47. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/images/sign-in-logo.svg +0 -0
  48. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/index.html +0 -0
  49. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/static/index.min.css +0 -0
  50. {fastadmin-0.5.0 → fastadmin-0.7.0}/fastadmin/templates/index.html +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastadmin
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin.
5
- Keywords: admin,dashboard,fastapi,flask,django,tortoise-orm,sqlalchemy,pony,admin-dashboard,django-admin
5
+ Keywords: admin,dashboard,fastapi,flask,django,tortoise-orm,sqlalchemy,pony,yara-orm,admin-dashboard,django-admin
6
6
  Author: Seva D
7
7
  Author-email: Seva D <vsdudakov@gmail.com>
8
8
  License-Expression: MIT
@@ -31,6 +31,7 @@ Requires-Dist: pony>=0.7,<1 ; extra == 'pony'
31
31
  Requires-Dist: sqlalchemy[asyncio]>=2,<3 ; extra == 'sqlalchemy'
32
32
  Requires-Dist: greenlet>=3 ; extra == 'sqlalchemy'
33
33
  Requires-Dist: tortoise-orm>=1,<2 ; extra == 'tortoise-orm'
34
+ Requires-Dist: yara-orm>=1,<2 ; extra == 'yara-orm'
34
35
  Requires-Python: >=3.12
35
36
  Project-URL: Homepage, https://vsdudakov.github.io/fastadmin/
36
37
  Project-URL: Repository, https://github.com/vsdudakov/fastadmin
@@ -42,6 +43,7 @@ Provides-Extra: flask
42
43
  Provides-Extra: pony
43
44
  Provides-Extra: sqlalchemy
44
45
  Provides-Extra: tortoise-orm
46
+ Provides-Extra: yara-orm
45
47
  Description-Content-Type: text/markdown
46
48
 
47
49
  <h1 align="center">FastAdmin — Admin Dashboard for FastAPI, Flask and Django</h1>
@@ -57,7 +59,8 @@ Description-Content-Type: text/markdown
57
59
  **FastAdmin** is an easy-to-use **admin dashboard (admin panel) for FastAPI, Flask and
58
60
  Django**, inspired by Django Admin. It gives your Python web application a
59
61
  production-ready **CRUD admin interface** in minutes — on top of **Tortoise ORM,
60
- Django ORM, SQLAlchemy or Pony ORM** — with authentication, filters, search,
62
+ Django ORM, SQLAlchemy, Pony ORM or [Yara ORM](https://github.com/vsdudakov/yara-orm)** —
63
+ with authentication, filters, search,
61
64
  inline editing, file uploads, CSV/JSON export and dashboard charts out of the box.
62
65
 
63
66
  FastAdmin is built with relationships in mind and admiration for Django Admin.
@@ -77,7 +80,8 @@ already know FastAdmin.
77
80
  - **Any web framework** — mount as a FastAPI sub-app, a Flask blueprint or
78
81
  Django urlpatterns.
79
82
  - **Any ORM** — first-class admin classes for Tortoise ORM, Django ORM,
80
- SQLAlchemy (async) and Pony ORM.
83
+ SQLAlchemy (async), Pony ORM and [Yara ORM](https://github.com/vsdudakov/yara-orm)
84
+ (a fast, Rust-engine async ORM).
81
85
  - **Authentication & permissions** — pluggable sign-in against your own user
82
86
  model, per-action permission hooks, request/user context in every admin method.
83
87
  - **Rich form widgets** — 20+ antd-based widgets (rich text, JSON, async
@@ -102,6 +106,7 @@ pip install fastadmin[fastapi,django] # FastAPI with Django ORM
102
106
  pip install fastadmin[fastapi,tortoise-orm] # FastAPI with Tortoise ORM
103
107
  pip install fastadmin[fastapi,pony] # FastAPI with Pony ORM
104
108
  pip install fastadmin[fastapi,sqlalchemy] # FastAPI with SQLAlchemy (includes greenlet)
109
+ pip install fastadmin[fastapi,yara-orm] # FastAPI with Yara ORM
105
110
  pip install fastadmin[django] # Django with Django ORM
106
111
  pip install fastadmin[django,pony] # Django with Pony ORM
107
112
  pip install fastadmin[flask,sqlalchemy] # Flask with SQLAlchemy (includes greenlet)
@@ -193,11 +198,15 @@ Runnable example apps for every framework/ORM combination are in
193
198
  ## Why FastAdmin?
194
199
 
195
200
  If you are looking for a **Django-Admin-like admin panel for FastAPI**, an
196
- **admin interface for SQLAlchemy or Tortoise ORM**, or a lightweight
201
+ **admin interface for SQLAlchemy, Tortoise ORM or
202
+ [Yara ORM](https://github.com/vsdudakov/yara-orm)**, or a lightweight
197
203
  **alternative to building a custom back office**, FastAdmin gives you a
198
204
  batteries-included, themeable admin UI without code generation, without tying
199
205
  your app to a specific framework, and without writing a single React component.
200
206
 
207
+ Pair it with [Yara ORM](https://github.com/vsdudakov/yara-orm) — our fast, async
208
+ Python ORM with a Rust engine — for a high-performance FastAPI + admin stack.
209
+
201
210
  ## Contributing
202
211
 
203
212
  Contributions are welcome — see the
@@ -11,7 +11,8 @@
11
11
  **FastAdmin** is an easy-to-use **admin dashboard (admin panel) for FastAPI, Flask and
12
12
  Django**, inspired by Django Admin. It gives your Python web application a
13
13
  production-ready **CRUD admin interface** in minutes — on top of **Tortoise ORM,
14
- Django ORM, SQLAlchemy or Pony ORM** — with authentication, filters, search,
14
+ Django ORM, SQLAlchemy, Pony ORM or [Yara ORM](https://github.com/vsdudakov/yara-orm)** —
15
+ with authentication, filters, search,
15
16
  inline editing, file uploads, CSV/JSON export and dashboard charts out of the box.
16
17
 
17
18
  FastAdmin is built with relationships in mind and admiration for Django Admin.
@@ -31,7 +32,8 @@ already know FastAdmin.
31
32
  - **Any web framework** — mount as a FastAPI sub-app, a Flask blueprint or
32
33
  Django urlpatterns.
33
34
  - **Any ORM** — first-class admin classes for Tortoise ORM, Django ORM,
34
- SQLAlchemy (async) and Pony ORM.
35
+ SQLAlchemy (async), Pony ORM and [Yara ORM](https://github.com/vsdudakov/yara-orm)
36
+ (a fast, Rust-engine async ORM).
35
37
  - **Authentication & permissions** — pluggable sign-in against your own user
36
38
  model, per-action permission hooks, request/user context in every admin method.
37
39
  - **Rich form widgets** — 20+ antd-based widgets (rich text, JSON, async
@@ -56,6 +58,7 @@ pip install fastadmin[fastapi,django] # FastAPI with Django ORM
56
58
  pip install fastadmin[fastapi,tortoise-orm] # FastAPI with Tortoise ORM
57
59
  pip install fastadmin[fastapi,pony] # FastAPI with Pony ORM
58
60
  pip install fastadmin[fastapi,sqlalchemy] # FastAPI with SQLAlchemy (includes greenlet)
61
+ pip install fastadmin[fastapi,yara-orm] # FastAPI with Yara ORM
59
62
  pip install fastadmin[django] # Django with Django ORM
60
63
  pip install fastadmin[django,pony] # Django with Pony ORM
61
64
  pip install fastadmin[flask,sqlalchemy] # Flask with SQLAlchemy (includes greenlet)
@@ -147,11 +150,15 @@ Runnable example apps for every framework/ORM combination are in
147
150
  ## Why FastAdmin?
148
151
 
149
152
  If you are looking for a **Django-Admin-like admin panel for FastAPI**, an
150
- **admin interface for SQLAlchemy or Tortoise ORM**, or a lightweight
153
+ **admin interface for SQLAlchemy, Tortoise ORM or
154
+ [Yara ORM](https://github.com/vsdudakov/yara-orm)**, or a lightweight
151
155
  **alternative to building a custom back office**, FastAdmin gives you a
152
156
  batteries-included, themeable admin UI without code generation, without tying
153
157
  your app to a specific framework, and without writing a single React component.
154
158
 
159
+ Pair it with [Yara ORM](https://github.com/vsdudakov/yara-orm) — our fast, async
160
+ Python ORM with a Rust engine — for a high-performance FastAPI + admin stack.
161
+
155
162
  ## Contributing
156
163
 
157
164
  Contributions are welcome — see the
@@ -32,6 +32,11 @@ try:
32
32
  except ModuleNotFoundError: # pragma: no cover
33
33
  logging.info("TortoiseORM is not installed") # pragma: no cover
34
34
 
35
+ try:
36
+ from fastadmin.models.orms.yaraorm import YaraOrmInlineModelAdmin, YaraOrmModelAdmin # noqa: F401
37
+ except ModuleNotFoundError: # pragma: no cover
38
+ logging.info("Yara ORM is not installed") # pragma: no cover
39
+
35
40
  # api
36
41
  from fastadmin.api.exceptions import AdminApiException # noqa: F401
37
42
 
@@ -78,7 +78,13 @@ async def sign_in(request: HttpRequest) -> JsonResponse:
78
78
  )
79
79
 
80
80
  response = JsonResponse({})
81
- response.set_cookie(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
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(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
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": str(exc)},
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(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
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
- if not id:
88
- return False
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 user_id or not await admin_model.get_obj(user_id):
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
- for k in query_params.filters:
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} {id}: {e}") from e
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
- _current_user_id, current_user = await self._get_authenticated_user(session_id)
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}: {e}") from e
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
- _current_user_id, current_user = await self._get_authenticated_user(session_id)
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
- raise AdminApiException(500, detail=f"Error getting {settings.ADMIN_USER_MODEL} {id}: {e}") from e
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
- _current_user_id, current_user = await self._get_authenticated_user(session_id)
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} {id}: {e}") from e
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
- raise AdminApiException(500, detail=f"Error uploading file for {model}: {e}") from e
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
- _current_user_id, current_user = await self._get_authenticated_user(session_id)
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
- for k in query_params.filters:
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} {id}: {e}") from e
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([await self.serialize_obj(obj, list_view=True) for obj in objs], output, cls=JSONEncoder)
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 id is None and password_fields:
765
- # save hashed password for create
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
- if condition == "in":
239
- value_set = set(value)
240
- qs = qs.filter(
241
- lambda m, f=field, vs=value_set: getattr(m, f) in vs,
242
- )
243
- continue
244
- pony_condition = "=="
245
- match condition:
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
- # Pony string filter for case-insensitive search
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))