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: Create flagsmith on flagsmith feature export task #3149

Merged
merged 13 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,15 @@
"FLAGSMITH_ON_FLAGSMITH_SERVER_API_URL", default=FLAGSMITH_ON_FLAGSMITH_API_URL
)

FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID = env.int(
"FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID",
default=None,
)
FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_TAG_ID = env.int(
"FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_TAG_ID",
default=None,
)

# LDAP setting
LDAP_INSTALLED = importlib.util.find_spec("flagsmith_ldap")
# The URL of the LDAP server.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 3.2.23 on 2023-12-12 19:11

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


class Migration(migrations.Migration):

dependencies = [
('features_import_export', '0002_status_and_data_featureexport'),
]

operations = [
migrations.CreateModel(
name='FlagsmithOnFlagsmithFeatureExport',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('feature_export', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flagsmith_on_flagsmith', to='features_import_export.featureexport')),
],
),
]
18 changes: 18 additions & 0 deletions api/features/import_export/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,21 @@ class FeatureImport(models.Model):
# 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)


class FlagsmithOnFlagsmithFeatureExport(models.Model):
"""
This model is internal to Flagsmith in order to support people
running their own instances of Flagsmith with exports of the
feature flags that Flagsmith uses to enable or disable
features of flagsmith. This model should not be considered
to be useful by third-party developers.
"""

feature_export = models.ForeignKey(
FeatureExport,
related_name="flagsmith_on_flagsmith",
on_delete=models.CASCADE,
)

created_at = models.DateTimeField(auto_now_add=True)
49 changes: 46 additions & 3 deletions api/features/import_export/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from datetime import timedelta
from typing import Optional, Union

from django.conf import settings
from django.db.models import Q
from django.utils import timezone

Expand All @@ -16,8 +17,12 @@
register_task_handler,
)

from .constants import FAILED, OVERWRITE_DESTRUCTIVE, SKIP, SUCCESS
from .models import FeatureExport, FeatureImport
from .constants import FAILED, OVERWRITE_DESTRUCTIVE, PROCESSING, SKIP, SUCCESS
from .models import (
FeatureExport,
FeatureImport,
FlagsmithOnFlagsmithFeatureExport,
)


@register_recurring_task(
Expand Down Expand Up @@ -88,7 +93,6 @@ def export_features_for_environment(
feature_export_id: int, tag_ids: Optional[list[int]] = None
) -> None:
feature_export = FeatureExport.objects.get(id=feature_export_id)

try:
_export_features_for_environment(feature_export, tag_ids)
assert feature_export.status == SUCCESS
Expand Down Expand Up @@ -200,3 +204,42 @@ def _create_new_feature(
)
feature_state.enabled = feature_data["enabled"]
feature_state.save()


# Should only run on official flagsmith instance.
if (
settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID
and settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_TAG_ID
):

@register_recurring_task(
run_every=timedelta(hours=24),
)
def create_flagsmith_on_flagsmith_feature_export_task():
# Defined in a one off function for testing import.
_create_flagsmith_on_flagsmith_feature_export()


def _create_flagsmith_on_flagsmith_feature_export():
"""
This is called by create_flagsmith_on_flagsmith_feature_export_task
and by tests. Should not be used by normal applications.
"""
environment_id = settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID
tag_id = settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_TAG_ID

feature_export = FeatureExport.objects.create(
environment_id=environment_id,
status=PROCESSING,
)

export_features_for_environment(
feature_export_id=feature_export.id,
tag_ids=[tag_id],
)

feature_export.refresh_from_db()

assert feature_export.status == SUCCESS

FlagsmithOnFlagsmithFeatureExport.objects.create(feature_export=feature_export)
34 changes: 33 additions & 1 deletion api/features/import_export/views.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import json

from django.conf import settings
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.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
from .models import FeatureExport, FlagsmithOnFlagsmithFeatureExport
from .permissions import (
CreateFeatureExportPermissions,
DownloadFeatureExportPermissions,
Expand Down Expand Up @@ -74,6 +77,35 @@ def download_feature_export(request: Request, feature_export_id: int) -> Respons
return response


@swagger_auto_schema(
method="GET",
responses={200: "Flagsmith on Flagsmith File downloaded"},
operation_description="This endpoint is to download an feature export to enable flagsmith on flagsmith",
)
@api_view(["GET"])
@permission_classes([permissions.AllowAny])
def download_flagsmith_on_flagsmith(request: Request) -> Response:
if (
not settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID
or not settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_TAG_ID
):
# No explicit settings on both feature settings, so 404.
raise Http404("This system is not configured for this download.")

fof = FlagsmithOnFlagsmithFeatureExport.objects.order_by("-created_at").first()

if fof is None:
raise Http404("There is no present downloadable export.")

response = Response(
json.loads(fof.feature_export.data), content_type="application/json"
)
response.headers[
"Content-Disposition"
] = f"attachment; filename=flagsmith_on_flagsmith.{fof.id}.json"
return response


class FeatureExportListView(ListAPIView):
serializer_class = FeatureExportSerializer
permission_classes = [FeatureExportListPermissions]
Expand Down
6 changes: 6 additions & 0 deletions api/features/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from features.import_export.views import (
create_feature_export,
download_feature_export,
download_flagsmith_on_flagsmith,
feature_import,
)
from features.views import (
Expand All @@ -29,6 +30,11 @@
download_feature_export,
name="download-feature-export",
),
path(
"download-flagsmith-on-flagsmith/",
download_flagsmith_on_flagsmith,
name="download-flagsmith-on-flagsmith",
),
path(
"feature-import/<int:environment_id>",
feature_import,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
from datetime import timedelta

import pytest
from django.utils import timezone
from freezegun.api import FrozenDateTimeFactory
from pytest_django.fixtures import SettingsWrapper

from environments.identities.models import Identity
from environments.models import Environment
Expand All @@ -11,9 +13,15 @@
OVERWRITE_DESTRUCTIVE,
PROCESSING,
SKIP,
SUCCESS,
)
from features.import_export.models import (
FeatureExport,
FeatureImport,
FlagsmithOnFlagsmithFeatureExport,
)
from features.import_export.models import FeatureExport, FeatureImport
from features.import_export.tasks import (
_create_flagsmith_on_flagsmith_feature_export,
clear_stale_feature_imports_and_exports,
export_features_for_environment,
import_features_for_environment,
Expand Down Expand Up @@ -314,3 +322,44 @@ def test_export_and_import_features_for_environment_with_overwrite_destructive(
assert new_feature_state3.enabled is True
assert new_feature_state3.feature_state_value.type == STRING
assert new_feature_state3.feature_state_value.value == "changed"


def test_create_flagsmith_on_flagsmith_feature_export(
db: None,
settings: SettingsWrapper,
environment: Environment,
project: Project,
) -> None:
# Given
flagsmith_tag = Tag.objects.create(
label="flagsmith-on-flagsmith", project=project, color="#228B22"
)
feature = Feature.objects.create(
name="fof_feature",
project=project,
initial_value="200",
is_server_key_only=True,
default_enabled=False,
)
feature.tags.add(flagsmith_tag)
feature_state = feature.feature_states.get(environment=environment)
feature_state.enabled = True
feature_state.save()

settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID = environment.id
settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_TAG_ID = flagsmith_tag.id
assert FlagsmithOnFlagsmithFeatureExport.objects.count() == 0

# When
_create_flagsmith_on_flagsmith_feature_export()

# Then
assert FlagsmithOnFlagsmithFeatureExport.objects.count() == 1
fof = FlagsmithOnFlagsmithFeatureExport.objects.first()
assert fof.feature_export.status == SUCCESS

data = json.loads(fof.feature_export.data)
assert len(data) == 1
assert data[0]["name"] == "fof_feature"
assert data[0]["default_enabled"] is False
assert data[0]["enabled"] is True
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from pytest_django.fixtures import SettingsWrapper
from pytest_mock import MockerFixture
from rest_framework.test import APIClient

from environments.models import Environment
from environments.permissions.models import UserEnvironmentPermission
from features.import_export.constants import OVERWRITE_DESTRUCTIVE, PROCESSING
from features.import_export.models import FeatureExport, FeatureImport
from features.import_export.models import (
FeatureExport,
FeatureImport,
FlagsmithOnFlagsmithFeatureExport,
)
from projects.models import Project
from projects.permissions import VIEW_PROJECT
from projects.tags.models import Tag
Expand Down Expand Up @@ -278,3 +283,59 @@ def test_create_feature_export_unauthorized(
assert response.json() == {
"detail": "You do not have permission to perform this action."
}


def test_download_flagsmith_on_flagsmith_when_none(
api_client: APIClient,
environment: Environment,
settings: SettingsWrapper,
) -> None:
# Given
tag = Tag.objects.create(
label="flagsmith-on-flagsmith",
project=environment.project,
color="#228B22",
)

settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID = environment.id
settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_TAG_ID = tag.id

url = reverse("api-v1:features:download-flagsmith-on-flagsmith")

# When
response = api_client.get(url)

# Then
assert response.status_code == 404


def test_download_flagsmith_on_flagsmith_when_success(
api_client: APIClient,
environment: Environment,
settings: SettingsWrapper,
) -> None:
# Given
tag = Tag.objects.create(
label="flagsmith-on-flagsmith",
project=environment.project,
color="#228B22",
)

settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_ENVIRONMENT_ID = environment.id
settings.FLAGSMITH_ON_FLAGSMITH_FEATURE_EXPORT_TAG_ID = tag.id

feature_export = FeatureExport.objects.create(
environment=environment,
data='[{"feature": "data"}]',
)
FlagsmithOnFlagsmithFeatureExport.objects.create(
feature_export=feature_export,
)
url = reverse("api-v1:features:download-flagsmith-on-flagsmith")

# When
response = api_client.get(url)

# Then
assert response.status_code == 200
assert response.data == [{"feature": "data"}]