33
44from functools import cache
55
6+ import pydantic
7+ from django .contrib .contenttypes .models import ContentType
8+ from django .db import models
9+ from django_pydantic_field .fields import PydanticSchemaField
610from drf_spectacular .utils import OpenApiResponse , extend_schema_field
11+ from pgvector .django import HalfVectorField , VectorField
712from rest_framework import serializers
813
914from apps .teams .export .manifest import (
1318 TEAM_MODEL ,
1419 ManifestEntry ,
1520 entry_model ,
21+ generic_fk_fields ,
1622 model_has_team_field ,
1723)
1824from apps .teams .export .seal import seal
1925from apps .teams .models import Flag
2026
2127
28+ def _json_safe (value ):
29+ """Reduce a pydantic object (e.g. CollectionFile.metadata, DocumentSource.config) to plain JSON
30+ data. Non-pydantic values are already JSON-native and pass through."""
31+ return value .model_dump (mode = "json" ) if isinstance (value , pydantic .BaseModel ) else value
32+
33+
34+ class _RelativeFileField (serializers .FileField ):
35+ """Serialize a file as its stored relative path (``value.name``) rather than the default
36+ ``/media/...`` URL. The URL is a presentation form; the importer needs the raw name so it
37+ assigns straight back into a FileField -- an absolute ``/media/`` path is rejected by Django's
38+ storage safe-join (SuspiciousFileOperation)."""
39+
40+ def __init__ (self , * args , ** kwargs ):
41+ kwargs .setdefault ("use_url" , False )
42+ super ().__init__ (* args , ** kwargs )
43+
44+
45+ @extend_schema_field (serializers .ListField (child = serializers .FloatField ()))
46+ class _VectorField (serializers .Field ):
47+ """Serialize a pgvector column as a plain list of floats. Its DB string form (e.g. ``[0.1,0.2]``)
48+ isn't accepted as input, so the importer needs the list to assign it straight back."""
49+
50+ def to_representation (self , value ):
51+ # A HalfVectorField reads back as a pgvector HalfVector object, which isn't iterable but
52+ # exposes .to_list(); a VectorField reads back as an (iterable) numpy array.
53+ if hasattr (value , "to_list" ):
54+ return value .to_list ()
55+ return [float (x ) for x in value ]
56+
57+ def to_internal_value (self , data ):
58+ return data
59+
60+
61+ class _PydanticSchemaField (serializers .JSONField ):
62+ """Serialize a django_pydantic_field SchemaField to a plain JSON object. The default JSONField
63+ mapping leaves the pydantic model instance in place, which DRF's JSON encoder then mangles into a
64+ list of (field, value) pairs (it falls back to iterating the model) that the importer can't read
65+ back into the schema. Subclasses JSONField so DRF's encoder/decoder kwargs are still accepted."""
66+
67+ def to_representation (self , value ):
68+ return _json_safe (value )
69+
70+
2271class _SecretMixin :
2372 secret_fields : list [str ] = []
2473
2574 def to_representation (self , instance ):
2675 data = super ().to_representation (instance )
2776 public_key = self .context .get ("public_key" )
2877 for field in self .secret_fields :
29- data [field ] = seal (getattr (instance , field ), public_key )
78+ data [field ] = seal (_json_safe ( getattr (instance , field ) ), public_key )
3079 return data
3180
3281
@@ -50,6 +99,23 @@ def _team_role_groups(self, user):
5099 return []
51100
52101
102+ def _content_type_label_resolver (ct_field : str ):
103+ """Build the method that emits a generic FK's content type as its ``app_label.model`` label
104+ instead of the source ContentType pk (which differs between servers). Reads the ``*_id`` attribute
105+ and looks the type up by id (cached), so it never triggers a related-object query."""
106+ attname = f"{ ct_field } _id"
107+
108+ @extend_schema_field (serializers .CharField ())
109+ def get_content_type_label (self , instance ) -> str | None :
110+ type_id = getattr (instance , attname )
111+ if type_id is None :
112+ return None
113+ ct = ContentType .objects .get_for_id (type_id )
114+ return f"{ ct .app_label } .{ ct .model } "
115+
116+ return get_content_type_label
117+
118+
53119def _is_global_resolver (null_field : str ):
54120 """Build the ``is_global`` method: a row is global when its scoping field (team or voice
55121 provider) is null. Reads the ``*_id`` attribute so it never triggers a related-object query."""
@@ -107,6 +173,14 @@ def build_resource_serializer(model):
107173 attrs : dict [str , object ] = {
108174 "Meta" : type ("Meta" , (), meta_attrs ),
109175 "secret_fields" : secret_fields ,
176+ "serializer_field_mapping" : {
177+ ** serializers .ModelSerializer .serializer_field_mapping ,
178+ models .FileField : _RelativeFileField ,
179+ models .ImageField : _RelativeFileField ,
180+ HalfVectorField : _VectorField ,
181+ VectorField : _VectorField ,
182+ PydanticSchemaField : _PydanticSchemaField ,
183+ },
110184 }
111185 # Secret fields are sealed to a base64 string at runtime (see _SecretMixin), so document them as
112186 # that string rather than their raw model field type.
@@ -120,6 +194,12 @@ def build_resource_serializer(model):
120194 attrs ["is_global" ] = serializers .SerializerMethodField ()
121195 attrs ["get_is_global" ] = _is_global_resolver (spec .null_field )
122196
197+ # A generic FK's content type goes out as its model label, not the source ContentType pk. The
198+ # object-id column keeps its default (the source target-row pk); the importer resolves both.
199+ for ct_field , _fk_field in generic_fk_fields (model ):
200+ attrs [ct_field ] = serializers .SerializerMethodField ()
201+ attrs [f"get_{ ct_field } " ] = _content_type_label_resolver (ct_field )
202+
123203 return type (f"{ component_name (model )} DetailSerializer" , (_SecretMixin , serializers .ModelSerializer ), attrs )
124204
125205
0 commit comments