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 14 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
11 changes: 11 additions & 0 deletions api/features/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,14 @@
# Feature state statuses
COMMITTED = "COMMITTED"
DRAFT = "DRAFT"

# Feature import strategies
SKIP = "SKIP"
OVERWRITE = "OVERWRITE"
FEATURE_IMPORT_STRATEGIES = (
(SKIP, "Skip"),
(OVERWRITE, "Overwrite"),
)

MAX_FEATURE_EXPORT_SIZE = 1000_000
MAX_FEATURE_IMPORT_SIZE = MAX_FEATURE_EXPORT_SIZE
24 changes: 24 additions & 0 deletions api/features/migrations/0061_featureexport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 3.2.23 on 2023-11-21 20:01

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


class Migration(migrations.Migration):

dependencies = [
('environments', '0032_rename_use_mv_v2_evaluation_to_use_in_percentage_split_evaluation'),
('features', '0060_feature_group_owners'),
]

operations = [
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_date', models.DateTimeField(auto_now_add=True, verbose_name='DateCreated')),
('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_exports', to='environments.environment')),
],
),
]
30 changes: 30 additions & 0 deletions api/features/migrations/0062_auto_20231121_2028.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 3.2.23 on 2023-11-21 20:28

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


class Migration(migrations.Migration):

dependencies = [
('environments', '0032_rename_use_mv_v2_evaluation_to_use_in_percentage_split_evaluation'),
('features', '0061_featureexport'),
]

operations = [
migrations.AlterField(
model_name='featureexport',
name='environment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_imports', to='environments.environment'),
),
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', 'Overwrite')], max_length=50)),
('data', models.CharField(max_length=1000000)),
('created_date', models.DateTimeField(auto_now_add=True, verbose_name='DateCreated')),
('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_exports', to='environments.environment')),
],
),
]
60 changes: 59 additions & 1 deletion api/features/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@
from environments.identities.helpers import (
get_hashed_percentage_for_object_ids,
)
from features.constants import ENVIRONMENT, FEATURE_SEGMENT, IDENTITY
from features.constants import (
ENVIRONMENT,
FEATURE_IMPORT_STRATEGIES,
FEATURE_SEGMENT,
IDENTITY,
MAX_FEATURE_EXPORT_SIZE,
MAX_FEATURE_IMPORT_SIZE,
)
from features.custom_lifecycle import CustomLifecycleModelMixin
from features.feature_states.models import AbstractBaseFeatureValueModel
from features.feature_types import MULTIVARIATE, STANDARD
Expand Down Expand Up @@ -933,6 +940,8 @@ class FeatureStateValue(
related_object_type = RelatedObjectType.FEATURE_STATE
history_record_class_path = "features.models.HistoricalFeatureStateValue"

# After a FeatureState is created, a FeatureStateValue is
# automatically created in a post create hook.
feature_state = models.OneToOneField(
FeatureState, related_name="feature_state_value", on_delete=models.CASCADE
)
Expand Down Expand Up @@ -984,3 +993,52 @@ def get_update_log_message(self, history_instance) -> typing.Optional[str]:

def _get_environment(self) -> typing.Optional["Environment"]:
return self.feature_state.environment


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_date = models.DateTimeField("DateCreated", 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,
)

# This is a JSON string of data generated by the export.
data = models.CharField(max_length=MAX_FEATURE_IMPORT_SIZE)
created_date = models.DateTimeField("DateCreated", auto_now_add=True)
7 changes: 7 additions & 0 deletions api/features/multivariate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ class MultivariateFeatureOption(
AbstractBaseExportableModel,
abstract_base_auditable_model_factory(["uuid"]),
):
"""
This class holds the *value* for a given multivariate feature
option. This value is the same for every environment, but the
percent allocation is set in MultivariateFeatureStateValue
which varies per-environment.
"""

history_record_class_path = (
"features.multivariate.models.HistoricalMultivariateFeatureOption"
)
Expand Down
46 changes: 45 additions & 1 deletion api/features/permissions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from contextlib import suppress

from django.shortcuts import get_object_or_404
from rest_framework.generics import ListAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.viewsets import GenericViewSet
Expand All @@ -14,7 +15,7 @@
UPDATE_FEATURE_STATE,
VIEW_ENVIRONMENT,
)
from features.models import Feature, FeatureState
from features.models import Feature, FeatureExport, FeatureState
from projects.models import Project
from projects.permissions import CREATE_FEATURE, DELETE_FEATURE
from projects.permissions import (
Expand Down Expand Up @@ -185,3 +186,46 @@ def has_permission(self, request, view):
permission=MANAGE_SEGMENT_OVERRIDES,
environment=environment,
)


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"])
return request.user.has_project_permission(VIEW_PROJECT, project)
35 changes: 34 additions & 1 deletion api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@
from .feature_segments.serializers import (
CreateSegmentOverrideFeatureSegmentSerializer,
)
from .models import Feature, FeatureState, FeatureStateValue
from .models import (
Feature,
FeatureExport,
FeatureImport,
FeatureState,
FeatureStateValue,
)
from .multivariate.serializers import (
MultivariateFeatureStateValueSerializer,
NestedMultivariateFeatureOptionSerializer,
Expand Down Expand Up @@ -495,3 +501,30 @@ def validate_environment_segment_override_limit(
"environment": "The environment has reached the maximum allowed segments overrides limit."
}
)


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


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

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

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


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