Skip to content

Commit c7d860c

Browse files
authored
Merge pull request #3712 from dimagi/cs/export_apis_followup
feat: expand team-export surface to remaining team models
2 parents 68252d6 + 4b92088 commit c7d860c

18 files changed

Lines changed: 5860 additions & 805 deletions

api-schemas/export.yml

Lines changed: 4899 additions & 728 deletions
Large diffs are not rendered by default.

apps/api/export/serializers.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33

44
from 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
610
from drf_spectacular.utils import OpenApiResponse, extend_schema_field
11+
from pgvector.django import HalfVectorField, VectorField
712
from rest_framework import serializers
813

914
from apps.teams.export.manifest import (
@@ -13,20 +18,64 @@
1318
TEAM_MODEL,
1419
ManifestEntry,
1520
entry_model,
21+
generic_fk_fields,
1622
model_has_team_field,
1723
)
1824
from apps.teams.export.seal import seal
1925
from 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+
2271
class _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+
53119
def _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

apps/api/export/tests/test_serializers.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,41 @@
1+
import json
2+
13
import pytest
24
from cryptography.hazmat.primitives import serialization
35
from cryptography.hazmat.primitives.asymmetric import rsa
6+
from django.conf import settings
47
from django.contrib.auth.models import Group
58
from django.db import connection
69
from django.test.utils import CaptureQueriesContext
710
from rest_framework import serializers
11+
from rest_framework.renderers import JSONRenderer
812

913
from apps.api.export.serializers import (
1014
ManifestEntrySerializer,
1115
ManifestSerializer,
1216
build_resource_response_serializer,
1317
build_resource_serializer,
1418
)
19+
from apps.assessments.models import Score
1520
from apps.chat.models import Chat
21+
from apps.documents.models import CollectionFile
1622
from apps.experiments.models import Experiment, ExperimentSession, ParticipantData
23+
from apps.files.models import File, FileChunkEmbedding
1724
from apps.service_providers.models import LlmProvider, LlmProviderModel
1825
from apps.teams.export import seal as seal_mod
1926
from apps.teams.export.manifest import build_manifest, entry_model, get_manifest_entry, team_scoped_queryset
2027
from apps.teams.models import Membership, Team
2128
from apps.users.models import CustomUser
29+
from apps.utils.factories.assessments import ScoreFactory
30+
from apps.utils.factories.documents import CollectionFileFactory
2231
from apps.utils.factories.experiment import (
2332
ChatFactory,
2433
ConsentFormFactory,
2534
ExperimentFactory,
2635
ExperimentSessionFactory,
2736
ParticipantFactory,
2837
)
38+
from apps.utils.factories.files import FileChunkEmbeddingFactory, FileFactory
2939
from apps.utils.factories.service_provider_factories import LlmProviderFactory
3040
from apps.utils.factories.team import TeamFactory
3141
from apps.utils.factories.user import UserFactory
@@ -188,6 +198,16 @@ def test_global_able_model_exposes_is_global_flag_instead_of_team(is_global):
188198
assert "team" not in data
189199

190200

201+
def test_generic_fk_content_type_serializes_as_model_label():
202+
"""A generic FK's content type is emitted as its ``app_label.model`` label, not the source
203+
ContentType pk (which is server-specific). The object id stays the source target-row pk; the
204+
importer resolves both on the target."""
205+
score = ScoreFactory() # target is an ExperimentSession
206+
data = _serialize(Score, score)
207+
assert data["target_content_type"] == "experiments.experimentsession"
208+
assert data["target_object_id"] == score.target_object_id
209+
210+
191211
def test_response_envelope_has_cursor_has_more_and_results():
192212
fields = build_resource_response_serializer(get_manifest_entry("users"))().fields
193213
assert set(fields) == {"cursor", "has_more", "results"}
@@ -208,3 +228,34 @@ def test_manifest_serializer_matches_build_manifest_payload():
208228
declared = set(ManifestEntrySerializer().fields)
209229
actual_keys = set().union(*(entry.keys() for entry in manifest["entries"]))
210230
assert declared == actual_keys
231+
232+
233+
def test_file_field_serializes_as_relative_name_not_media_url():
234+
"""A FileField serializes to its stored relative name so the importer can assign it straight
235+
back; the default ``/media/...`` URL is an absolute path Django's storage rejects on save."""
236+
file = FileFactory()
237+
data = build_resource_serializer(File)(file, context={"public_key": None}).data
238+
assert data["file"] == file.file.name
239+
assert not data["file"].startswith("/")
240+
241+
242+
def test_halfvector_field_serializes_as_list_of_floats():
243+
"""A pgvector column reads back as a non-iterable HalfVector object; the serializer must still
244+
emit a plain list of floats the importer can assign straight back."""
245+
size = settings.EMBEDDING_VECTOR_SIZE
246+
created = FileChunkEmbeddingFactory(embedding=[0.5] * size)
247+
instance = FileChunkEmbedding.objects.get(pk=created.pk) # re-fetch so embedding is the DB object
248+
data = build_resource_serializer(FileChunkEmbedding)(instance, context={"public_key": None}).data
249+
assert data["embedding"] == [0.5] * size
250+
251+
252+
def test_pydantic_schema_field_serializes_as_json_object():
253+
"""A SchemaField (pydantic) serializes to a JSON object. The default JSONField mapping leaves the
254+
pydantic instance in place, which DRF's JSON encoder mangles into (field, value) pairs the
255+
importer can't read back. The corruption is at encode time, so render to JSON to catch it."""
256+
metadata = {"chunking_strategy": {"chunk_size": 400, "chunk_overlap": 200}}
257+
cf = CollectionFileFactory(metadata=metadata)
258+
instance = CollectionFile.objects.get(pk=cf.pk)
259+
data = build_resource_serializer(CollectionFile)(instance, context={"public_key": None}).data
260+
rendered = json.loads(JSONRenderer().render(data))
261+
assert rendered["metadata"] == metadata

apps/api/export/tests/test_views.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
from django.utils import timezone
1010
from rest_framework.test import APIClient
1111

12+
from apps.documents.datamodels import DocumentSourceConfig, GitHubSourceConfig
1213
from apps.experiments.models import Participant
1314
from apps.pipelines.models import Pipeline
1415
from apps.teams.export import seal as seal_mod
16+
from apps.utils.factories.documents import CollectionFactory, DocumentSourceFactory
1517
from apps.utils.factories.experiment import ConsentFormFactory, ParticipantFactory
1618
from apps.utils.factories.pipelines import PipelineFactory
1719
from apps.utils.factories.service_provider_factories import LlmProviderFactory, LlmProviderModelFactory
@@ -240,3 +242,27 @@ def test_updated_at_id_cursor_pages_ties_without_skip_or_repeat():
240242
break
241243

242244
assert sorted(collected) == sorted(p.id for p in parts) # each tied row exactly once
245+
246+
247+
def test_document_sources_resource_seals_config():
248+
"""A document_sources row seals its config field through the HTTP layer and is team-scoped."""
249+
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
250+
public_pem = private.public_key().public_bytes(
251+
encoding=serialization.Encoding.PEM,
252+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
253+
)
254+
team = TeamWithUsersFactory(public_key=public_pem.decode())
255+
collection = CollectionFactory(team=team)
256+
secret_token = "ghp_supersecrettoken"
257+
config = DocumentSourceConfig(
258+
github=GitHubSourceConfig(repo_url="https://github.com/dimagi/open-chat-studio", branch=secret_token)
259+
)
260+
DocumentSourceFactory(team=team, collection=collection, config=config)
261+
client = ApiTestClient(_admin(team), team)
262+
response = client.get(_resource_url("document_sources"))
263+
assert response.status_code == 200
264+
sealed = response.json()["results"][0]["config"]
265+
# The response must be an opaque sealed token, not plaintext config.
266+
assert secret_token not in str(sealed)
267+
unsealed = seal_mod.unseal(sealed, private)
268+
assert unsealed["github"]["branch"] == secret_token

0 commit comments

Comments
 (0)