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: Add endpoints for feature imports #3255

Merged
merged 14 commits into from
Jan 17, 2024
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