diff --git a/freeadmin/contrib/adapters/tortoise/adapter.py b/freeadmin/contrib/adapters/tortoise/adapter.py index cc58957..10c64c7 100644 --- a/freeadmin/contrib/adapters/tortoise/adapter.py +++ b/freeadmin/contrib/adapters/tortoise/adapter.py @@ -17,6 +17,7 @@ from __future__ import annotations from inspect import isawaitable +import json from typing import Any, Iterable from tortoise import Tortoise, connections @@ -118,6 +119,27 @@ def _normalize_relation_value(self, field: Any, value: Any) -> Any: return int(value) return value + def _normalize_json_value(self, value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except (TypeError, ValueError, json.JSONDecodeError): + return value + return value + + def _normalize_m2m_value(self, value: Any) -> list[Any]: + parsed = value + if isinstance(value, str): + try: + parsed = json.loads(value) + except (TypeError, ValueError, json.JSONDecodeError): + parsed = value + if parsed is None: + return [] + if isinstance(parsed, (list, tuple, set)): + return list(parsed) + return [parsed] + def normalize_import_data(self, model: type[Model], data: dict[str, Any]) -> dict[str, Any]: """Convert raw import values into ORM-friendly types.""" meta = getattr(model, "_meta", None) @@ -129,6 +151,12 @@ def normalize_import_data(self, model: type[Model], data: dict[str, Any]) -> dic if not field: cleaned[name] = value continue + if isinstance(field, fields.JSONField): + cleaned[name] = self._normalize_json_value(value) + continue + if isinstance(field, fields.relational.ManyToManyFieldInstance): + cleaned[name] = self._normalize_m2m_value(value) + continue if isinstance( field, ( @@ -321,13 +349,7 @@ async def create( if fname not in data: continue value = data.pop(fname) - if value is None: - m2m_values[fname] = [] - continue - if isinstance(value, (list, tuple, set)): - m2m_values[fname] = list(value) - else: - m2m_values[fname] = [value] + m2m_values[fname] = self._normalize_m2m_value(value) data = self.normalize_import_data(model_cls, data) obj = await model_cls.create(**data) diff --git a/freeadmin/core/interface/services/export.py b/freeadmin/core/interface/services/export.py index 2e0ab88..bdf05f6 100644 --- a/freeadmin/core/interface/services/export.py +++ b/freeadmin/core/interface/services/export.py @@ -37,15 +37,29 @@ class FieldSerializer: Supported types include ``datetime``/``date`` (ISO 8601), ``list``/``tuple`` (recursively serialized), ``set`` (converted to list) and nested - structures like ``dict``. + structures like ``dict``. When possible, the serializer leverages model + descriptors to format JSON and many-to-many fields into stable string + representations suitable for tabular exports. """ + def __init__(self, adapter: BaseAdapter | None = None) -> None: + """Create a serializer optionally aware of the admin adapter.""" + + self.adapter = adapter + self._descriptor_cache: dict[type[Any], Any] = {} + def serialize(self, obj: Any, field: str) -> Any: """Return serialized value for ``field`` on ``obj``.""" id_field = f"{field}_id" if hasattr(obj, id_field): return getattr(obj, id_field) value = getattr(obj, field, None) + descriptor = self._field_descriptor_for(obj, field) + if descriptor is not None: + if descriptor.kind == "json": + return self._serialize_json_value(value) + if getattr(descriptor.relation, "kind", None) == "m2m": + return self._serialize_m2m_value(value) return self._serialize(value) def _serialize(self, value: Any) -> Any: @@ -103,6 +117,43 @@ def _serialize(self, value: Any) -> Any: pass return str(value) + def _serialize_json_value(self, value: Any) -> str: + parsed = value + if isinstance(value, str): + try: + parsed = json.loads(value) + except (TypeError, ValueError, json.JSONDecodeError): + parsed = value + prepared = self._serialize(parsed) + return json.dumps(prepared, ensure_ascii=False) + + def _serialize_m2m_value(self, value: Any) -> str: + prepared = self._serialize(value) + if isinstance(prepared, str): + try: + prepared = json.loads(prepared) + except (TypeError, ValueError, json.JSONDecodeError): + prepared = [prepared] + if prepared is None: + prepared = [] + if not isinstance(prepared, list): + prepared = [prepared] + filtered = [v for v in prepared if v is not None] + return json.dumps(filtered, ensure_ascii=False) + + def _field_descriptor_for(self, obj: Any, field: str) -> Any: + if self.adapter is None: + return None + model_cls = obj.__class__ + descriptor = self._descriptor_cache.get(model_cls) + if descriptor is None: + descriptor = self.adapter.get_model_descriptor(model_cls) + self._descriptor_cache[model_cls] = descriptor + try: + return descriptor.fields_map[field] + except Exception: + return None + @dataclass class CachedFile: @@ -525,7 +576,7 @@ def __init__( self.settings = settings or current_settings() self.tmp_dir = tmp_dir or tempfile.gettempdir() self.ttl = ttl if ttl is not None else self.settings.export_cache_ttl - self.serializer = serializer or FieldSerializer() + self.serializer = serializer or FieldSerializer(adapter) self.cache = cache_backend or SQLiteExportCacheBackend( path=self.settings.export_cache_path )