fastadmin 0.3.3__tar.gz → 0.3.5__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 (49) hide show
  1. {fastadmin-0.3.3 → fastadmin-0.3.5}/PKG-INFO +2 -2
  2. {fastadmin-0.3.3 → fastadmin-0.3.5}/README.md +1 -1
  3. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/app/api.py +15 -1
  4. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/fastapi/api.py +26 -3
  5. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/flask/api.py +15 -1
  6. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/service.py +81 -40
  7. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/base.py +50 -3
  8. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/helpers.py +6 -0
  9. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/django.py +16 -4
  10. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/ponyorm.py +65 -3
  11. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/sqlalchemy.py +98 -3
  12. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/tortoise.py +40 -4
  13. {fastadmin-0.3.3 → fastadmin-0.3.5}/pyproject.toml +2 -1
  14. {fastadmin-0.3.3 → fastadmin-0.3.5}/LICENSE +0 -0
  15. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/__init__.py +0 -0
  16. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/__init__.py +0 -0
  17. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/exceptions.py +0 -0
  18. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/__init__.py +0 -0
  19. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/__init__.py +0 -0
  20. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
  21. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/app/urls.py +0 -0
  22. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/app/views.py +0 -0
  23. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
  24. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/fastapi/app.py +0 -0
  25. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/fastapi/views.py +0 -0
  26. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/flask/__init__.py +0 -0
  27. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/flask/app.py +0 -0
  28. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/flask/views.py +0 -0
  29. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/helpers.py +0 -0
  30. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/schemas.py +0 -0
  31. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/__init__.py +0 -0
  32. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/decorators.py +0 -0
  33. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/__init__.py +0 -0
  34. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/schemas.py +0 -0
  35. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/settings.py +0 -0
  36. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
  37. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
  38. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
  39. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
  40. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
  41. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
  42. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
  43. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/images/favicon.png +0 -0
  44. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/images/header-logo.svg +0 -0
  45. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/images/sign-in-logo.svg +0 -0
  46. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/index.html +0 -0
  47. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/index.min.css +0 -0
  48. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/index.min.js +0 -0
  49. {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/templates/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastadmin
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -42,7 +42,7 @@ Description-Content-Type: text/markdown
42
42
 
43
43
  # Admin Dashboard for FastAPI / Flask / Django
44
44
 
45
- [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://codecov.io/gh/vsdudakov/fastadmin)
45
+ [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://app.codecov.io/gh/vsdudakov/fastadmin)
46
46
  [![License](https://img.shields.io/github/license/vsdudakov/fastadmin)](https://github.com/vsdudakov/fastadmin/blob/master/LICENSE)
47
47
  [![PyPi](https://badgen.net/pypi/v/fastadmin)](https://pypi.org/project/fastadmin/)
48
48
  [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-3120/)
@@ -1,6 +1,6 @@
1
1
  # Admin Dashboard for FastAPI / Flask / Django
2
2
 
3
- [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://codecov.io/gh/vsdudakov/fastadmin)
3
+ [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://app.codecov.io/gh/vsdudakov/fastadmin)
4
4
  [![License](https://img.shields.io/github/license/vsdudakov/fastadmin)](https://github.com/vsdudakov/fastadmin/blob/master/LICENSE)
5
5
  [![PyPi](https://badgen.net/pypi/v/fastadmin)](https://pypi.org/project/fastadmin/)
6
6
  [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-3120/)
@@ -64,6 +64,7 @@ async def sign_in(request: HttpRequest) -> JsonResponse:
64
64
  session_id = await api_service.sign_in(
65
65
  request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
66
66
  payload,
67
+ request=request,
67
68
  )
68
69
 
69
70
  response = JsonResponse({})
@@ -111,7 +112,10 @@ async def me(request: HttpRequest) -> JsonResponse:
111
112
  if not user_id:
112
113
  raise AdminApiException(401, "User is not authenticated.")
113
114
  obj = await api_service.get(
114
- request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None), settings.ADMIN_USER_MODEL, user_id
115
+ request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
116
+ settings.ADMIN_USER_MODEL,
117
+ user_id,
118
+ request=request,
115
119
  )
116
120
  return JsonResponse(obj)
117
121
 
@@ -143,6 +147,7 @@ async def dashboard_widget(request: HttpRequest, model: str) -> JsonResponse:
143
147
  min_x_field=min_x_field,
144
148
  max_x_field=max_x_field,
145
149
  period_x_field=period_x_field,
150
+ request=request,
146
151
  )
147
152
  return JsonResponse(data)
148
153
  except AdminApiException as e:
@@ -182,6 +187,7 @@ async def list_objs(request: HttpRequest, model: str) -> JsonResponse:
182
187
  filters=list_filters,
183
188
  offset=offset,
184
189
  limit=limit,
190
+ request=request,
185
191
  )
186
192
  return JsonResponse(
187
193
  {
@@ -212,6 +218,7 @@ async def get(request: HttpRequest, model: str, id: UUID | int | str) -> JsonRes
212
218
  request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
213
219
  model,
214
220
  id,
221
+ request=request,
215
222
  )
216
223
  return JsonResponse(obj)
217
224
 
@@ -234,6 +241,7 @@ async def add(request: HttpRequest, model: str) -> JsonResponse:
234
241
  request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
235
242
  model,
236
243
  json.loads(request.body),
244
+ request=request,
237
245
  )
238
246
  return JsonResponse(obj)
239
247
  except AdminApiException as e:
@@ -257,6 +265,7 @@ async def change_password(request: HttpRequest, id: UUID | int | str) -> JsonRes
257
265
  request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
258
266
  id,
259
267
  json.loads(request.body),
268
+ request=request,
260
269
  )
261
270
  return JsonResponse(id, safe=False)
262
271
 
@@ -283,6 +292,7 @@ async def change(request: HttpRequest, model: str, id: UUID | int | str) -> Json
283
292
  model,
284
293
  id,
285
294
  json.loads(request.body),
295
+ request=request,
286
296
  )
287
297
  return JsonResponse(obj)
288
298
 
@@ -319,6 +329,7 @@ async def export(request: HttpRequest, model: str) -> JsonResponse:
319
329
  search=search,
320
330
  sort_by=sort_by,
321
331
  filters=list_filters,
332
+ request=request,
322
333
  )
323
334
  response = StreamingHttpResponse(stream, content_type=content_type)
324
335
  response.headers["Content-Disposition"] = f'attachment; filename="{file_name}"'
@@ -349,6 +360,7 @@ async def delete(
349
360
  request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
350
361
  model,
351
362
  id,
363
+ request=request,
352
364
  )
353
365
  return JsonResponse(deleted_id, safe=False)
354
366
 
@@ -378,6 +390,7 @@ async def action(
378
390
  model,
379
391
  action,
380
392
  payload,
393
+ request=request,
381
394
  )
382
395
  return JsonResponse({})
383
396
 
@@ -397,5 +410,6 @@ async def configuration(request: HttpRequest) -> JsonResponse:
397
410
 
398
411
  obj = await api_service.get_configuration(
399
412
  request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
413
+ request=request,
400
414
  )
401
415
  return JsonResponse(asdict(obj))
@@ -35,6 +35,7 @@ async def sign_in(
35
35
  session_id = await api_service.sign_in(
36
36
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
37
37
  payload,
38
+ request=request,
38
39
  )
39
40
 
40
41
  response.set_cookie(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
@@ -78,7 +79,10 @@ async def me(
78
79
  if not user_id:
79
80
  raise AdminApiException(401, "User is not authenticated.")
80
81
  return await api_service.get(
81
- request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None), settings.ADMIN_USER_MODEL, user_id
82
+ request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
83
+ settings.ADMIN_USER_MODEL,
84
+ user_id,
85
+ request=request,
82
86
  )
83
87
  except AdminApiException as e:
84
88
  raise HTTPException(e.status_code, detail=e.detail) from None
@@ -107,6 +111,7 @@ async def dashboard_widget(
107
111
  min_x_field=min_x_field,
108
112
  max_x_field=max_x_field,
109
113
  period_x_field=period_x_field,
114
+ request=request,
110
115
  )
111
116
  return data
112
117
  except AdminApiException as e:
@@ -146,6 +151,7 @@ async def list_objs(
146
151
  filters=list_filters,
147
152
  offset=offset,
148
153
  limit=limit,
154
+ request=request,
149
155
  )
150
156
  return {
151
157
  "total": total,
@@ -172,6 +178,7 @@ async def get(
172
178
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
173
179
  model,
174
180
  id,
181
+ request=request,
175
182
  )
176
183
  except AdminApiException as e:
177
184
  raise HTTPException(e.status_code, detail=e.detail) from None
@@ -194,6 +201,7 @@ async def add(
194
201
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
195
202
  model,
196
203
  payload,
204
+ request=request,
197
205
  )
198
206
  except AdminApiException as e:
199
207
  raise HTTPException(e.status_code, detail=e.detail) from None
@@ -212,7 +220,12 @@ async def change_password(
212
220
  :return: An object.
213
221
  """
214
222
  try:
215
- await api_service.change_password(request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None), id, payload)
223
+ await api_service.change_password(
224
+ request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
225
+ id,
226
+ payload,
227
+ request=request,
228
+ )
216
229
  return id
217
230
  except AdminApiException as e:
218
231
  raise HTTPException(e.status_code, detail=e.detail) from None
@@ -233,7 +246,13 @@ async def change(
233
246
  :return: An object.
234
247
  """
235
248
  try:
236
- return await api_service.change(request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None), model, id, payload)
249
+ return await api_service.change(
250
+ request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
251
+ model,
252
+ id,
253
+ payload,
254
+ request=request,
255
+ )
237
256
  except AdminApiException as e:
238
257
  raise HTTPException(e.status_code, detail=e.detail) from None
239
258
 
@@ -268,6 +287,7 @@ async def export(
268
287
  search=search,
269
288
  sort_by=sort_by,
270
289
  filters=list_filters,
290
+ request=request,
271
291
  )
272
292
  headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
273
293
  return StreamingResponse(
@@ -296,6 +316,7 @@ async def delete(
296
316
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
297
317
  model,
298
318
  id,
319
+ request=request,
299
320
  )
300
321
  except AdminApiException as e:
301
322
  raise HTTPException(e.status_code, detail=e.detail) from None
@@ -321,6 +342,7 @@ async def action(
321
342
  model,
322
343
  action,
323
344
  payload,
345
+ request=request,
324
346
  )
325
347
  except AdminApiException as e:
326
348
  raise HTTPException(e.status_code, detail=e.detail) from None
@@ -337,4 +359,5 @@ async def configuration(
337
359
  """
338
360
  return await api_service.get_configuration(
339
361
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
362
+ request=request,
340
363
  )
@@ -30,6 +30,7 @@ async def sign_in() -> Response:
30
30
  session_id = await api_service.sign_in(
31
31
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
32
32
  payload,
33
+ request=request,
33
34
  )
34
35
  response = make_response({})
35
36
  response.set_cookie(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
@@ -74,7 +75,10 @@ async def me() -> dict:
74
75
  if not user_id:
75
76
  raise AdminApiException(401, "User is not authenticated.")
76
77
  return await api_service.get(
77
- request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None), settings.ADMIN_USER_MODEL, user_id
78
+ request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
79
+ settings.ADMIN_USER_MODEL,
80
+ user_id,
81
+ request=request,
78
82
  )
79
83
  except AdminApiException as e:
80
84
  http_exception = HTTPException(e.detail)
@@ -104,6 +108,7 @@ async def dashboard_widget(model: str) -> dict:
104
108
  min_x_field=min_x_field,
105
109
  max_x_field=max_x_field,
106
110
  period_x_field=period_x_field,
111
+ request=request,
107
112
  )
108
113
  return data
109
114
  except AdminApiException as e:
@@ -142,6 +147,7 @@ async def list_objs(model: str) -> dict:
142
147
  filters=list_filters,
143
148
  offset=offset,
144
149
  limit=limit,
150
+ request=request,
145
151
  )
146
152
  return {
147
153
  "total": total,
@@ -174,6 +180,7 @@ async def get(model: str, id: UUID | int | str) -> dict:
174
180
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
175
181
  model,
176
182
  id,
183
+ request=request,
177
184
  )
178
185
  except AdminApiException as e:
179
186
  http_exception = HTTPException(e.detail)
@@ -195,6 +202,7 @@ async def add(model: str) -> dict:
195
202
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
196
203
  model,
197
204
  payload,
205
+ request=request,
198
206
  )
199
207
  except AdminApiException as e:
200
208
  http_exception = HTTPException(e.detail)
@@ -220,6 +228,7 @@ async def change_password(id: UUID | int | str) -> UUID | int | str:
220
228
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
221
229
  id,
222
230
  payload,
231
+ request=request,
223
232
  )
224
233
  return id
225
234
  except AdminApiException as e:
@@ -248,6 +257,7 @@ async def change(model: str, id: UUID | int | str) -> dict:
248
257
  model,
249
258
  id,
250
259
  payload,
260
+ request=request,
251
261
  )
252
262
  except AdminApiException as e:
253
263
  http_exception = HTTPException(e.detail)
@@ -283,6 +293,7 @@ async def export(model: str) -> Response:
283
293
  search=search,
284
294
  sort_by=sort_by,
285
295
  filters=list_filters,
296
+ request=request,
286
297
  )
287
298
  response = Response(stream, mimetype=content_type)
288
299
  response.headers["Content-Disposition"] = f'attachment; filename="{file_name}"'
@@ -313,6 +324,7 @@ async def delete(
313
324
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
314
325
  model,
315
326
  id,
327
+ request=request,
316
328
  )
317
329
  except AdminApiException as e:
318
330
  http_exception = HTTPException(e.detail)
@@ -340,6 +352,7 @@ async def action(
340
352
  model,
341
353
  action,
342
354
  payload,
355
+ request=request,
343
356
  )
344
357
  return {}
345
358
  except AdminApiException as e:
@@ -357,5 +370,6 @@ async def configuration() -> dict:
357
370
  """
358
371
  obj = await api_service.get_configuration(
359
372
  request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
373
+ request=request,
360
374
  )
361
375
  return asdict(obj)
@@ -36,6 +36,14 @@ from fastadmin.settings import settings
36
36
  logger = logging.getLogger(__name__)
37
37
 
38
38
 
39
+ def is_allowed_field_or_path(field: str, allowed_fields: set[str]) -> bool:
40
+ """Allow direct fields and nested paths from allowed base fields."""
41
+ if field in allowed_fields:
42
+ return True
43
+ base_field = field.split("__", 1)[0]
44
+ return base_field in allowed_fields
45
+
46
+
39
47
  def convert_id(id: str | int | UUID) -> int | UUID | None:
40
48
  """Convert the given id to int or UUID.
41
49
 
@@ -94,10 +102,31 @@ async def get_user_id_from_session_id(session_id: str | None) -> UUID | int | No
94
102
 
95
103
 
96
104
  class ApiService:
105
+ @staticmethod
106
+ def _bind_admin_context(
107
+ admin_model: ModelAdmin | InlineModelAdmin | Any, request: Any | None, user: Any | None
108
+ ) -> None:
109
+ if hasattr(admin_model, "set_context"):
110
+ admin_model.set_context(request=request, user=user)
111
+
112
+ async def _get_authenticated_user(self, session_id: str | None) -> tuple[UUID | int, Any | None]:
113
+ current_user_id = await get_user_id_from_session_id(session_id)
114
+ if not current_user_id:
115
+ raise AdminApiException(401, detail="User is not authenticated.")
116
+
117
+ admin_user_model = get_admin_model(settings.ADMIN_USER_MODEL)
118
+ current_user = (
119
+ await admin_user_model.get_obj(current_user_id)
120
+ if admin_user_model and hasattr(admin_user_model, "get_obj")
121
+ else None
122
+ )
123
+ return current_user_id, current_user
124
+
97
125
  async def sign_in(
98
126
  self,
99
127
  session_id: str | None,
100
128
  payload: SignInInputSchema,
129
+ request: Any | None = None,
101
130
  ) -> str:
102
131
  model = settings.ADMIN_USER_MODEL
103
132
  admin_model = get_admin_model(model)
@@ -110,6 +139,7 @@ class ApiService:
110
139
  else:
111
140
  authenticate_fn = sync_to_async(admin_model.authenticate) # type: ignore [arg-type]
112
141
 
142
+ self._bind_admin_context(admin_model, request=request, user=None)
113
143
  user_id = await authenticate_fn(payload.username, payload.password)
114
144
 
115
145
  if not user_id or not isinstance(user_id, int | UUID):
@@ -131,10 +161,9 @@ class ApiService:
131
161
  async def sign_out(
132
162
  self,
133
163
  session_id: str | None,
164
+ request: Any | None = None,
134
165
  ) -> bool:
135
- current_user_id = await get_user_id_from_session_id(session_id)
136
- if not current_user_id:
137
- raise AdminApiException(401, detail="User is not authenticated.")
166
+ _current_user_id, _current_user = await self._get_authenticated_user(session_id)
138
167
 
139
168
  return True
140
169
 
@@ -145,10 +174,9 @@ class ApiService:
145
174
  min_x_field: str | None = None,
146
175
  max_x_field: str | None = None,
147
176
  period_x_field: str | None = None,
177
+ request: Any | None = None,
148
178
  ) -> dict[str, str | int | float]:
149
- current_user_id = await get_user_id_from_session_id(session_id)
150
- if not current_user_id:
151
- raise AdminApiException(401, detail="User is not authenticated.")
179
+ _current_user_id, current_user = await self._get_authenticated_user(session_id)
152
180
 
153
181
  query_params = DashboardWidgetQuerySchema(
154
182
  min_x_field=min_x_field,
@@ -160,6 +188,8 @@ class ApiService:
160
188
  if not dashboard_widget_model:
161
189
  raise AdminApiException(404, detail=f"{model} model is not registered.")
162
190
 
191
+ self._bind_admin_context(dashboard_widget_model, request=request, user=current_user)
192
+
163
193
  if inspect.iscoroutinefunction(dashboard_widget_model.get_data):
164
194
  get_data = dashboard_widget_model.get_data
165
195
  else:
@@ -182,10 +212,9 @@ class ApiService:
182
212
  filters: dict | None = None,
183
213
  offset: int | None = 0,
184
214
  limit: int | None = 10,
215
+ request: Any | None = None,
185
216
  ) -> tuple[list[dict], int]:
186
- current_user_id = await get_user_id_from_session_id(session_id)
187
- if not current_user_id:
188
- raise AdminApiException(401, detail="User is not authenticated.")
217
+ _current_user_id, current_user = await self._get_authenticated_user(session_id)
189
218
 
190
219
  query_params = ListQuerySchema(
191
220
  search=search,
@@ -198,13 +227,14 @@ class ApiService:
198
227
  admin_model = get_admin_or_admin_inline_model(model)
199
228
  if not admin_model:
200
229
  raise AdminApiException(404, detail=f"{model} model is not registered.")
230
+ self._bind_admin_context(admin_model, request=request, user=current_user)
201
231
 
202
232
  # validations
203
- fields = admin_model.get_fields_for_serialize()
233
+ fields = set(admin_model.get_fields_for_serialize())
204
234
 
205
235
  if query_params.search and admin_model.search_fields:
206
236
  for field in admin_model.search_fields:
207
- if field not in fields:
237
+ if not is_allowed_field_or_path(field, fields):
208
238
  raise AdminApiException(422, detail=f"Search by {field} is not allowed")
209
239
 
210
240
  exclude_filter_fields = ("search", "sort_by", "offset", "limit")
@@ -227,7 +257,7 @@ class ApiService:
227
257
  raise AdminApiException(422, detail=f"Sort by {query_params.sort_by} is not allowed")
228
258
  elif admin_model.ordering:
229
259
  for ordering_field in admin_model.ordering:
230
- if ordering_field.strip("-") not in fields:
260
+ if not is_allowed_field_or_path(ordering_field.strip("-"), fields):
231
261
  raise AdminApiException(422, detail=f"Sort by {ordering_field} is not allowed")
232
262
 
233
263
  if admin_model.list_select_related:
@@ -248,14 +278,14 @@ class ApiService:
248
278
  session_id: str | None,
249
279
  model: str,
250
280
  id: UUID | int | str,
281
+ request: Any | None = None,
251
282
  ) -> dict:
252
- current_user_id = await get_user_id_from_session_id(session_id)
253
- if not current_user_id:
254
- raise AdminApiException(401, detail="User is not authenticated.")
283
+ _current_user_id, current_user = await self._get_authenticated_user(session_id)
255
284
 
256
285
  admin_model = get_admin_or_admin_inline_model(model)
257
286
  if not admin_model:
258
287
  raise AdminApiException(404, detail=f"{model} model is not registered.")
288
+ self._bind_admin_context(admin_model, request=request, user=current_user)
259
289
 
260
290
  try:
261
291
  obj = await admin_model.get_obj(id)
@@ -270,14 +300,14 @@ class ApiService:
270
300
  session_id: str | None,
271
301
  model: str,
272
302
  payload: dict,
303
+ request: Any | None = None,
273
304
  ) -> dict:
274
- current_user_id = await get_user_id_from_session_id(session_id)
275
- if not current_user_id:
276
- raise AdminApiException(401, detail="User is not authenticated.")
305
+ _current_user_id, current_user = await self._get_authenticated_user(session_id)
277
306
 
278
307
  admin_model = get_admin_or_admin_inline_model(model)
279
308
  if not admin_model:
280
309
  raise AdminApiException(404, detail=f"{model} model is not registered.")
310
+ self._bind_admin_context(admin_model, request=request, user=current_user)
281
311
  return await admin_model.save_model(None, payload) # type: ignore [return-value]
282
312
 
283
313
  async def change_password(
@@ -285,14 +315,14 @@ class ApiService:
285
315
  session_id: str | None,
286
316
  id: UUID | int | str,
287
317
  payload: dict,
318
+ request: Any | None = None,
288
319
  ) -> None:
289
- current_user_id = await get_user_id_from_session_id(session_id)
290
- if not current_user_id:
291
- raise AdminApiException(401, detail="User is not authenticated.")
320
+ _current_user_id, current_user = await self._get_authenticated_user(session_id)
292
321
 
293
322
  admin_model = get_admin_model(settings.ADMIN_USER_MODEL)
294
323
  if not admin_model:
295
324
  raise AdminApiException(404, detail=f"{settings.ADMIN_USER_MODEL} model is not registered.")
325
+ self._bind_admin_context(admin_model, request=request, user=current_user)
296
326
 
297
327
  payload = ChangePasswordInputSchema(**payload)
298
328
  if payload.password != payload.confirm_password:
@@ -325,14 +355,14 @@ class ApiService:
325
355
  model: str,
326
356
  id: UUID | int | str,
327
357
  payload: dict,
358
+ request: Any | None = None,
328
359
  ) -> dict:
329
- current_user_id = await get_user_id_from_session_id(session_id)
330
- if not current_user_id:
331
- raise AdminApiException(401, detail="User is not authenticated.")
360
+ _current_user_id, current_user = await self._get_authenticated_user(session_id)
332
361
 
333
362
  admin_model = get_admin_or_admin_inline_model(model)
334
363
  if not admin_model:
335
364
  raise AdminApiException(404, detail=f"{model} model is not registered.")
365
+ self._bind_admin_context(admin_model, request=request, user=current_user)
336
366
 
337
367
  try:
338
368
  obj = await admin_model.save_model(id, payload)
@@ -350,10 +380,9 @@ class ApiService:
350
380
  search: str | None = None,
351
381
  sort_by: str | None = None,
352
382
  filters: dict | None = None,
383
+ request: Any | None = None,
353
384
  ) -> tuple[str, str, StringIO | BytesIO | None]:
354
- current_user_id = await get_user_id_from_session_id(session_id)
355
- if not current_user_id:
356
- raise AdminApiException(401, detail="User is not authenticated.")
385
+ _current_user_id, current_user = await self._get_authenticated_user(session_id)
357
386
 
358
387
  query_params = ListQuerySchema(
359
388
  search=search,
@@ -366,13 +395,14 @@ class ApiService:
366
395
  admin_model = get_admin_or_admin_inline_model(model)
367
396
  if not admin_model:
368
397
  raise AdminApiException(404, detail=f"{model} model is not registered.")
398
+ self._bind_admin_context(admin_model, request=request, user=current_user)
369
399
 
370
400
  # validations
371
- fields = admin_model.get_fields_for_serialize()
401
+ fields = set(admin_model.get_fields_for_serialize())
372
402
 
373
403
  if query_params.search and admin_model.search_fields:
374
404
  for field in admin_model.search_fields:
375
- if field not in fields:
405
+ if not is_allowed_field_or_path(field, fields):
376
406
  raise AdminApiException(422, detail=f"Search by {field} is not allowed")
377
407
 
378
408
  exclude_filter_fields = ("search", "sort_by", "offset", "limit")
@@ -395,7 +425,7 @@ class ApiService:
395
425
  raise AdminApiException(422, detail=f"Sort by {query_params.sort_by} is not allowed")
396
426
  elif admin_model.ordering:
397
427
  for ordering_field in admin_model.ordering:
398
- if ordering_field.strip("-") not in fields:
428
+ if not is_allowed_field_or_path(ordering_field.strip("-"), fields):
399
429
  raise AdminApiException(422, detail=f"Sort by {ordering_field} is not allowed")
400
430
 
401
431
  content_type = "text/plain"
@@ -424,14 +454,14 @@ class ApiService:
424
454
  session_id: str | None,
425
455
  model: str,
426
456
  id: UUID | int | str,
457
+ request: Any | None = None,
427
458
  ) -> UUID | int | str:
428
- current_user_id = await get_user_id_from_session_id(session_id)
429
- if not current_user_id:
430
- raise AdminApiException(401, detail="User is not authenticated.")
459
+ current_user_id, current_user = await self._get_authenticated_user(session_id)
431
460
 
432
461
  admin_model = get_admin_or_admin_inline_model(model)
433
462
  if not admin_model:
434
463
  raise AdminApiException(404, detail=f"{model} model is not registered.")
464
+ self._bind_admin_context(admin_model, request=request, user=current_user)
435
465
 
436
466
  if str(current_user_id) == str(id) and model == settings.ADMIN_USER_MODEL:
437
467
  raise AdminApiException(403, detail="You cannot delete yourself.")
@@ -441,14 +471,20 @@ class ApiService:
441
471
  raise AdminApiException(404, detail=f"{model} not found.") from None
442
472
  return id
443
473
 
444
- async def action(self, session_id: str | None, model: str, action: str, payload: ActionInputSchema) -> None:
445
- current_user_id = await get_user_id_from_session_id(session_id)
446
- if not current_user_id:
447
- raise AdminApiException(401, detail="User is not authenticated.")
474
+ async def action(
475
+ self,
476
+ session_id: str | None,
477
+ model: str,
478
+ action: str,
479
+ payload: ActionInputSchema,
480
+ request: Any | None = None,
481
+ ) -> None:
482
+ _current_user_id, current_user = await self._get_authenticated_user(session_id)
448
483
 
449
484
  admin_model = get_admin_or_admin_inline_model(model)
450
485
  if not admin_model:
451
486
  raise AdminApiException(404, detail=f"{model} model is not registered.")
487
+ self._bind_admin_context(admin_model, request=request, user=current_user)
452
488
 
453
489
  if action not in admin_model.actions:
454
490
  raise AdminApiException(422, detail=f"{action} action is not in actions setting.")
@@ -467,9 +503,11 @@ class ApiService:
467
503
  async def get_configuration(
468
504
  self,
469
505
  session_id: str | None,
506
+ request: Any | None = None,
470
507
  ) -> ConfigurationSchema:
471
- current_user_id = await get_user_id_from_session_id(session_id)
472
- if not current_user_id:
508
+ try:
509
+ current_user_id, current_user = await self._get_authenticated_user(session_id)
510
+ except AdminApiException:
473
511
  return ConfigurationSchema(
474
512
  site_name=settings.ADMIN_SITE_NAME,
475
513
  site_sign_in_logo=settings.ADMIN_SITE_SIGN_IN_LOGO,
@@ -485,7 +523,10 @@ class ApiService:
485
523
  )
486
524
 
487
525
  admin_models = cast(dict[Any, ModelAdmin | InlineModelAdmin], get_admin_models())
488
- models = cast(Sequence[ModelSchema], await generate_models_schema(admin_models, user_id=current_user_id))
526
+ models = cast(
527
+ Sequence[ModelSchema],
528
+ await generate_models_schema(admin_models, user_id=current_user_id, user=current_user, request=request),
529
+ )
489
530
  dashboard_widgets = generate_dashboard_widgets_schema()
490
531
  return ConfigurationSchema(
491
532
  site_name=settings.ADMIN_SITE_NAME,
@@ -3,6 +3,7 @@ import datetime
3
3
  import inspect
4
4
  import json
5
5
  from collections.abc import Sequence
6
+ from contextvars import ContextVar
6
7
  from io import BytesIO, StringIO
7
8
  from typing import Any
8
9
  from uuid import UUID
@@ -16,6 +17,9 @@ from fastadmin.models.schemas import DashboardWidgetType, ModelFieldWidgetSchema
16
17
  class BaseModelAdmin:
17
18
  """Base class for model admin"""
18
19
 
20
+ _request_context: ContextVar[Any | None]
21
+ _user_context: ContextVar[Any | None]
22
+
19
23
  # Use it only if you use several orms in your project.
20
24
  model_name_prefix: str | None = None
21
25
 
@@ -198,6 +202,23 @@ class BaseModelAdmin:
198
202
  :params model_cls: an orm/db model class.
199
203
  """
200
204
  self.model_cls = model_cls
205
+ self._request_context = ContextVar(f"fastadmin_admin_request_context_{id(self)}", default=None)
206
+ self._user_context = ContextVar(f"fastadmin_admin_user_context_{id(self)}", default=None)
207
+
208
+ @property
209
+ def request(self) -> Any | None:
210
+ """Current request object for this async context."""
211
+ return self._request_context.get()
212
+
213
+ @property
214
+ def user(self) -> Any | None:
215
+ """Current authenticated user object for this async context."""
216
+ return self._user_context.get()
217
+
218
+ def set_context(self, request: Any | None = None, user: Any | None = None) -> None:
219
+ """Set request/user context for the current async task."""
220
+ self._request_context.set(request)
221
+ self._user_context.set(user)
201
222
 
202
223
  @staticmethod
203
224
  def get_model_pk_name(orm_model_cls: Any) -> str:
@@ -227,6 +248,8 @@ class BaseModelAdmin:
227
248
  search: str | None = None,
228
249
  sort_by: str | None = None,
229
250
  filters: dict | None = None,
251
+ prefetch_related_fields: list[str] | None = None,
252
+ additional_search_fields: list[str] | None = None,
230
253
  ) -> tuple[list[Any], int]:
231
254
  """This method is used to get list of orm/db model objects.
232
255
 
@@ -235,6 +258,8 @@ class BaseModelAdmin:
235
258
  :params search: a search query.
236
259
  :params sort_by: a sort by field name.
237
260
  :params filters: a dict of filters.
261
+ :params prefetch_related_fields: a list of related fields to prefetch.
262
+ :params additional_search_fields: a list of additional search fields.
238
263
  :return: A tuple of list of objects and total count.
239
264
  """
240
265
  raise NotImplementedError
@@ -372,12 +397,12 @@ class BaseModelAdmin:
372
397
  return serialized_dict
373
398
 
374
399
  async def _serialize_obj_after_save(self, obj: Any) -> dict:
375
- """Serialize object after save; re-fetch if detached (e.g. SQLAlchemy after commit)."""
400
+ """Serialize object after save; re-fetch if detached/session is over."""
376
401
  try:
377
402
  return await self.serialize_obj(obj)
378
403
  except Exception as exc:
379
- # SQLAlchemy DetachedInstanceError when session is closed after commit
380
- if exc.__class__.__name__ != "DetachedInstanceError":
404
+ # ORM-specific detached/session-over cases after commit.
405
+ if exc.__class__.__name__ not in {"DetachedInstanceError", "DatabaseSessionIsOver"}:
381
406
  raise
382
407
  pk_name = self.get_model_pk_name(self.model_cls)
383
408
  pk = getattr(obj, pk_name, None)
@@ -697,6 +722,9 @@ class ModelAdmin(BaseModelAdmin):
697
722
 
698
723
 
699
724
  class DashboardWidgetAdmin:
725
+ _request_context: ContextVar[Any | None]
726
+ _user_context: ContextVar[Any | None]
727
+
700
728
  title: str
701
729
  dashboard_widget_type: DashboardWidgetType
702
730
  x_field: str
@@ -706,6 +734,25 @@ class DashboardWidgetAdmin:
706
734
  x_field_filter_widget_props: dict[str, Any] | None = None
707
735
  x_field_periods: list[str] | None = None
708
736
 
737
+ def __init__(self) -> None:
738
+ self._request_context = ContextVar(f"fastadmin_dashboard_request_context_{id(self)}", default=None)
739
+ self._user_context = ContextVar(f"fastadmin_dashboard_user_context_{id(self)}", default=None)
740
+
741
+ @property
742
+ def request(self) -> Any | None:
743
+ """Current request object for this async context."""
744
+ return self._request_context.get()
745
+
746
+ @property
747
+ def user(self) -> Any | None:
748
+ """Current authenticated user object for this async context."""
749
+ return self._user_context.get()
750
+
751
+ def set_context(self, request: Any | None = None, user: Any | None = None) -> None:
752
+ """Set request/user context for the current async task."""
753
+ self._request_context.set(request)
754
+ self._user_context.set(user)
755
+
709
756
  async def get_data(
710
757
  self,
711
758
  min_x_field: str | None = None,
@@ -101,6 +101,8 @@ def get_admin_or_admin_inline_model(orm_model_cls: str) -> ModelAdmin | InlineMo
101
101
  async def generate_models_schema(
102
102
  admin_models: dict[Any, ModelAdmin | InlineModelAdmin],
103
103
  user_id: UUID | int | None = None,
104
+ user: Any | None = None,
105
+ request: Any | None = None,
104
106
  inline_parent_admin_modal: ModelAdmin | None = None,
105
107
  ) -> list[ModelSchema | InlineModelSchema]:
106
108
  """Generate models schema
@@ -111,6 +113,8 @@ async def generate_models_schema(
111
113
  """
112
114
  models_schemas: list[ModelSchema | InlineModelSchema] = []
113
115
  for orm_model_cls, admin_model_obj in admin_models.items():
116
+ if hasattr(admin_model_obj, "set_context"):
117
+ admin_model_obj.set_context(request=request, user=user)
114
118
  orm_model_fields = admin_model_obj.get_model_fields_with_widget_types()
115
119
  orm_model_fields_for_serialize = admin_model_obj.get_fields_for_serialize()
116
120
 
@@ -258,6 +262,8 @@ async def generate_models_schema(
258
262
  view_on_site=admin_model_obj.view_on_site,
259
263
  inlines=await generate_models_schema(
260
264
  {inline.model: inline(inline.model) for inline in admin_model_obj.inlines},
265
+ user=user,
266
+ request=request,
261
267
  inline_parent_admin_modal=admin_model_obj,
262
268
  user_id=user_id,
263
269
  ), # type: ignore [arg-type]
@@ -1,4 +1,3 @@
1
- import operator
2
1
  from base64 import b64decode
3
2
  from typing import Any
4
3
  from uuid import UUID
@@ -245,6 +244,8 @@ class DjangoORMMixin:
245
244
  search: str | None = None,
246
245
  sort_by: str | None = None,
247
246
  filters: dict | None = None,
247
+ prefetch_related_fields: list[str] | None = None,
248
+ additional_search_fields: list[str] | None = None,
248
249
  ) -> tuple[list[Any], int]:
249
250
  """This method is used to get list of orm/db model objects.
250
251
 
@@ -253,19 +254,30 @@ class DjangoORMMixin:
253
254
  :params search: a search query.
254
255
  :params sort_by: a sort by field name.
255
256
  :params filters: a dict of filters.
257
+ :params prefetch_related_fields: a list of related fields to prefetch.
258
+ :params additional_search_fields: a list of additional search fields.
256
259
  :return: A tuple of list of objects and total count.
257
260
  """
258
261
  qs = self.model_cls.objects.all()
259
262
 
263
+ if prefetch_related_fields:
264
+ qs = qs.prefetch_related(*prefetch_related_fields)
265
+
260
266
  if filters:
261
267
  for field_with_condition, value in filters.items():
262
268
  field = field_with_condition[0]
263
269
  condition = field_with_condition[1]
264
270
  qs = qs.filter(**{f"{field}__{condition}" if condition != "exact" else field: value})
265
271
 
266
- if search and self.search_fields:
267
- search_conditions = [Q(**{f + "__icontains": search}) for f in self.search_fields]
268
- search_q = search_conditions[0] if len(search_conditions) == 1 else operator.or_(*search_conditions)
272
+ search_fields = list(self.search_fields)
273
+ if additional_search_fields:
274
+ search_fields.extend(additional_search_fields)
275
+
276
+ if search and search_fields:
277
+ search_conditions = [Q(**{f + "__icontains": search}) for f in search_fields]
278
+ search_q = search_conditions[0]
279
+ for condition in search_conditions[1:]:
280
+ search_q |= condition
269
281
  qs = qs.filter(search_q)
270
282
 
271
283
  if sort_by:
@@ -1,4 +1,6 @@
1
+ import inspect
1
2
  from enum import EnumMeta
3
+ from types import SimpleNamespace
2
4
  from typing import Any
3
5
  from uuid import UUID
4
6
 
@@ -222,6 +224,8 @@ class PonyORMMixin:
222
224
  search: str | None = None,
223
225
  sort_by: str | None = None,
224
226
  filters: dict | None = None,
227
+ prefetch_related_fields: list[str] | None = None,
228
+ additional_search_fields: list[str] | None = None,
225
229
  ) -> tuple[list[Any], int]:
226
230
  """This method is used to get list of orm/db model objects.
227
231
 
@@ -230,6 +234,8 @@ class PonyORMMixin:
230
234
  :params search: a search query.
231
235
  :params sort_by: a sort by field name.
232
236
  :params filters: a dict of filters.
237
+ :params prefetch_related_fields: a list of related fields to prefetch.
238
+ :params additional_search_fields: a list of additional search fields.
233
239
  :return: A tuple of list of objects and total count.
234
240
  """
235
241
 
@@ -269,11 +275,16 @@ class PonyORMMixin:
269
275
  filter_expr = f""""{value}" {pony_condition} m.{field}"""
270
276
  qs = qs.filter(filter_expr)
271
277
 
272
- if search and self.search_fields:
278
+ search_fields = list(self.search_fields)
279
+ if additional_search_fields:
280
+ search_fields.extend(additional_search_fields)
281
+
282
+ if search and search_fields:
273
283
  ids = []
274
- for search_field in self.search_fields:
284
+ for search_field in search_fields:
285
+ pony_search_field = search_field.replace("__", ".")
275
286
  # Pony string filter for case-insensitive search
276
- filter_expr = f'"{search.lower()}" in m.{search_field}.lower()'
287
+ filter_expr = f'"{search.lower()}" in m.{pony_search_field}.lower()'
277
288
  qs_ids = qs.filter(filter_expr)
278
289
  objs = list(qs_ids)
279
290
  ids += [o.id for o in objs]
@@ -293,6 +304,9 @@ class PonyORMMixin:
293
304
  if self.list_select_related:
294
305
  qs = qs.prefetch(*[getattr(self.model_cls, field) for field in self.list_select_related])
295
306
 
307
+ if prefetch_related_fields:
308
+ qs = qs.prefetch(*[getattr(self.model_cls, field) for field in prefetch_related_fields])
309
+
296
310
  if offset is not None and limit is not None:
297
311
  qs = qs.limit(limit, offset=offset)
298
312
 
@@ -309,6 +323,54 @@ class PonyORMMixin:
309
323
  """
310
324
  return self.model_cls.select(**{self.get_model_pk_name(self.model_cls): id}).first()
311
325
 
326
+ @sync_to_async
327
+ @db_session
328
+ def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
329
+ """Serialize object by id in one db_session (avoids detached access)."""
330
+ obj = self.model_cls.select(**{self.get_model_pk_name(self.model_cls): id}).first()
331
+ if not obj:
332
+ return None
333
+
334
+ fields = self.get_model_fields_with_widget_types()
335
+ fields_for_serialize = self.get_fields_for_serialize()
336
+ obj_dict: dict[str, Any] = {}
337
+
338
+ for field in fields:
339
+ if field.name not in fields_for_serialize:
340
+ continue
341
+ if field.is_m2m:
342
+ rel_model_cls = getattr(self.model_cls, field.column_name).py_type
343
+ rel_key_id = self.get_model_pk_name(rel_model_cls)
344
+ obj_dict[field.name] = [getattr(o, rel_key_id) for o in getattr(obj, field.column_name)]
345
+ else:
346
+ relation_attr = getattr(self.model_cls, field.column_name, None)
347
+ if relation_attr and getattr(relation_attr, "is_relation", False):
348
+ rel_obj = getattr(obj, field.column_name)
349
+ if rel_obj is None:
350
+ obj_dict[field.name] = None
351
+ else:
352
+ rel_model_cls = relation_attr.py_type
353
+ rel_key_id = self.get_model_pk_name(rel_model_cls)
354
+ obj_dict[field.name] = getattr(rel_obj, rel_key_id)
355
+ else:
356
+ obj_dict[field.name] = getattr(obj, field.column_name)
357
+
358
+ obj_dict["__str__"] = str(obj)
359
+ proxy = SimpleNamespace(**obj_dict)
360
+ for field_name in fields_for_serialize:
361
+ display_field_function = getattr(self, field_name, None)
362
+ if not display_field_function or not hasattr(display_field_function, "is_display"):
363
+ continue
364
+ if inspect.iscoroutinefunction(display_field_function):
365
+ # Async display functions are not expected in Pony sync session context.
366
+ continue
367
+ try:
368
+ obj_dict[field_name] = display_field_function(proxy)
369
+ except (AttributeError, TypeError, KeyError):
370
+ obj_dict[field_name] = display_field_function(obj)
371
+
372
+ return obj_dict
373
+
312
374
  @sync_to_async
313
375
  @db_session
314
376
  def orm_save_obj(self, id: UUID | Any | None, payload: dict) -> Any:
@@ -12,6 +12,66 @@ from fastadmin.settings import settings
12
12
 
13
13
 
14
14
  class SqlAlchemyMixin:
15
+ def _build_search_condition(self, field_path: str, search: str) -> Any | None:
16
+ """Build SQLAlchemy search condition for simple and nested fields.
17
+
18
+ Supports direct fields (e.g. ``name``) and relation paths using
19
+ Django-style separator (e.g. ``tournament__name``).
20
+ """
21
+
22
+ def recurse(model_cls: Any, parts: list[str]) -> Any | None:
23
+ attr = getattr(model_cls, parts[0], None)
24
+ if attr is None:
25
+ return None
26
+
27
+ if len(parts) == 1:
28
+ return attr.ilike(f"%{search}%")
29
+
30
+ rel_property = getattr(attr, "property", None)
31
+ if rel_property is None:
32
+ return None
33
+
34
+ related_model = getattrs(rel_property, "mapper.class_")
35
+ if related_model is None:
36
+ return None
37
+
38
+ nested_condition = recurse(related_model, parts[1:])
39
+ if nested_condition is None:
40
+ return None
41
+
42
+ if getattr(rel_property, "uselist", False):
43
+ return attr.any(nested_condition)
44
+ return attr.has(nested_condition)
45
+
46
+ return recurse(self.model_cls, field_path.split("__"))
47
+
48
+ def _resolve_ordering_field(self, ordering_field: str) -> str:
49
+ """Resolve ordering field for SQLAlchemy.
50
+
51
+ Relation keys (e.g. `tournament`) are not direct SQL columns.
52
+ For MANYTOONE/ONETOONE relations, sort by local FK column
53
+ (e.g. `tournament_id`) instead.
54
+ """
55
+ if not ordering_field:
56
+ return ordering_field
57
+
58
+ prefix = "-" if ordering_field.startswith("-") else ""
59
+ field_name = ordering_field.lstrip("-")
60
+
61
+ # Keep nested paths unchanged (join/loader strategy specific)
62
+ if "__" in field_name:
63
+ return ordering_field
64
+
65
+ mapper = inspect(self.model_cls)
66
+ relation = next((rel for rel in mapper.relationships if rel.key == field_name), None)
67
+ if relation and getattrs(relation, "direction.name") in ("MANYTOONE", "ONETOONE"):
68
+ local_columns = list(getattr(relation, "local_columns", []))
69
+ if local_columns:
70
+ return f"{prefix}{local_columns[0].key}"
71
+ return f"{prefix}{field_name}_id"
72
+
73
+ return ordering_field
74
+
15
75
  @staticmethod
16
76
  def get_model_pk_name(orm_model_cls: Any) -> str:
17
77
  """This method is used to get model pk name.
@@ -240,6 +300,8 @@ class SqlAlchemyMixin:
240
300
  search: str | None = None,
241
301
  sort_by: str | None = None,
242
302
  filters: dict | None = None,
303
+ prefetch_related_fields: list[str] | None = None,
304
+ additional_search_fields: list[str] | None = None,
243
305
  ) -> tuple[list[Any], int]:
244
306
  """This method is used to get list of orm/db model objects.
245
307
 
@@ -248,10 +310,13 @@ class SqlAlchemyMixin:
248
310
  :params search: a search query.
249
311
  :params sort_by: a sort by field name.
250
312
  :params filters: a dict of filters.
313
+ :params prefetch_related_fields: a list of related fields to prefetch.
314
+ :params additional_search_fields: a list of additional search fields.
251
315
  :return: A tuple of list of objects and total count.
252
316
  """
253
317
 
254
318
  def convert_sort_by(sort_by: str) -> str:
319
+ sort_by = self._resolve_ordering_field(sort_by)
255
320
  if sort_by.startswith("-"):
256
321
  return sort_by[1:] + " desc"
257
322
  return sort_by
@@ -293,10 +358,18 @@ class SqlAlchemyMixin:
293
358
  q.append(model_field.ilike(f"%{value}%"))
294
359
  qs = qs.where(and_(*q))
295
360
 
296
- if search and self.search_fields:
361
+ search_fields = list(self.search_fields)
362
+ if additional_search_fields:
363
+ search_fields.extend(additional_search_fields)
364
+
365
+ if search and search_fields:
297
366
  q = []
298
- for field in self.search_fields:
299
- q.append(getattr(self.model_cls, field).ilike(f"%{search}%"))
367
+ for field in search_fields:
368
+ condition = self._build_search_condition(field, search)
369
+ if condition is not None:
370
+ q.append(condition)
371
+ if not q:
372
+ return [], 0
300
373
  qs = qs.where(or_(*q))
301
374
 
302
375
  if sort_by:
@@ -312,6 +385,28 @@ class SqlAlchemyMixin:
312
385
  for field in self.list_select_related:
313
386
  qs = qs.options(selectinload(getattr(self.model_cls, field)))
314
387
 
388
+ if prefetch_related_fields:
389
+ for field_path in prefetch_related_fields:
390
+ parts = field_path.split("__")
391
+ current_model = self.model_cls
392
+ attr = getattr(current_model, parts[0], None)
393
+ if attr is None:
394
+ continue
395
+ option = selectinload(attr)
396
+ current_model = getattrs(attr, "property.mapper.class_")
397
+ for part in parts[1:]:
398
+ if current_model is None:
399
+ break
400
+ nested_attr = getattr(current_model, part, None)
401
+ if nested_attr is None:
402
+ break
403
+ next_model = getattrs(nested_attr, "property.mapper.class_")
404
+ if next_model is None:
405
+ break
406
+ option = option.selectinload(nested_attr)
407
+ current_model = next_model
408
+ qs = qs.options(option)
409
+
315
410
  if offset is not None and limit is not None:
316
411
  qs = qs.offset(offset)
317
412
  qs = qs.limit(limit)
@@ -11,6 +11,31 @@ from fastadmin.settings import settings
11
11
 
12
12
 
13
13
  class TortoiseMixin:
14
+ def _resolve_ordering_field(self, ordering_field: str) -> str:
15
+ """Resolve ordering field for Tortoise.
16
+
17
+ Tortoise 1.x does not allow ordering by relation names directly
18
+ (e.g. `tournament`). For FK/O2O relation fields, order by their
19
+ backing id field instead (e.g. `tournament_id`).
20
+ """
21
+ if not ordering_field:
22
+ return ordering_field
23
+
24
+ prefix = "-" if ordering_field.startswith("-") else ""
25
+ field_name = ordering_field.lstrip("-")
26
+
27
+ # Respect explicit nested ordering (e.g. relation__name)
28
+ if "__" in field_name:
29
+ return ordering_field
30
+
31
+ orm_field = self.model_cls._meta.fields_map.get(field_name)
32
+ if not orm_field:
33
+ return ordering_field
34
+
35
+ if orm_field.__class__.__name__ in ("ForeignKeyFieldInstance", "OneToOneFieldInstance"):
36
+ return f"{prefix}{field_name}_id"
37
+ return ordering_field
38
+
14
39
  @staticmethod
15
40
  def get_model_pk_name(orm_model_cls: Any) -> str:
16
41
  """This method is used to get model pk name.
@@ -229,6 +254,8 @@ class TortoiseMixin:
229
254
  search: str | None = None,
230
255
  sort_by: str | None = None,
231
256
  filters: dict | None = None,
257
+ prefetch_related_fields: list[str] | None = None,
258
+ additional_search_fields: list[str] | None = None,
232
259
  ) -> tuple[list[Any], int]:
233
260
  """This method is used to get list of orm/db model objects.
234
261
 
@@ -237,29 +264,38 @@ class TortoiseMixin:
237
264
  :params search: a search query.
238
265
  :params sort_by: a sort by field name.
239
266
  :params filters: a dict of filters.
267
+ :params prefetch_related_fields: a list of related fields to prefetch.
268
+ :params additional_search_fields: a list of additional search fields.
240
269
  :return: A tuple of list of objects and total count.
241
270
  """
242
271
  qs = self.model_cls.all()
243
272
 
273
+ if prefetch_related_fields:
274
+ qs = qs.prefetch_related(*prefetch_related_fields).distinct()
275
+
244
276
  if filters:
245
277
  for field_with_condition, value in filters.items():
246
278
  field = field_with_condition[0]
247
279
  condition = field_with_condition[1]
248
280
  qs = qs.filter(**{f"{field}__{condition}" if condition != "exact" else field: value})
249
281
 
250
- if search and self.search_fields:
282
+ search_fields = list(self.search_fields)
283
+ if additional_search_fields:
284
+ search_fields.extend(additional_search_fields)
285
+
286
+ if search and search_fields:
251
287
  qs = qs.filter(
252
288
  functools.reduce(
253
289
  operator.or_,
254
- (Q(**{f + "__icontains": search}) for f in self.search_fields),
290
+ (Q(**{f + "__icontains": search}) for f in search_fields),
255
291
  Q(),
256
292
  )
257
293
  )
258
294
 
259
295
  if sort_by:
260
- qs = qs.order_by(sort_by)
296
+ qs = qs.order_by(self._resolve_ordering_field(sort_by))
261
297
  elif self.ordering:
262
- qs = qs.order_by(*self.ordering)
298
+ qs = qs.order_by(*(self._resolve_ordering_field(field) for field in self.ordering))
263
299
 
264
300
  total = await qs.count()
265
301
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "fastadmin"
3
- version = "0.3.3"
3
+ version = "0.3.5"
4
4
  description = "FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin."
5
5
  authors = ["Seva D <vsdudakov@gmail.com>"]
6
6
  license = "MIT"
@@ -61,6 +61,7 @@ isort = "^6.0.1"
61
61
  python-dotenv = "^1.0.0"
62
62
  jinja2 = "^3.1.2"
63
63
  pytest-xdist = "^3.6.1"
64
+ typing-extensions = "^4.15.0"
64
65
 
65
66
  [build-system]
66
67
  requires = ["poetry-core"]
File without changes