Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Import / export of features across environments and orgs #3026

Merged
merged 44 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d073e15
Create FeatureExport
zachaysan Nov 23, 2023
60a00c0
Create FeatureImport
zachaysan Nov 23, 2023
032139c
Add feature import export constants
zachaysan Nov 23, 2023
13a2248
Create tasks to export and import features
zachaysan Nov 23, 2023
0208b89
Add FeatureExport and FeatureImport models
zachaysan Nov 23, 2023
fef74de
Add docstring
zachaysan Nov 23, 2023
dca3241
Create permissions for import export of features
zachaysan Nov 23, 2023
dc6fdef
Create serializers for import export of features
zachaysan Nov 23, 2023
e50a7d3
Add feature import export urls
zachaysan Nov 23, 2023
3b3da88
Add feature export list
zachaysan Nov 23, 2023
ea0eb70
Create views for feature import export
zachaysan Nov 23, 2023
c26e8bb
Create tests for unit feature tasks
zachaysan Nov 23, 2023
52043e6
Fix typing
zachaysan Nov 23, 2023
1c2c3d0
Add tests for feature import export views
zachaysan Nov 23, 2023
03eef19
Fix test
zachaysan Nov 23, 2023
3597604
Fix conflicts and merge branch 'main' into feat/import_export_of_feat…
zachaysan Nov 23, 2023
54c3591
Merge branch 'main' into feat/import_export_of_features_from_environm…
zachaysan Nov 27, 2023
d104ae8
Fix feature export
zachaysan Nov 27, 2023
a62bfe5
Fix feature migration
zachaysan Nov 27, 2023
d33a1df
Update call point to new function
zachaysan Nov 27, 2023
2156cbe
Merge branch 'main' into feat/import_export_of_features_from_environm…
zachaysan Nov 27, 2023
791380b
Create app with models and migrations
zachaysan Nov 29, 2023
9be0602
Switch constants and add statuses
zachaysan Nov 29, 2023
c81204b
Create features import export apps
zachaysan Nov 29, 2023
49ec9c1
Move permissions over to new app and expand code coverage
zachaysan Nov 29, 2023
cc8f5f1
Move serializers over to import export
zachaysan Nov 29, 2023
7d311aa
Move tasks to import export and refactor them
zachaysan Nov 29, 2023
7f868ce
Pull in views from nested app
zachaysan Nov 29, 2023
e18e6df
Switch views over to import export
zachaysan Nov 29, 2023
9708be7
Import new view
zachaysan Nov 29, 2023
396b4ea
Move tasks to import export unit test
zachaysan Nov 29, 2023
32a04ee
Move views to feature import export unit test
zachaysan Nov 29, 2023
f5ae1f2
Merge branch 'main' into feat/import_export_of_features_from_environm…
zachaysan Nov 29, 2023
080d629
Remove
zachaysan Nov 30, 2023
d6876cb
Switch to strategy in serializer
zachaysan Nov 30, 2023
27fb78c
Switch to freezer and update test name
zachaysan Nov 30, 2023
7f4f0d5
Update test to new seriailzer
zachaysan Nov 30, 2023
d2a4c94
Merge branch 'main' into feat/import_export_of_features_from_environm…
zachaysan Nov 30, 2023
760f28b
Move task into serializer save method and add typing
zachaysan Dec 5, 2023
af412aa
Merge branch 'main' into feat/import_export_of_features_from_environm…
zachaysan Dec 5, 2023
8fbf059
Move to serializer save method from view
zachaysan Dec 6, 2023
fc367b8
Merge branch 'main' into feat/import_export_of_features_from_environm…
zachaysan Dec 6, 2023
b4ab74a
Minor PR feedback tweak and linting fix
matthewelwell Dec 7, 2023
91c7188
Merge branch 'main' into feat/import_export_of_features_from_environm…
zachaysan Dec 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"environments.identities",
"environments.identities.traits",
"features",
"features.import_export",
"features.multivariate",
"features.versioning",
"features.workflows.core",
Expand Down
6 changes: 6 additions & 0 deletions api/features/import_export/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class FeaturesImportExport(AppConfig):
name = "features.import_export"
label = "features_import_export"
20 changes: 20 additions & 0 deletions api/features/import_export/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
SKIP = "SKIP"
OVERWRITE_DESTRUCTIVE = "OVERWRITE_DESTRUCTIVE"
FEATURE_IMPORT_STRATEGIES = (
(SKIP, "Skip"),
(OVERWRITE_DESTRUCTIVE, "Overwrite Destructive"),
)

MAX_FEATURE_EXPORT_SIZE = 1000_000
MAX_FEATURE_IMPORT_SIZE = MAX_FEATURE_EXPORT_SIZE


SUCCESS = "SUCCESS"
PROCESSING = "PROCESSING"
FAILED = "FAILED"

FEATURE_IMPORT_STATUSES = (
(SUCCESS, "Success"),
(PROCESSING, "Processing"),
(FAILED, "Failed"),
)
36 changes: 36 additions & 0 deletions api/features/import_export/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 3.2.23 on 2023-11-29 18:44

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
('environments', '0033_add_environment_feature_state_version_logic'),
]

operations = [
migrations.CreateModel(
name='FeatureImport',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('strategy', models.CharField(choices=[('SKIP', 'Skip'), ('OVERWRITE_DESTRUCTIVE', 'Overwrite Destructive')], max_length=50)),
('status', models.CharField(choices=[('SUCCESS', 'Success'), ('PROCESSING', 'Processing'), ('FAILED', 'Failed')], default='PROCESSING', max_length=50)),
('data', models.CharField(max_length=1000000)),
('created_at', models.DateTimeField(auto_now_add=True)),
('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_exports', to='environments.environment')),
],
),
migrations.CreateModel(
name='FeatureExport',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', models.CharField(max_length=1000000)),
('created_at', models.DateTimeField(auto_now_add=True)),
('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_imports', to='environments.environment')),
],
),
]
Empty file.
65 changes: 65 additions & 0 deletions api/features/import_export/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from django.db import models

from features.import_export.constants import (
FEATURE_IMPORT_STATUSES,
FEATURE_IMPORT_STRATEGIES,
MAX_FEATURE_EXPORT_SIZE,
MAX_FEATURE_IMPORT_SIZE,
PROCESSING,
)


class FeatureExport(models.Model):
"""
Stores the representation of an environment's export of
features between the request for the export and the
ultimate download. Records are deleted automatically after
a waiting period.
"""

# The environment the export came from.
environment = models.ForeignKey(
"environments.Environment",
related_name="feature_imports",
on_delete=models.CASCADE,
swappable=True,
)

# This is a JSON string of data used for file download
# once the task has completed assembly.
data = models.CharField(max_length=MAX_FEATURE_EXPORT_SIZE)
created_at = models.DateTimeField(auto_now_add=True)


class FeatureImport(models.Model):
"""
Stores the representation of an environment's import of
features between upload of a previously exported featureset
and the processing of the import. Records are deleted
automatically after a waiting period.
"""

# The environment the features are being imported to.
environment = models.ForeignKey(
"environments.Environment",
related_name="feature_exports",
on_delete=models.CASCADE,
swappable=True,
)
strategy = models.CharField(
choices=FEATURE_IMPORT_STRATEGIES,
max_length=50,
blank=False,
null=False,
)
status = models.CharField(
choices=FEATURE_IMPORT_STATUSES,
max_length=50,
blank=False,
null=False,
default=PROCESSING,
)

# This is a JSON string of data generated by the export.
data = models.CharField(max_length=MAX_FEATURE_IMPORT_SIZE)
created_at = models.DateTimeField(auto_now_add=True)
53 changes: 53 additions & 0 deletions api/features/import_export/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from rest_framework.generics import ListAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request

from environments.models import Environment
from features.import_export.models import FeatureExport
from projects.models import Project
from projects.permissions import VIEW_PROJECT


class FeatureImportPermissions(IsAuthenticated):
def has_permission(
self, request: Request, view: "WrappedAPIView" # noqa: F821
) -> bool:
if not super().has_permission(request, view):
return False

environment = Environment.objects.get(id=view.kwargs["environment_id"])
return request.user.is_environment_admin(environment)


class CreateFeatureExportPermissions(IsAuthenticated):
def has_permission(
self, request: Request, view: "WrappedAPIView" # noqa: F821
) -> bool:
if not super().has_permission(request, view):
return False

environment = Environment.objects.get(id=request.data["environment_id"])
return request.user.is_environment_admin(environment)


class DownloadFeatureExportPermissions(IsAuthenticated):
def has_permission(
self, request: Request, view: "WrappedAPIView" # noqa: F821
) -> bool:
if not super().has_permission(request, view):
return False

feature_export = FeatureExport.objects.get(id=view.kwargs["feature_export_id"])

return request.user.is_environment_admin(feature_export.environment)


class FeatureExportListPermissions(IsAuthenticated):
def has_permission(self, request: Request, view: ListAPIView) -> bool:
if not super().has_permission(request, view):
return False

project = Project.objects.get(id=view.kwargs["project_id"])
# The user will only see environment feature exports
# that the user is an environment admin.
return request.user.has_project_permission(VIEW_PROJECT, project)
61 changes: 61 additions & 0 deletions api/features/import_export/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from django.core.files.uploadedfile import InMemoryUploadedFile
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from .constants import MAX_FEATURE_IMPORT_SIZE, OVERWRITE_DESTRUCTIVE, SKIP
from .models import FeatureExport, FeatureImport
from .tasks import export_features_for_environment


class CreateFeatureExportSerializer(serializers.Serializer):
environment_id = serializers.IntegerField(required=True)
tag_ids = serializers.ListField(child=serializers.IntegerField())

def save(self) -> None:
export_features_for_environment.delay(
kwargs={
"environment_id": self.validated_data["environment_id"],
"tag_ids": self.validated_data["tag_ids"],
}
)


class FeatureExportSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField()

class Meta:
model = FeatureExport
fields = (
"id",
"name",
"environment_id",
"created_at",
)

def get_name(self, obj: FeatureExport) -> str:
return (
f"{obj.environment.name} | {obj.created_at.strftime('%Y-%m-%d %H:%M')} UTC"
)


def validate_feature_import_file_size(upload_file: InMemoryUploadedFile) -> None:
if upload_file.size > MAX_FEATURE_IMPORT_SIZE:
raise ValidationError("File size is too large.")


class FeatureImportUploadSerializer(serializers.Serializer):
file = serializers.FileField(
validators=[validate_feature_import_file_size],
)
strategy = serializers.ChoiceField(choices=[SKIP, OVERWRITE_DESTRUCTIVE])


class FeatureImportSerializer(serializers.ModelSerializer):
class Meta:
model = FeatureImport
fields = (
"id",
"environment_id",
"status",
"created_at",
)
Loading