Skip to content

Project export endpoint #7126

@khvn26

Description

@khvn26

Scope

  • New module: api/features/project_import_export/
  • New model: ProjectExport
    • Inherits abstract_base_auditable_model_factory() for audit log entries.
    • history_record_class_path, related_object_type (new RelatedObjectType.PROJECT_EXPORT)
    • project FK
    • created_by FK to FFAdminUser (nullable)
    • created_by_api_key FK to MasterAPIKey (nullable)
    • status (PROCESSING / SUCCESS / FAILED)
    • dataTextField. Max size 10MB (vs 1MB for env-level).
    • created_at
    • include_identity_overrides bool
    • include_tags bool
    • Audit log messages:
      • get_create_log_message: "Project export requested"
      • get_update_log_message: "Project export completed" or "Project export failed"
    • get_audit_log_author returns self.created_by
    • _get_project returns self.project
  • New task processor task: export_project(project_export_id: int)
    • Query project data and serialize using the export serializers above.
    • Wrap in ProjectExportDataSerializer.
    • Store in data field, set status = SUCCESS.
  • New views:
    • POST /projects/{project_id}/project-exports/ — create export (accepts optional toggle booleans).
    • GET /projects/{project_id}/project-exports/ — list exports.
    • GET /projects/{project_id}/project-exports/{id}/download/ — download JSON file.
  • Request/response serializers for create (with toggle fields) and list.
  • Permissions: project admin.
  • Recurring cleanup task: delete exports older than 14 days.
  • Migration for new model.
  • Tests: 100% diff coverage.

Acceptance criteria (write as tests first)

  • POST with toggles creates an async export, returns 202.
  • GET list shows all exports for the project with status.
  • GET download returns the JSON file with correct Content-Disposition header.
  • Export JSON matches the schema in Epic: Project-Scoped Export/Import #7125 exactly.
  • Exports without optional data omit those top-level keys entirely.
  • Stale exports are cleaned up after 14 days.
  • Adding a new field to an existing model serializer (e.g. a new environment setting) should automatically appear in the export without modifying export code.

Serializer composition strategy

New fields added to models or existing serializers should automatically appear in project exports
without modifying the export code. We achieve this by subclassing existing DRF serializers and
using exclude instead of fields on Meta classes.

For each entity, create a thin export serializer that inherits from the existing serializer and
overrides only what's needed:

# Self-contained sub-objects — reuse directly, new fields auto-included:
#   FeatureStateValueSerializer         → {type, string_value, integer_value, boolean_value}
#   NestedMultivariateFeatureOptionSerializer → {type, string_value, ..., default_percentage_allocation}
#   SegmentRuleSerializer               → {type, rules[], conditions[]}
#   ConditionSerializer                 → {operator, property_, value, description}

# Top-level entities — thin subclasses with exclude + FK overrides:

class TagExportSerializer(TagSerializer):
    class Meta(TagSerializer.Meta):
        exclude = ("id", "uuid", "project", "is_system_tag", "type")


class SegmentExportSerializer(SegmentSerializer):
    class Meta(SegmentSerializer.Meta):
        exclude = ("id", "uuid", "project", "feature", "version_of",
                   "created_at", "updated_at", "metadata")


class FeatureExportSerializer(CreateFeatureSerializer):
    tags = serializers.SlugRelatedField(slug_field="label", many=True, read_only=True)

    class Meta(CreateFeatureSerializer.Meta):
        exclude = ("id", "uuid", "project", "created_date", "owners", "group_owners",
                   "is_archived")


class EnvironmentExportSerializer(EnvironmentSerializerLight):
    feature_states = FeatureStateExportSerializer(many=True, read_only=True)
    feature_segments = FeatureSegmentExportSerializer(many=True, read_only=True)
    identity_overrides = IdentityOverrideExportSerializer(many=True, read_only=True)

    class Meta(EnvironmentSerializerLight.Meta):
        exclude = ("id", "uuid", "project", "api_key", "is_creating",
                   "webhooks_enabled", "webhook_url", "updated_at", "created_date")


class FeatureStateExportSerializer(serializers.Serializer):
    feature = serializers.SlugRelatedField(slug_field="name", read_only=True)
    enabled = serializers.BooleanField()
    feature_state_value = FeatureStateValueSerializer()
    multivariate_feature_state_values = MVStateValueExportSerializer(many=True)


class FeatureSegmentExportSerializer(serializers.Serializer):
    feature = serializers.SlugRelatedField(slug_field="name", read_only=True)
    segment = serializers.SlugRelatedField(slug_field="name", read_only=True)
    priority = serializers.IntegerField()
    feature_state = FeatureStateExportSerializer()

class ProjectExportDataSerializer(serializers.Serializer):
    version = serializers.IntegerField()
    exported_at = serializers.DateTimeField()
    tags = TagExportSerializer(many=True, required=False)
    segments = SegmentExportSerializer(many=True)
    features = FeatureExportSerializer(many=True)
    environments = EnvironmentExportSerializer(many=True)

The pattern: exclude on Meta so new model fields are auto-included. FK fields overridden to
SlugRelatedField (name-based) or dropped (implicit from nesting). Nested serializers
(FeatureStateValueSerializer, SegmentRuleSerializer, etc.) reused directly.
multivariate_feature_option in MVStateValueExportSerializer is the only custom field —
resolves the FK to an array index.

Metadata

Metadata

Assignees

Labels

apiIssue related to the REST API

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions