Skip to content

Commit

Permalink
feat: Add endpoints for feature imports (#3255)
Browse files Browse the repository at this point in the history
  • Loading branch information
zachaysan committed Jan 17, 2024
1 parent 83449bb commit a2eeaf4
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 6 deletions.
13 changes: 12 additions & 1 deletion api/features/import_export/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,18 @@ 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"])
project = Project.objects.get(id=view.kwargs["project_pk"])
# The user will only see environment feature exports
# that the user is an environment admin.
return request.user.has_project_permission(VIEW_PROJECT, project)


class FeatureImportListPermissions(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_pk"])
# The user will only see environment feature imports
# that the user is an environment admin.
return request.user.has_project_permission(VIEW_PROJECT, project)
1 change: 1 addition & 0 deletions api/features/import_export/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class Meta:
fields = (
"id",
"environment_id",
"strategy",
"status",
"created_at",
)
13 changes: 13 additions & 0 deletions api/features/import_export/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@ def export_features_for_environment(
@register_task_handler()
def import_features_for_environment(feature_import_id: int) -> None:
feature_import = FeatureImport.objects.get(id=feature_import_id)
try:
_import_features_for_environment(feature_import)
assert feature_import.status == SUCCESS
except Exception:
feature_import.status = FAILED
feature_import.save()
raise


def _import_features_for_environment(feature_import: FeatureImport) -> None:
environment = feature_import.environment
input_data = json.loads(feature_import.data)
project = environment.project
Expand All @@ -126,6 +136,9 @@ def import_features_for_environment(feature_import_id: int) -> None:

_create_new_feature(feature_data, project, environment)

feature_import.status = SUCCESS
feature_import.save()


def _save_feature_state_value_with_type(
value: Optional[Union[int, bool, str]],
Expand Down
28 changes: 26 additions & 2 deletions api/features/import_export/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@

from environments.models import Environment

from .models import FeatureExport, FlagsmithOnFlagsmithFeatureExport
from .models import (
FeatureExport,
FeatureImport,
FlagsmithOnFlagsmithFeatureExport,
)
from .permissions import (
CreateFeatureExportPermissions,
DownloadFeatureExportPermissions,
FeatureExportListPermissions,
FeatureImportListPermissions,
FeatureImportPermissions,
)
from .serializers import (
Expand Down Expand Up @@ -115,11 +120,30 @@ def get_queryset(self) -> QuerySet[FeatureExport]:
user = self.request.user

for environment in Environment.objects.filter(
project_id=self.kwargs["project_id"],
project_id=self.kwargs["project_pk"],
):
if user.is_environment_admin(environment):
environment_ids.append(environment.id)

return FeatureExport.objects.filter(environment__in=environment_ids).order_by(
"-created_at"
)


class FeatureImportListView(ListAPIView):
serializer_class = FeatureImportSerializer
permission_classes = [FeatureImportListPermissions]

def get_queryset(self) -> QuerySet[FeatureImport]:
environment_ids = []
user = self.request.user

for environment in Environment.objects.filter(
project_id=self.kwargs["project_pk"],
):
if user.is_environment_admin(environment):
environment_ids.append(environment.id)

return FeatureImport.objects.filter(environment__in=environment_ids).order_by(
"-created_at"
)
1 change: 1 addition & 0 deletions api/features/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
router = routers.DefaultRouter()
router.register(r"featurestates", SimpleFeatureStateViewSet, basename="featurestates")
router.register(r"feature-segments", FeatureSegmentViewSet, basename="feature-segment")

app_name = "features"

urlpatterns = [
Expand Down
13 changes: 10 additions & 3 deletions api/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
from rest_framework_nested import routers

from audit.views import ProjectAuditLogViewSet
from features.import_export.views import FeatureExportListView
from features.import_export.views import (
FeatureExportListView,
FeatureImportListView,
)
from features.multivariate.views import MultivariateFeatureOptionViewSet
from features.views import FeatureViewSet
from integrations.datadog.views import DataDogConfigurationViewSet
Expand Down Expand Up @@ -56,7 +59,6 @@
ProjectAuditLogViewSet,
basename="project-audit",
)

nested_features_router = routers.NestedSimpleRouter(
projects_router, r"features", lookup="feature"
)
Expand All @@ -76,8 +78,13 @@
name="all-user-permissions",
),
path(
"<int:project_id>/feature-exports/",
"<int:project_pk>/feature-exports/",
FeatureExportListView.as_view(),
name="feature-exports",
),
path(
"<int:project_pk>/feature-imports/",
FeatureImportListView.as_view(),
name="feature-imports",
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ def test_export_and_import_features_for_environment_with_skip(
import_features_for_environment(feature_import.id)

# Then
feature_import.refresh_from_db()
assert feature_import.status == SUCCESS

assert project2.features.count() == 4
overlapping_feature.refresh_from_db()
assert overlapping_feature.deleted_at is None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,82 @@ def test_download_flagsmith_on_flagsmith_when_success(
# Then
assert response.status_code == 200
assert response.data == [{"feature": "data"}]


def test_list_feature_import_with_filtered_environments(
staff_client: APIClient,
staff_user: FFAdminUser,
project: Project,
environment: Environment,
with_project_permissions: WithProjectPermissionsCallable,
) -> None:
# Given
with_project_permissions([VIEW_PROJECT])
environment2 = Environment.objects.create(
name="Allowed admin for this environment",
project=project,
)

# Staff user is only set as an admin on the second environment
UserEnvironmentPermission.objects.create(
user=staff_user,
environment=environment2,
admin=True,
)

# Create a FeatureImport that will be filtered out.
FeatureImport.objects.create(
environment=environment,
strategy=OVERWRITE_DESTRUCTIVE,
status=PROCESSING,
data="{}",
)
# Create a FeatureImport that will be included
feature_import2 = FeatureImport.objects.create(
environment=environment2,
strategy=OVERWRITE_DESTRUCTIVE,
status=PROCESSING,
data="{}",
)

url = reverse(
"api-v1:projects:feature-imports",
args=[project.id],
)

# When
response = staff_client.get(url)

# Then
assert response.status_code == 200
assert response.data["count"] == 1

# Only the second environment is included in the results.
assert response.data["results"][0]["environment_id"] == environment2.id
assert response.data["results"][0]["id"] == feature_import2.id
assert response.data["results"][0]["status"] == PROCESSING
assert response.data["results"][0]["strategy"] == OVERWRITE_DESTRUCTIVE


def test_list_feature_import_unauthorized(
staff_client: APIClient,
project: Project,
environment: Environment,
) -> None:
# Given
FeatureImport.objects.create(
environment=environment,
strategy=OVERWRITE_DESTRUCTIVE,
status=PROCESSING,
data="{}",
)
url = reverse(
"api-v1:projects:feature-imports",
args=[project.id],
)

# When
response = staff_client.get(url)

# Then
assert response.status_code == 403

2 comments on commit a2eeaf4

@vercel
Copy link

@vercel vercel bot commented on a2eeaf4 Jan 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on a2eeaf4 Jan 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.