Skip to content

Commit 82f1a5e

Browse files
committed
Fix DetachedInstanceError when session is closed after commit. Fix list display widths. Fix flask issues.
1 parent 242963b commit 82f1a5e

16 files changed

Lines changed: 217 additions & 104 deletions

File tree

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ lint:
3131
@echo "Run frontend linters"
3232
@exec make -C frontend lint
3333

34-
# -n auto : fix django
34+
# Use -n 1: -n auto causes flaky failures with Django+SQLite (database is locked, 500s). conftest
35+
# uses a per-worker DB and SQLite timeout when xdist is used, but parallel runs remain unreliable.
3536
.PHONY: test
3637
test:
3738
@exec poetry run pytest -n 1 --cov=fastadmin --cov-report=term-missing --cov-report=xml --cov-fail-under=80 -s tests

docs/build.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ def read_cls_docstring(cls):
4242

4343
def get_versions():
4444
return [
45+
{
46+
"version": "0.3.3",
47+
"changes": [
48+
"Fix DetachedInstanceError when session is closed after commit.",
49+
"Fix list display widths.",
50+
"Fix flask issues.",
51+
],
52+
},
4553
{
4654
"version": "0.3.2",
4755
"changes": [

docs/index.html

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2281,6 +2281,16 @@ <h3>Methods and Attributes</h3>
22812281
"""
22822282
raise NotImplementedError
22832283

2284+
async def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
2285+
"""Serialize object by id (e.g. in a single session to avoid DetachedInstanceError).
2286+
2287+
Override in ORM layer to run get+serialize inside one session when needed.
2288+
"""
2289+
obj = await self.orm_get_obj(id)
2290+
if obj is None:
2291+
return None
2292+
return await self.serialize_obj(obj)
2293+
22842294
async def orm_save_obj(self, id: UUID | Any | None, payload: dict) -> Any:
22852295
"""This method is used to save orm/db model object.
22862296

@@ -2395,6 +2405,23 @@ <h3>Methods and Attributes</h3>
23952405
serialized_dict["__str__"] = await str_fn()
23962406
return serialized_dict
23972407

2408+
async def _serialize_obj_after_save(self, obj: Any) -> dict:
2409+
"""Serialize object after save; re-fetch if detached (e.g. SQLAlchemy after commit)."""
2410+
try:
2411+
return await self.serialize_obj(obj)
2412+
except Exception as exc:
2413+
# SQLAlchemy DetachedInstanceError when session is closed after commit
2414+
if exc.__class__.__name__ != "DetachedInstanceError":
2415+
raise
2416+
pk_name = self.get_model_pk_name(self.model_cls)
2417+
pk = getattr(obj, pk_name, None)
2418+
if pk is None:
2419+
raise
2420+
result = await self.orm_serialize_obj_by_id(pk)
2421+
if result is None:
2422+
raise
2423+
return result
2424+
23982425
async def serialize_obj(self, obj: Any, list_view: bool = False) -> dict:
23992426
"""Serialize orm model obj to dict.
24002427

@@ -2438,8 +2465,13 @@ <h3>Methods and Attributes</h3>
24382465
return value
24392466
match field.form_widget_type:
24402467
case WidgetType.TimePicker:
2441-
return datetime.datetime.fromisoformat(value).time()
2442-
case WidgetType.DatePicker | WidgetType.DateTimePicker:
2468+
try:
2469+
return datetime.datetime.fromisoformat(value).time()
2470+
except ValueError:
2471+
return datetime.time.fromisoformat(value)
2472+
case WidgetType.DatePicker:
2473+
return datetime.datetime.fromisoformat(value).date()
2474+
case WidgetType.DateTimePicker:
24432475
return datetime.datetime.fromisoformat(value)
24442476
case _:
24452477
return value
@@ -2517,7 +2549,7 @@ <h3>Methods and Attributes</h3>
25172549
if m2m_field.name in payload:
25182550
await self.orm_save_m2m_ids(obj, m2m_field.column_name, payload[m2m_field.name])
25192551

2520-
return await self.serialize_obj(obj)
2552+
return await self._serialize_obj_after_save(obj)
25212553

25222554
async def delete_model(self, id: UUID | int | str) -> None:
25232555
"""This method is used to delete orm/db model object.

fastadmin/api/frameworks/django/app/views.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import os
2+
13
from django.http import HttpResponse
24

35
from fastadmin.api.helpers import get_template
46
from fastadmin.settings import ROOT_DIR, settings
57

68

9+
def _get_admin_prefix() -> str:
10+
"""Return admin URL prefix from env at request time so it respects os.environ set after import."""
11+
return os.getenv("ADMIN_PREFIX", settings.ADMIN_PREFIX)
12+
13+
714
def index(request):
815
"""This method is used to render index page.
916
@@ -13,7 +20,7 @@ def index(request):
1320
template = get_template(
1421
ROOT_DIR / "templates" / "index.html",
1522
{
16-
"ADMIN_PREFIX": settings.ADMIN_PREFIX,
23+
"ADMIN_PREFIX": _get_admin_prefix(),
1724
},
1825
)
1926
return HttpResponse(template)

fastadmin/api/frameworks/fastapi/views.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import os
23

34
from fastapi import APIRouter
45
from fastapi.responses import HTMLResponse
@@ -10,6 +11,11 @@
1011
router = APIRouter()
1112

1213

14+
def _get_admin_prefix() -> str:
15+
"""Return admin URL prefix from env at request time so it respects os.environ set after import."""
16+
return os.getenv("ADMIN_PREFIX", settings.ADMIN_PREFIX)
17+
18+
1319
@router.get("/", response_class=HTMLResponse)
1420
def index():
1521
"""This method is used to render index page.
@@ -20,6 +26,6 @@ def index():
2026
return get_template(
2127
ROOT_DIR / "templates" / "index.html",
2228
{
23-
"ADMIN_PREFIX": settings.ADMIN_PREFIX,
29+
"ADMIN_PREFIX": _get_admin_prefix(),
2430
},
2531
)

fastadmin/api/frameworks/flask/views.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import os
23

34
from flask import Blueprint
45

@@ -12,6 +13,11 @@
1213
)
1314

1415

16+
def _get_admin_prefix() -> str:
17+
"""Return admin URL prefix from env at request time so it respects os.environ set after import."""
18+
return os.getenv("ADMIN_PREFIX", settings.ADMIN_PREFIX)
19+
20+
1521
@views_router.route("/")
1622
def index():
1723
"""This method is used to render index page.
@@ -21,6 +27,6 @@ def index():
2127
return get_template(
2228
ROOT_DIR / "templates" / "index.html",
2329
{
24-
"ADMIN_PREFIX": settings.ADMIN_PREFIX,
30+
"ADMIN_PREFIX": _get_admin_prefix(),
2531
},
2632
)

fastadmin/api/helpers.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import base64
2-
import binascii
31
from pathlib import Path
42
from uuid import UUID
53

@@ -105,19 +103,6 @@ def is_valid_id(id: UUID | int | str) -> bool:
105103
return False
106104

107105

108-
def is_valid_base64(value: str) -> bool:
109-
"""Check if a string is a valid base64.
110-
111-
:param value: A string to test.
112-
:return: True if s is a valid base64, False otherwise.
113-
"""
114-
try:
115-
base64.decodebytes(value.encode("ascii"))
116-
return True
117-
except binascii.Error:
118-
return False
119-
120-
121106
def get_template(template: Path, context: dict) -> str:
122107
with template.open("r") as file:
123108
content = file.read()

fastadmin/models/base.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,16 @@ async def orm_get_obj(self, id: UUID | int | str) -> Any | None:
247247
"""
248248
raise NotImplementedError
249249

250+
async def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
251+
"""Serialize object by id (e.g. in a single session to avoid DetachedInstanceError).
252+
253+
Override in ORM layer to run get+serialize inside one session when needed.
254+
"""
255+
obj = await self.orm_get_obj(id)
256+
if obj is None:
257+
return None
258+
return await self.serialize_obj(obj)
259+
250260
async def orm_save_obj(self, id: UUID | Any | None, payload: dict) -> Any:
251261
"""This method is used to save orm/db model object.
252262
@@ -361,6 +371,23 @@ async def serialize_obj_attributes(
361371
serialized_dict["__str__"] = await str_fn()
362372
return serialized_dict
363373

374+
async def _serialize_obj_after_save(self, obj: Any) -> dict:
375+
"""Serialize object after save; re-fetch if detached (e.g. SQLAlchemy after commit)."""
376+
try:
377+
return await self.serialize_obj(obj)
378+
except Exception as exc:
379+
# SQLAlchemy DetachedInstanceError when session is closed after commit
380+
if exc.__class__.__name__ != "DetachedInstanceError":
381+
raise
382+
pk_name = self.get_model_pk_name(self.model_cls)
383+
pk = getattr(obj, pk_name, None)
384+
if pk is None:
385+
raise
386+
result = await self.orm_serialize_obj_by_id(pk)
387+
if result is None:
388+
raise
389+
return result
390+
364391
async def serialize_obj(self, obj: Any, list_view: bool = False) -> dict:
365392
"""Serialize orm model obj to dict.
366393
@@ -404,8 +431,13 @@ def deserialize_value(self, field: ModelFieldWidgetSchema, value: Any) -> Any:
404431
return value
405432
match field.form_widget_type:
406433
case WidgetType.TimePicker:
407-
return datetime.datetime.fromisoformat(value).time()
408-
case WidgetType.DatePicker | WidgetType.DateTimePicker:
434+
try:
435+
return datetime.datetime.fromisoformat(value).time()
436+
except ValueError:
437+
return datetime.time.fromisoformat(value)
438+
case WidgetType.DatePicker:
439+
return datetime.datetime.fromisoformat(value).date()
440+
case WidgetType.DateTimePicker:
409441
return datetime.datetime.fromisoformat(value)
410442
case _:
411443
return value
@@ -483,7 +515,7 @@ async def save_model(self, id: UUID | int | str | None, payload: dict) -> dict |
483515
if m2m_field.name in payload:
484516
await self.orm_save_m2m_ids(obj, m2m_field.column_name, payload[m2m_field.name])
485517

486-
return await self.serialize_obj(obj)
518+
return await self._serialize_obj_after_save(obj)
487519

488520
async def delete_model(self, id: UUID | int | str) -> None:
489521
"""This method is used to delete orm/db model object.

fastadmin/models/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ async def generate_models_schema(
198198
empty_value_display=admin_model_obj.empty_value_display,
199199
filter_widget_type=None,
200200
filter_widget_props=None,
201-
width=None,
201+
width=admin_model_obj.list_display_widths.get(field_name, None),
202202
),
203203
add_configuration=None,
204204
change_configuration=None,

fastadmin/models/orms/sqlalchemy.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,15 @@ async def orm_get_obj(self, id: UUID | int | str) -> Any | None:
329329
async with sessionmaker() as session:
330330
return await session.get(self.model_cls, id)
331331

332+
async def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
333+
"""Serialize object by id inside a single session to avoid DetachedInstanceError."""
334+
sessionmaker = self.get_sessionmaker()
335+
async with sessionmaker() as session:
336+
obj = await session.get(self.model_cls, id)
337+
if obj is None:
338+
return None
339+
return await self.serialize_obj(obj)
340+
332341
def _get_foreign_key_fields(self) -> list[str]:
333342
"""Returns a list of foreign key fields for the model.
334343

0 commit comments

Comments
 (0)