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
26 changes: 26 additions & 0 deletions api/features/import_export/permissions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from rest_framework.generics import ListAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.viewsets import GenericViewSet

from environments.models import Environment
from features.import_export.models import FeatureExport
Expand Down Expand Up @@ -56,3 +57,28 @@ def has_permission(self, request: Request, view: ListAPIView) -> bool:
# 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: GenericViewSet) -> 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)


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

feature_import = view.get_queryset().filter(pk=view.kwargs["pk"]).first()

if not feature_import:
return False

environment = feature_import.environment
return request.user.is_environment_admin(environment)
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",
)
35 changes: 33 additions & 2 deletions api/features/import_export/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@
from django.db.models import QuerySet
from django.http import Http404
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions
from rest_framework import mixins, permissions, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.generics import ListAPIView, get_object_or_404
from rest_framework.request import Request
from rest_framework.response import Response

from environments.models import Environment

from .models import FeatureExport, FlagsmithOnFlagsmithFeatureExport
from .models import (
FeatureExport,
FeatureImport,
FlagsmithOnFlagsmithFeatureExport,
)
from .permissions import (
CreateFeatureExportPermissions,
DownloadFeatureExportPermissions,
FeatureExportListPermissions,
FeatureImportListPermissions,
FeatureImportPermissions,
FeatureImportRetrievePermissions,
)
from .serializers import (
CreateFeatureExportSerializer,
Expand Down Expand Up @@ -123,3 +129,28 @@ def get_queryset(self) -> QuerySet[FeatureExport]:
return FeatureExport.objects.filter(environment__in=environment_ids).order_by(
"-created_at"
)


class FeatureImportViewSet(viewsets.GenericViewSet, mixins.ListModelMixin):
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"
)


class FeatureImportRetrieve(viewsets.GenericViewSet, mixins.RetrieveModelMixin):
serializer_class = FeatureImportSerializer
permission_classes = [FeatureImportRetrievePermissions]
queryset = FeatureImport.objects.all()
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we're only including one action on each of these viewsets, perhaps we should just use standard APIView classes? ListAPIView / RetrieveAPIView I mean.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thinking about it, I'm not sure why we don't just combine them? I get that in one we have the object to do the permission check, but I don't really see how it's better than just filtering out in the queryset (which would essentially have the same effect as the get_object_or_404 that I suggested above).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

From our discussion on slack: Switch to just using the List and used the ListAPIView as mentioned.

7 changes: 7 additions & 0 deletions api/features/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from features.feature_segments.views import FeatureSegmentViewSet
from features.import_export.views import (
FeatureImportRetrieve,
create_feature_export,
download_feature_export,
download_flagsmith_on_flagsmith,
Expand All @@ -19,6 +20,12 @@
router = routers.DefaultRouter()
router.register(r"featurestates", SimpleFeatureStateViewSet, basename="featurestates")
router.register(r"feature-segments", FeatureSegmentViewSet, basename="feature-segment")
router.register(
r"feature-import-retrieve",
FeatureImportRetrieve,
basename="feature-import-retrieve",
)

app_name = "features"

urlpatterns = [
Expand Down
9 changes: 7 additions & 2 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,
FeatureImportViewSet,
)
from features.multivariate.views import MultivariateFeatureOptionViewSet
from features.views import FeatureViewSet
from integrations.datadog.views import DataDogConfigurationViewSet
Expand Down Expand Up @@ -56,7 +59,9 @@
ProjectAuditLogViewSet,
basename="project-audit",
)

projects_router.register(
r"feature-imports", FeatureImportViewSet, basename="feature-import"
)
nested_features_router = routers.NestedSimpleRouter(
projects_router, r"features", lookup="feature"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,158 @@ 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: Callable[[list[str], int], None],
) -> 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-import-list",
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-import-list",
args=[project.id],
)

# When
response = staff_client.get(url)

# Then
assert response.status_code == 403


def test_retrieve_feature_import(
staff_client: APIClient,
staff_user: FFAdminUser,
project: Project,
with_project_permissions: Callable[[list[str], int], None],
) -> None:
# Given
with_project_permissions([VIEW_PROJECT])
environment = Environment.objects.create(
name="Allowed admin for this environment",
project=project,
)
UserEnvironmentPermission.objects.create(
user=staff_user,
environment=environment,
admin=True,
)

feature_import = FeatureImport.objects.create(
environment=environment,
strategy=OVERWRITE_DESTRUCTIVE,
status=PROCESSING,
data="{}",
)

# Second feature import for testing permission queryset.
FeatureImport.objects.create(
environment=environment,
strategy=OVERWRITE_DESTRUCTIVE,
status=PROCESSING,
data="{}",
)

url = reverse(
"api-v1:features:feature-import-retrieve-detail",
args=[feature_import.id],
)

# When
response = staff_client.get(url)

# Then
assert response.status_code == 200
assert response.data["environment_id"] == environment.id
assert response.data["id"] == feature_import.id
assert response.data["status"] == PROCESSING
assert response.data["strategy"] == OVERWRITE_DESTRUCTIVE


def test_retrieve_feature_import_unauthorized(
staff_client: APIClient,
staff_user: FFAdminUser,
environment: Environment,
project: Project,
with_project_permissions: Callable[[list[str], int], None],
) -> None:
# Given
feature_import = FeatureImport.objects.create(
environment=environment,
strategy=OVERWRITE_DESTRUCTIVE,
status=PROCESSING,
data="{}",
)

url = reverse(
"api-v1:features:feature-import-retrieve-detail",
args=[feature_import.id],
)

# When
response = staff_client.get(url)

# Then
assert response.status_code == 403