From 7c4ef9f016918116e23ce19657624d23c1bc3f9c Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 20 May 2025 17:42:36 +0530 Subject: [PATCH 1/4] wip: release pipeline --- api/app/settings/common.py | 9 + api/audit/constants.py | 8 + api/audit/related_object_type.py | 1 + api/conftest.py | 61 +++- .../release_pipelines/core/__init__.py | 0 api/features/release_pipelines/core/apps.py | 7 + .../release_pipelines/core/constants.py | 1 + .../release_pipelines/core/exceptions.py | 10 + .../migrations/0001_add_release_pipelines.py | 285 ++++++++++++++++++ .../core/migrations/__init__.py | 0 api/features/release_pipelines/core/models.py | 162 ++++++++++ ...add_pipeline_stage_to_envfeatureversion.py | 49 +++ api/features/versioning/models.py | 18 +- api/organisations/models.py | 7 + api/poetry.lock | 42 ++- api/projects/urls.py | 9 +- api/pyproject.toml | 7 + api/tests/integration/conftest.py | 2 +- api/tests/types.py | 15 +- .../release_pipeline/core/conftest.py | 23 ++ .../core/test_unit_release_pipeline_models.py | 134 ++++++++ .../test_unit_organisations_models.py | 60 ++++ 22 files changed, 898 insertions(+), 12 deletions(-) create mode 100644 api/features/release_pipelines/core/__init__.py create mode 100644 api/features/release_pipelines/core/apps.py create mode 100644 api/features/release_pipelines/core/constants.py create mode 100644 api/features/release_pipelines/core/exceptions.py create mode 100644 api/features/release_pipelines/core/migrations/0001_add_release_pipelines.py create mode 100644 api/features/release_pipelines/core/migrations/__init__.py create mode 100644 api/features/release_pipelines/core/models.py create mode 100644 api/features/versioning/migrations/0006_add_pipeline_stage_to_envfeatureversion.py create mode 100644 api/tests/unit/features/release_pipeline/core/conftest.py create mode 100644 api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 7436f7a87b0f..b3b4f34fe407 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -113,6 +113,7 @@ "features.multivariate", "features.versioning", "features.workflows.core", + "features.release_pipelines.core", "segments", "app", "e2etests", @@ -1067,6 +1068,14 @@ if importlib.util.find_spec("workflows_logic.stale_flags") is not None: INSTALLED_APPS.append("workflows_logic.stale_flags") +RELEASE_PIPELINES_LOGIC_INSTALLED = ( + importlib.util.find_spec("release_pipelines_logic") is not None +) + +if RELEASE_PIPELINES_LOGIC_INSTALLED: # pragma: no cover + INSTALLED_APPS.append("release_pipelines_logic") + + # Additional functionality for restricting authentication to a set of authentication methods in Flagsmith SaaS AUTH_CONTROLLER_INSTALLED = importlib.util.find_spec("auth_controller") is not None if AUTH_CONTROLLER_INSTALLED: diff --git a/api/audit/constants.py b/api/audit/constants.py index 5f5cd568828b..36656d6f3bcd 100644 --- a/api/audit/constants.py +++ b/api/audit/constants.py @@ -64,3 +64,11 @@ ENVIRONMENT_FEATURE_VERSION_PUBLISHED_MESSAGE = "New version published for feature: %s" DATETIME_FORMAT = "%d/%m/%Y %H:%M:%S" + + +RELEASE_PIPELINE_CREATED_MESSAGE = "Release Pipeline: %s created" +RELEASE_PIPELINE_PUBLISHED_MESSAGE = "Release Pipeline: %s published" +# TODO: Add audit log for pipeline update +RELEASE_PIPELINE_DELETED_MESSAGE = "Release Pipeline: %s deleted" +RELEASE_PIPELINE_FEATURE_ADDED_MESSAGE = "Feature: %s added to Release Pipeline: %s" +FEATURE_STATE_UPDATED_BY_RELEASE_PIPELINE_MESSAGE = "Flag state / Remote config updated for feature: %s by Release pipeline: %s (stage: %s)" diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index 5240e2f51c35..884bf3cc8fe9 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -11,3 +11,4 @@ class RelatedObjectType(enum.Enum): IMPORT_REQUEST = "Import request" EF_VERSION = "Environment feature version" FEATURE_HEALTH = "Feature health status" + RELEASE_PIPELINE = "Release pipeline" diff --git a/api/conftest.py b/api/conftest.py index eed57e3b793c..8e06f509c649 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -1,3 +1,4 @@ +import json import logging import os import site @@ -17,6 +18,7 @@ from django.core.cache import caches from django.db.backends.base.creation import TEST_DATABASE_PREFIX from django.test.utils import setup_databases +from django.urls import reverse from flag_engine.segments.constants import EQUAL from moto import mock_dynamodb # type: ignore[import-untyped] from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table @@ -25,6 +27,7 @@ from pytest_django.fixtures import SettingsWrapper from pytest_django.plugin import blocking_manager_key from pytest_mock import MockerFixture +from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.test import APIClient from task_processor.task_run_method import TaskRunMethod @@ -83,6 +86,8 @@ from tests.test_helpers import fix_issue_3869 from tests.types import ( AdminClientAuthType, + GetEnvironmentFlagsResponseJSONCallable, + GetIdentityFlagsResponseJSONCallable, WithEnvironmentPermissionsCallable, WithOrganisationPermissionsCallable, WithProjectPermissionsCallable, @@ -548,8 +553,8 @@ def multivariate_options( @pytest.fixture() -def identity_matching_segment(project, trait): # type: ignore[no-untyped-def] - segment = Segment.objects.create(name="Matching segment", project=project) +def identity_matching_segment(project: Project, trait: Trait) -> Segment: + segment: Segment = Segment.objects.create(name="Matching segment", project=project) matching_rule = SegmentRule.objects.create( segment=segment, type=SegmentRule.ALL_RULE ) @@ -563,10 +568,17 @@ def identity_matching_segment(project, trait): # type: ignore[no-untyped-def] @pytest.fixture() -def api_client(): # type: ignore[no-untyped-def] +def api_client() -> APIClient: return APIClient() +@pytest.fixture() +def sdk_client(environment: Environment) -> APIClient: + client = APIClient() + client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + return client + + @pytest.fixture() def feature(project: Project, environment: Environment) -> Feature: return Feature.objects.create(name="Test Feature1", project=project) # type: ignore[no-any-return] @@ -1265,3 +1277,46 @@ def set_github_webhook_secret() -> None: from django.conf import settings settings.GITHUB_WEBHOOK_SECRET = "secret-key" + + +# TODO: move _flags_response_json to common? +@pytest.fixture() +def get_environment_flags_response_json( + sdk_client: APIClient, +) -> GetEnvironmentFlagsResponseJSONCallable: + get_environment_flags_url = reverse("api-v1:flags") + + def _get_environment_flags_response_json(num_expected_flags: int) -> typing.Dict: # type: ignore[type-arg] + _response = sdk_client.get(get_environment_flags_url) + assert _response.status_code == status.HTTP_200_OK + _response_json = _response.json() + assert len(_response_json) == num_expected_flags + return _response_json # type: ignore[no-any-return] + + return _get_environment_flags_response_json + + +@pytest.fixture() +def get_identity_flags_response_json( + sdk_client: APIClient, identity: Identity +) -> GetIdentityFlagsResponseJSONCallable: + identities_url = reverse("api-v1:sdk-identities") + + def _get_identity_flags_response_json( # type: ignore[no-untyped-def] + num_expected_flags: int, identifier: str = identity.identifier, **traits + ) -> typing.Dict: # type: ignore[type-arg] + traits = traits or {} + data = { + "identifier": identifier, + "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()], + } + + _response = sdk_client.post( + identities_url, data=json.dumps(data), content_type="application/json" + ) + assert _response.status_code == status.HTTP_200_OK + _response_json = _response.json() + assert len(_response_json["flags"]) == num_expected_flags + return _response_json # type: ignore[no-any-return] + + return _get_identity_flags_response_json # type: ignore[return-value] diff --git a/api/features/release_pipelines/core/__init__.py b/api/features/release_pipelines/core/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/features/release_pipelines/core/apps.py b/api/features/release_pipelines/core/apps.py new file mode 100644 index 000000000000..11f48d2cc0e9 --- /dev/null +++ b/api/features/release_pipelines/core/apps.py @@ -0,0 +1,7 @@ +from core.apps import BaseAppConfig + + +class ReleasePipelineConfig(BaseAppConfig): + name = "features.release_pipelines.core" + label = "release_pipelines_core" + default = True diff --git a/api/features/release_pipelines/core/constants.py b/api/features/release_pipelines/core/constants.py new file mode 100644 index 000000000000..2f38bccc6fad --- /dev/null +++ b/api/features/release_pipelines/core/constants.py @@ -0,0 +1 @@ +MAX_PIPELINE_STAGES = 30 diff --git a/api/features/release_pipelines/core/exceptions.py b/api/features/release_pipelines/core/exceptions.py new file mode 100644 index 000000000000..8553b9b71401 --- /dev/null +++ b/api/features/release_pipelines/core/exceptions.py @@ -0,0 +1,10 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class ReleasePipelineError(APIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + +class InvalidPipelineStateError(ReleasePipelineError): + status_code = status.HTTP_400_BAD_REQUEST # type: ignore[assignment] diff --git a/api/features/release_pipelines/core/migrations/0001_add_release_pipelines.py b/api/features/release_pipelines/core/migrations/0001_add_release_pipelines.py new file mode 100644 index 000000000000..d5c716128d51 --- /dev/null +++ b/api/features/release_pipelines/core/migrations/0001_add_release_pipelines.py @@ -0,0 +1,285 @@ +# Generated by Django 4.2.22 on 2025-07-01 04:34 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models # type: ignore[import-untyped] +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("api_keys", "0003_masterapikey_is_admin"), + ("projects", "0027_add_create_project_level_change_requests_permission"), + ("environments", "0037_add_uuid_field"), + ] + + operations = [ + migrations.CreateModel( + name="PipelineStage", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "order", + models.PositiveSmallIntegerField( + validators=[django.core.validators.MaxValueValidator(30)] + ), + ), + ( + "environment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pipeline_stages", + to="environments.environment", + ), + ), + ], + ), + migrations.CreateModel( + name="ReleasePipeline", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, + db_index=True, + default=None, + editable=False, + null=True, + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, null=True)), + ("published_at", models.DateTimeField(blank=True, null=True)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="release_pipelines", + to="projects.project", + ), + ), + ( + "published_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="published_release_pipelines", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="PipelineStageTrigger", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "trigger_type", + models.CharField( + choices=[ + ("ON_ENTER", "Trigger when flag enters stage"), + ("WAIT_FOR", "Trigger after waiting for x amount of time"), + ], + default="ON_ENTER", + max_length=50, + ), + ), + ("trigger_body", models.JSONField(null=True)), + ( + "stage", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="trigger", + to="release_pipelines_core.pipelinestage", + ), + ), + ], + ), + migrations.CreateModel( + name="PipelineStageAction", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "action_type", + models.CharField( + choices=[ + ( + "TOGGLE_FEATURE", + "Enable/Disable Feature for the environment", + ), + ( + "UPDATE_FEATURE_VALUE", + "Update Feature Value for the environment", + ), + ( + "TOGGLE_FEATURE_FOR_SEGMENT", + "Enable/Disable Feature for a specific segment", + ), + ( + "UPDATE_FEATURE_VALUE_FOR_SEGMENT", + "Update Feature Value for a specific segment", + ), + ], + default="TOGGLE_FEATURE", + max_length=50, + ), + ), + ("action_body", models.JSONField(null=True)), + ( + "stage", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="actions", + to="release_pipelines_core.pipelinestage", + ), + ), + ], + ), + migrations.AddField( + model_name="pipelinestage", + name="pipeline", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stages", + to="release_pipelines_core.releasepipeline", + ), + ), + migrations.CreateModel( + name="HistoricalReleasePipeline", + fields=[ + ( + "id", + models.IntegerField( + auto_created=True, blank=True, db_index=True, verbose_name="ID" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, + db_index=True, + default=None, + editable=False, + null=True, + ), + ), + ( + "uuid", + models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, null=True)), + ("published_at", models.DateTimeField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "master_api_key", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="api_keys.masterapikey", + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="projects.project", + ), + ), + ( + "published_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical release pipeline", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddConstraint( + model_name="pipelinestage", + constraint=models.UniqueConstraint( + fields=("pipeline", "order"), name="unique_pipeline_stage_order" + ), + ), + ] diff --git a/api/features/release_pipelines/core/migrations/__init__.py b/api/features/release_pipelines/core/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/features/release_pipelines/core/models.py b/api/features/release_pipelines/core/models.py new file mode 100644 index 000000000000..d5bc932a219c --- /dev/null +++ b/api/features/release_pipelines/core/models.py @@ -0,0 +1,162 @@ +import typing + +from django.core.validators import MaxValueValidator +from django.db import models +from django.utils import timezone + +from audit.constants import ( + RELEASE_PIPELINE_CREATED_MESSAGE, + RELEASE_PIPELINE_DELETED_MESSAGE, + RELEASE_PIPELINE_PUBLISHED_MESSAGE, +) +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from core.models import ( + SoftDeleteExportableModel, + abstract_base_auditable_model_factory, +) +from features.release_pipelines.core.constants import MAX_PIPELINE_STAGES +from features.release_pipelines.core.exceptions import InvalidPipelineStateError +from projects.models import Project +from users.models import FFAdminUser + + +class StageTriggerType(models.TextChoices): + ON_ENTER = "ON_ENTER", "Trigger when flag enters stage" + WAIT_FOR = "WAIT_FOR", "Trigger after waiting for x amount of time" + + +class StageActionType(models.TextChoices): + TOGGLE_FEATURE = "TOGGLE_FEATURE", "Enable/Disable Feature for the environment" + UPDATE_FEATURE_VALUE = ( + "UPDATE_FEATURE_VALUE", + "Update Feature Value for the environment", + ) + TOGGLE_FEATURE_FOR_SEGMENT = ( + "TOGGLE_FEATURE_FOR_SEGMENT", + "Enable/Disable Feature for a specific segment", + ) + UPDATE_FEATURE_VALUE_FOR_SEGMENT = ( + "UPDATE_FEATURE_VALUE_FOR_SEGMENT", + "Update Feature Value for a specific segment", + ) + + +class ReleasePipeline( + SoftDeleteExportableModel, + abstract_base_auditable_model_factory(), # type: ignore[misc] +): + history_record_class_path = ( + "features.release_pipelines.core.models.HistoricalReleasePipeline" + ) + related_object_type = RelatedObjectType.RELEASE_PIPELINE + + name = models.CharField(max_length=255) + description = models.TextField(null=True, blank=True) + project = models.ForeignKey( + "projects.Project", related_name="release_pipelines", on_delete=models.CASCADE + ) + + published_at = models.DateTimeField(blank=True, null=True) + published_by = models.ForeignKey( + FFAdminUser, + related_name="published_release_pipelines", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + def publish(self, published_by: FFAdminUser) -> None: + if self.published_at is not None: + raise InvalidPipelineStateError("Pipeline is already published.") + self.published_at = timezone.now() + self.published_by = published_by + self.save() + self._create_pipeline_published_audit_log() + + def get_first_stage(self) -> "PipelineStage | None": + return self.stages.order_by("order").first() + + def get_last_stage(self) -> "PipelineStage | None": + return self.stages.order_by("-order").first() + + def get_create_log_message( + self, history_instance: "ReleasePipeline" + ) -> typing.Optional[str]: + return RELEASE_PIPELINE_CREATED_MESSAGE % self.name + + def get_delete_log_message( + self, history_instance: "ReleasePipeline" + ) -> typing.Optional[str]: + return RELEASE_PIPELINE_DELETED_MESSAGE % self.name + + def _get_project(self) -> Project: + return self.project + + def _create_pipeline_published_audit_log(self) -> None: + AuditLog.objects.create( + related_object_id=self.id, + related_object_type=RelatedObjectType.RELEASE_PIPELINE.name, + project=self._get_project(), + log=RELEASE_PIPELINE_PUBLISHED_MESSAGE % self.name, + author=self.published_by, + ) + + +class PipelineStage(models.Model): + name = models.CharField(max_length=255) + pipeline = models.ForeignKey( + "ReleasePipeline", related_name="stages", on_delete=models.CASCADE + ) + order = models.PositiveSmallIntegerField( + validators=[MaxValueValidator(MAX_PIPELINE_STAGES)] + ) + + environment = models.ForeignKey( + "environments.Environment", + related_name="pipeline_stages", + on_delete=models.CASCADE, + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["pipeline", "order"], name="unique_pipeline_stage_order" + ) + ] + + def get_next_stage(self) -> "PipelineStage | None": + return ( + PipelineStage.objects.filter(pipeline=self.pipeline, order__gt=self.order) + .order_by("order") + .first() + ) + + +class PipelineStageTrigger(models.Model): + trigger_type = models.CharField( + max_length=50, + choices=StageTriggerType.choices, + default=StageTriggerType.ON_ENTER, + ) + trigger_body = models.JSONField(null=True) # Json field? + + stage = models.OneToOneField( + PipelineStage, + related_name="trigger", + on_delete=models.CASCADE, + ) + + +class PipelineStageAction(models.Model): + action_type = models.CharField( + max_length=50, + choices=StageActionType.choices, + default=StageActionType.TOGGLE_FEATURE, + ) + action_body = models.JSONField(null=True) + stage = models.ForeignKey( + PipelineStage, + related_name="actions", + on_delete=models.CASCADE, + ) diff --git a/api/features/versioning/migrations/0006_add_pipeline_stage_to_envfeatureversion.py b/api/features/versioning/migrations/0006_add_pipeline_stage_to_envfeatureversion.py new file mode 100644 index 000000000000..95470d1b68b2 --- /dev/null +++ b/api/features/versioning/migrations/0006_add_pipeline_stage_to_envfeatureversion.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.22 on 2025-07-01 06:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("release_pipelines_core", "0001_add_release_pipelines"), + ( + "feature_versioning", + "0005_fix_scheduled_fs_data_issue_caused_by_enabling_versioning", + ), + ] + + operations = [ + migrations.AddField( + model_name="environmentfeatureversion", + name="pipeline_stage", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="environment_feature_versions", + to="release_pipelines_core.pipelinestage", + ), + ), + migrations.AddField( + model_name="historicalenvironmentfeatureversion", + name="pipeline_stage", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="release_pipelines_core.pipelinestage", + ), + ), + migrations.AddConstraint( + model_name="environmentfeatureversion", + constraint=models.UniqueConstraint( + condition=models.Q(("published_at__isnull", True)), + fields=("feature", "environment", "pipeline_stage"), + name="unique_feature_environment_stage_unpublished", + ), + ), + ] diff --git a/api/features/versioning/models.py b/api/features/versioning/models.py index a6d1ae80fcb2..4270fc989129 100644 --- a/api/features/versioning/models.py +++ b/api/features/versioning/models.py @@ -6,7 +6,7 @@ from django.conf import settings from django.db import models -from django.db.models import Index +from django.db.models import Index, Q from django.utils import timezone from django_lifecycle import ( # type: ignore[import-untyped] BEFORE_CREATE, @@ -78,7 +78,6 @@ class EnvironmentFeatureVersion( # type: ignore[django-manager-missing] null=True, blank=True, ) - change_request = models.ForeignKey( "workflows_core.ChangeRequest", related_name="environment_feature_versions", @@ -86,11 +85,26 @@ class EnvironmentFeatureVersion( # type: ignore[django-manager-missing] null=True, blank=True, ) + pipeline_stage = models.ForeignKey( + "release_pipelines_core.PipelineStage", + related_name="environment_feature_versions", + on_delete=models.CASCADE, + null=True, + blank=True, + ) objects = EnvironmentFeatureVersionManager() # type: ignore[misc] class Meta: indexes = [Index(fields=("environment", "feature"))] + constraints = [ + models.UniqueConstraint( + fields=["feature", "environment", "pipeline_stage"], + condition=Q(published_at__isnull=True), + name="unique_feature_environment_stage_unpublished", + ) + ] + ordering = ("-live_from",) def __gt__(self, other): # type: ignore[no-untyped-def] diff --git a/api/organisations/models.py b/api/organisations/models.py index d9dbb19478ea..2d3a6db87d8c 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -120,6 +120,9 @@ def is_paid(self): # type: ignore[no-untyped-def] self.has_paid_subscription() and self.subscription.cancellation_date is None ) + def has_enterprise_subscription(self) -> bool: + return self.is_paid and self.subscription.is_enterprise + @property def flagsmith_identifier(self): # type: ignore[no-untyped-def] return f"org.{self.id}" @@ -279,6 +282,10 @@ def is_free_plan(self) -> bool: def subscription_plan_family(self) -> SubscriptionPlanFamily: return SubscriptionPlanFamily.get_by_plan_id(self.plan) # type: ignore[arg-type] + @property + def is_enterprise(self) -> bool: + return self.subscription_plan_family == SubscriptionPlanFamily.ENTERPRISE + @hook(AFTER_SAVE, when="plan", has_changed=True) def update_api_limit_access_block(self): # type: ignore[no-untyped-def] if not getattr(self.organisation, "api_limit_access_block", None): diff --git a/api/poetry.lock b/api/poetry.lock index 3880be488870..2de4634ff55b 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -931,7 +931,7 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -1993,6 +1993,25 @@ url = "https://github.com/flagsmith/flagsmith-ldap" reference = "v0.1.1" resolved_reference = "d7cf0dd9e306a529498839c3f7a1d7c652093228" +[[package]] +name = "flagsmith-private" +version = "0.1.0" +description = "Flagsmith-private meta package" +optional = false +python-versions = ">=3.10" +groups = ["release-pipelines"] +files = [] +develop = false + +[package.dependencies] +release-pipelines-logic = {git = "https://github.com/Flagsmith/flagsmith-private/", rev = "feat/release-pipelines-logic", subdirectory = "flagsmith-release-pipelines-logic"} + +[package.source] +type = "git" +url = "https://github.com/Flagsmith/flagsmith-private/" +reference = "feat/release-pipelines-logic" +resolved_reference = "de613e14e91f3ec49baf893133b8468ba13393bb" + [[package]] name = "flagsmith-split-testing" version = "v0.1.3" @@ -4277,6 +4296,23 @@ async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2 hiredis = ["hiredis (>=1.0.0)"] ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] +[[package]] +name = "release-pipelines-logic" +version = "0.1.0" +description = "Release pipelines logic plugin for Flagsmith application" +optional = false +python-versions = ">=3.10,<4.0" +groups = ["release-pipelines"] +files = [] +develop = false + +[package.source] +type = "git" +url = "https://github.com/Flagsmith/flagsmith-private/" +reference = "feat/release-pipelines-logic" +resolved_reference = "4fde5461096065ca5fa360c909b6f0b37990b918" +subdirectory = "flagsmith-release-pipelines-logic" + [[package]] name = "requests" version = "2.32.4" @@ -5369,4 +5405,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.13" -content-hash = "f444be832e2a8c763f0b624e10847aa57536272d082988b3060f714e89f078fa" +content-hash = "8563ee55deeaf867a04cfa657bd3377070faa23ddadbd2ba661721ed6f9c2134" diff --git a/api/projects/urls.py b/api/projects/urls.py index 61ebbdc5efb3..01f6e70ad3ed 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -92,8 +92,13 @@ workflow_views.ProjectChangeRequestViewSet, basename="project-change-requests", ) - - +if settings.RELEASE_PIPELINES_LOGIC_INSTALLED: # pragma: no cover + release_pipelines_views = importlib.import_module("release_pipelines_logic.views") + projects_router.register( + r"release-pipelines", + release_pipelines_views.ReleasePipelineViewSet, + basename="project-release-pipelines", + ) nested_features_router = routers.NestedSimpleRouter( projects_router, r"features", lookup="feature" ) diff --git a/api/pyproject.toml b/api/pyproject.toml index f97ccb7b3019..31dc1c3a6406 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -202,6 +202,13 @@ optional = true [tool.poetry.group.split-testing.dependencies] flagsmith-split-testing = { git = "https://github.com/flagsmith/flagsmith-split-testing", tag = "v0.2.1" } +[tool.poetry.group.release-pipelines] +optional = true + +[tool.poetry.group.release-pipelines.dependencies] +flagsmith-private = { git = "https://github.com/Flagsmith/flagsmith-private/", rev = "feat/release-pipelines-logic" } + + [tool.poetry.group.dev.dependencies] django-test-migrations = "~1.2.0" responses = "~0.22.0" diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 949cfd0e0e5b..8e80f9c9c4b9 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -160,7 +160,7 @@ def identity_with_traits_matching_segment( # type: ignore[no-untyped-def] @pytest.fixture() -def sdk_client(environment_api_key): # type: ignore[no-untyped-def] +def sdk_client(environment_api_key: str) -> APIClient: client = APIClient() client.credentials(HTTP_X_ENVIRONMENT_KEY=environment_api_key) return client diff --git a/api/tests/types.py b/api/tests/types.py index f565e6243b4a..676b0b059a8b 100644 --- a/api/tests/types.py +++ b/api/tests/types.py @@ -1,4 +1,4 @@ -from typing import Callable, Literal +from typing import Callable, Literal, Protocol from environments.permissions.models import UserEnvironmentPermission from organisations.permissions.models import UserOrganisationPermission @@ -17,3 +17,16 @@ ] AdminClientAuthType = Literal["user", "master_api_key"] + + +class GetEnvironmentFlagsResponseJSONCallable(Protocol): + def __call__(self, num_expected_flags: int) -> dict: ... # type: ignore[type-arg] + + +class GetIdentityFlagsResponseJSONCallable(Protocol): + def __call__( # type: ignore[no-untyped-def] + self, + num_expected_flags: int, + identity_identifier: str = "test-identity", + **traits, + ) -> dict: ... # type: ignore[type-arg] diff --git a/api/tests/unit/features/release_pipeline/core/conftest.py b/api/tests/unit/features/release_pipeline/core/conftest.py new file mode 100644 index 000000000000..d6be136c007f --- /dev/null +++ b/api/tests/unit/features/release_pipeline/core/conftest.py @@ -0,0 +1,23 @@ +import pytest +import pytest +from django.utils import timezone +from environments.models import Environment +from features.release_pipelines.core.models import ( + PipelineStage, + PipelineStageAction, + PipelineStageTrigger, + ReleasePipeline, + StageActionType, + StageTriggerType, +) +from projects.models import Project +from segments.models import Segment +from users.models import FFAdminUser + + +@pytest.fixture() +def release_pipeline(project: Project) -> ReleasePipeline: + return ReleasePipeline.objects.create( + name="Test Pipeline", + project=project, + ) diff --git a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py new file mode 100644 index 000000000000..47445d832b6c --- /dev/null +++ b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py @@ -0,0 +1,134 @@ +import pytest +from django.utils import timezone +from environments.models import Environment +from features.release_pipelines.core.models import ( + PipelineStage, + PipelineStageAction, + PipelineStageTrigger, + ReleasePipeline, + StageActionType, + StageTriggerType, +) +from audit.constants import ( + RELEASE_PIPELINE_CREATED_MESSAGE, + RELEASE_PIPELINE_DELETED_MESSAGE, + RELEASE_PIPELINE_PUBLISHED_MESSAGE, +) + +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from projects.models import Project +from segments.models import Segment +from users.models import FFAdminUser + +from features.release_pipelines.core.exceptions import InvalidPipelineStateError + + +def test_release_pipeline_publish_creates_audit_log( + release_pipeline: ReleasePipeline, admin_user: FFAdminUser +) -> None: + # When + release_pipeline.publish(admin_user) + + # Then + assert ( + AuditLog.objects.filter( + related_object_id=release_pipeline.id, + related_object_type=RelatedObjectType.RELEASE_PIPELINE.name, + project=release_pipeline.project, + log=RELEASE_PIPELINE_PUBLISHED_MESSAGE % release_pipeline.name, + author=admin_user, + ).exists() + is True + ) + + +def test_release_pipeline_publish_raises_error_if_pipeline_is_already_published( + release_pipeline: ReleasePipeline, admin_user: FFAdminUser +) -> None: + # Given - the pipeline is already published + release_pipeline.publish(admin_user) + + # When / Then + with pytest.raises( + InvalidPipelineStateError, match="Pipeline is already published." + ): + release_pipeline.publish(admin_user) + + +def test_release_pipeline_get_first_stage_returns_none_if_pipeline_has_no_stages( + release_pipeline: ReleasePipeline, +) -> None: + # When + first_stage = release_pipeline.get_first_stage() + + # Then + assert first_stage is None + + +def test_release_pipeline_get_first_stage_returns_correct_stage( + release_pipeline: ReleasePipeline, environment: Environment +) -> None: + # Given + for i in range(3): + PipelineStage.objects.create( + name=f"Stage {i}", + pipeline=release_pipeline, + environment=environment, + order=i, + ) + + # When + first_stage = release_pipeline.get_first_stage() + + # Then + assert first_stage.order == 0 + + +def test_release_pipeline_get_last_stage_returns_none_if_pipeline_has_no_stages( + release_pipeline: ReleasePipeline, +) -> None: + # When + last_stage = release_pipeline.get_last_stage() + + # Then + assert last_stage is None + + +def test_release_pipeline_get_last_stage_returns_correct_stage( + release_pipeline: ReleasePipeline, environment: Environment +) -> None: + # Given + for i in range(3): + PipelineStage.objects.create( + name=f"Stage {i}", + pipeline=release_pipeline, + environment=environment, + order=i, + ) + + # When + last_stage = release_pipeline.get_last_stage() + + # Then + assert last_stage.order == 2 + + +def test_release_pipeline_get_create_log_message( + release_pipeline: ReleasePipeline, +) -> None: + # When + expected_message = RELEASE_PIPELINE_CREATED_MESSAGE % release_pipeline.name + + # Then + assert release_pipeline.get_create_log_message(release_pipeline) == expected_message + + +def test_release_pipeline_get_delete_log_message( + release_pipeline: ReleasePipeline, +) -> None: + # When + expected_message = RELEASE_PIPELINE_DELETED_MESSAGE % release_pipeline.name + + # Then + assert release_pipeline.get_delete_log_message(release_pipeline) == expected_message diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index bdba6563c586..9a7ec2f40843 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -48,6 +48,37 @@ def test_organisation_has_paid_subscription_true(db: None) -> None: assert organisation.has_paid_subscription() +@pytest.mark.parametrize( + "plan_id, expected_has_enterprise", + ( + ("free", False), + ("enterprise", True), + ("enterprise-semiannual", True), + ("scale-up", False), + ("scaleup", False), + ("scale-up-v2", False), + ("scale-up-v2-annual", False), + ("startup", False), + ("start-up", False), + ("start-up-v2", False), + ("start-up-v2-annual", False), + ), +) +def test_organisation_has_enterprise_subscription( + plan_id: str, expected_has_enterprise: bool, organisation: Organisation +) -> None: + # Given + Subscription.objects.filter(organisation=organisation).update( + plan=plan_id, subscription_id="subscription_id" + ) + + # # When + organisation.refresh_from_db() + + # Then + assert organisation.has_enterprise_subscription() is expected_has_enterprise + + def test_organisation_has_paid_subscription_missing_subscription_id(db: None) -> None: # Given organisation = Organisation.objects.create(name="Test org") @@ -550,6 +581,35 @@ def test_subscription_plan_family( assert Subscription(plan=plan_id).subscription_plan_family == expected_plan_family +@pytest.mark.parametrize( + "plan_id, expected_is_enterprise", + ( + ("free", False), + ("enterprise", True), + ("enterprise-semiannual", True), + ("scale-up", False), + ("scaleup", False), + ("scale-up-v2", False), + ("scale-up-v2-annual", False), + ("startup", False), + ("start-up", False), + ("start-up-v2", False), + ("start-up-v2-annual", False), + ), +) +def test_subscription_is_enterprise_property( + plan_id: str, expected_is_enterprise: bool, organisation: Organisation +) -> None: + # Given + subscription = Subscription.objects.get(organisation=organisation) + + subscription.plan = plan_id + subscription.save() + + # Then + assert subscription.is_enterprise is expected_is_enterprise + + @pytest.mark.parametrize( "billing_term_starts_at, billing_term_ends_at, expected_result", [ From 1f44b3c3bbbda845ae94966343d95b9e1e3453ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 06:43:31 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../release_pipeline/core/conftest.py | 11 +--------- .../core/test_unit_release_pipeline_models.py | 22 ++++++------------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/api/tests/unit/features/release_pipeline/core/conftest.py b/api/tests/unit/features/release_pipeline/core/conftest.py index d6be136c007f..62ff3cb646b6 100644 --- a/api/tests/unit/features/release_pipeline/core/conftest.py +++ b/api/tests/unit/features/release_pipeline/core/conftest.py @@ -1,18 +1,9 @@ import pytest -import pytest -from django.utils import timezone -from environments.models import Environment + from features.release_pipelines.core.models import ( - PipelineStage, - PipelineStageAction, - PipelineStageTrigger, ReleasePipeline, - StageActionType, - StageTriggerType, ) from projects.models import Project -from segments.models import Segment -from users.models import FFAdminUser @pytest.fixture() diff --git a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py index 47445d832b6c..ddb9a137c4ab 100644 --- a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py +++ b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py @@ -1,27 +1,19 @@ import pytest -from django.utils import timezone -from environments.models import Environment -from features.release_pipelines.core.models import ( - PipelineStage, - PipelineStageAction, - PipelineStageTrigger, - ReleasePipeline, - StageActionType, - StageTriggerType, -) + from audit.constants import ( RELEASE_PIPELINE_CREATED_MESSAGE, RELEASE_PIPELINE_DELETED_MESSAGE, RELEASE_PIPELINE_PUBLISHED_MESSAGE, ) - from audit.models import AuditLog from audit.related_object_type import RelatedObjectType -from projects.models import Project -from segments.models import Segment -from users.models import FFAdminUser - +from environments.models import Environment from features.release_pipelines.core.exceptions import InvalidPipelineStateError +from features.release_pipelines.core.models import ( + PipelineStage, + ReleasePipeline, +) +from users.models import FFAdminUser def test_release_pipeline_publish_creates_audit_log( From 3be9ede1dbfc8a08db0433f2616b43945773400d Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 20 May 2025 17:42:36 +0530 Subject: [PATCH 3/4] wip: release pipeline --- api/features/release_pipelines/core/models.py | 2 +- .../release_pipeline/core/conftest.py | 3 +- .../core/test_unit_release_pipeline_models.py | 33 +++++++++++++++++-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/api/features/release_pipelines/core/models.py b/api/features/release_pipelines/core/models.py index d5bc932a219c..40db5b9b013c 100644 --- a/api/features/release_pipelines/core/models.py +++ b/api/features/release_pipelines/core/models.py @@ -139,7 +139,7 @@ class PipelineStageTrigger(models.Model): choices=StageTriggerType.choices, default=StageTriggerType.ON_ENTER, ) - trigger_body = models.JSONField(null=True) # Json field? + trigger_body = models.JSONField(null=True) stage = models.OneToOneField( PipelineStage, diff --git a/api/tests/unit/features/release_pipeline/core/conftest.py b/api/tests/unit/features/release_pipeline/core/conftest.py index 62ff3cb646b6..8088e1750beb 100644 --- a/api/tests/unit/features/release_pipeline/core/conftest.py +++ b/api/tests/unit/features/release_pipeline/core/conftest.py @@ -8,7 +8,8 @@ @pytest.fixture() def release_pipeline(project: Project) -> ReleasePipeline: - return ReleasePipeline.objects.create( + release_pipeline = ReleasePipeline.objects.create( name="Test Pipeline", project=project, ) + return release_pipeline # type: ignore[no-any-return] diff --git a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py index ddb9a137c4ab..ff4fdf6f0054 100644 --- a/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py +++ b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py @@ -74,7 +74,7 @@ def test_release_pipeline_get_first_stage_returns_correct_stage( first_stage = release_pipeline.get_first_stage() # Then - assert first_stage.order == 0 + assert first_stage.order == 0 # type: ignore[union-attr] def test_release_pipeline_get_last_stage_returns_none_if_pipeline_has_no_stages( @@ -103,7 +103,36 @@ def test_release_pipeline_get_last_stage_returns_correct_stage( last_stage = release_pipeline.get_last_stage() # Then - assert last_stage.order == 2 + assert last_stage.order == 2 # type: ignore[union-attr] + + +def test_release_pipeline_get_next_stage( + release_pipeline: ReleasePipeline, environment: Environment +) -> None: + # Given + stage1 = PipelineStage.objects.create( + name="Stage 1", + pipeline=release_pipeline, + environment=environment, + order=0, + ) + stage2 = PipelineStage.objects.create( + name="Stage 2", + pipeline=release_pipeline, + environment=environment, + order=1, + ) + stage3 = PipelineStage.objects.create( + name="Stage 3", + pipeline=release_pipeline, + environment=environment, + order=2, + ) + + # Then + assert stage1.get_next_stage() == stage2 + assert stage2.get_next_stage() == stage3 + assert stage3.get_next_stage() is None def test_release_pipeline_get_create_log_message( From 9fdd21945fe0569e1ad20a0dc3deefcde1d06230 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 20 May 2025 17:42:36 +0530 Subject: [PATCH 4/4] feat: Add release pipeline v0.1.0 --- Dockerfile | 2 +- api/conftest.py | 55 ---------------------------------------------- api/poetry.lock | 14 ++++++------ api/pyproject.toml | 2 +- 4 files changed, 9 insertions(+), 64 deletions(-) diff --git a/Dockerfile b/Dockerfile index 37c7aa8594e7..239a7fb5410b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -108,7 +108,7 @@ FROM build-python AS build-python-private # and integrate private modules ARG SAML_REVISION ARG RBAC_REVISION -ARG WITH="saml,auth-controller,ldap,workflows,licensing" +ARG WITH="saml,auth-controller,ldap,workflows,licensing,release-pipelines" RUN --mount=type=secret,id=github_private_cloud_token \ echo "https://$(cat /run/secrets/github_private_cloud_token):@github.com" > ${HOME}/.git-credentials && \ git config --global credential.helper store && \ diff --git a/api/conftest.py b/api/conftest.py index 8e06f509c649..0d6cede4a12b 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -1,4 +1,3 @@ -import json import logging import os import site @@ -18,7 +17,6 @@ from django.core.cache import caches from django.db.backends.base.creation import TEST_DATABASE_PREFIX from django.test.utils import setup_databases -from django.urls import reverse from flag_engine.segments.constants import EQUAL from moto import mock_dynamodb # type: ignore[import-untyped] from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table @@ -27,7 +25,6 @@ from pytest_django.fixtures import SettingsWrapper from pytest_django.plugin import blocking_manager_key from pytest_mock import MockerFixture -from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.test import APIClient from task_processor.task_run_method import TaskRunMethod @@ -86,8 +83,6 @@ from tests.test_helpers import fix_issue_3869 from tests.types import ( AdminClientAuthType, - GetEnvironmentFlagsResponseJSONCallable, - GetIdentityFlagsResponseJSONCallable, WithEnvironmentPermissionsCallable, WithOrganisationPermissionsCallable, WithProjectPermissionsCallable, @@ -572,13 +567,6 @@ def api_client() -> APIClient: return APIClient() -@pytest.fixture() -def sdk_client(environment: Environment) -> APIClient: - client = APIClient() - client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) - return client - - @pytest.fixture() def feature(project: Project, environment: Environment) -> Feature: return Feature.objects.create(name="Test Feature1", project=project) # type: ignore[no-any-return] @@ -1277,46 +1265,3 @@ def set_github_webhook_secret() -> None: from django.conf import settings settings.GITHUB_WEBHOOK_SECRET = "secret-key" - - -# TODO: move _flags_response_json to common? -@pytest.fixture() -def get_environment_flags_response_json( - sdk_client: APIClient, -) -> GetEnvironmentFlagsResponseJSONCallable: - get_environment_flags_url = reverse("api-v1:flags") - - def _get_environment_flags_response_json(num_expected_flags: int) -> typing.Dict: # type: ignore[type-arg] - _response = sdk_client.get(get_environment_flags_url) - assert _response.status_code == status.HTTP_200_OK - _response_json = _response.json() - assert len(_response_json) == num_expected_flags - return _response_json # type: ignore[no-any-return] - - return _get_environment_flags_response_json - - -@pytest.fixture() -def get_identity_flags_response_json( - sdk_client: APIClient, identity: Identity -) -> GetIdentityFlagsResponseJSONCallable: - identities_url = reverse("api-v1:sdk-identities") - - def _get_identity_flags_response_json( # type: ignore[no-untyped-def] - num_expected_flags: int, identifier: str = identity.identifier, **traits - ) -> typing.Dict: # type: ignore[type-arg] - traits = traits or {} - data = { - "identifier": identifier, - "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()], - } - - _response = sdk_client.post( - identities_url, data=json.dumps(data), content_type="application/json" - ) - assert _response.status_code == status.HTTP_200_OK - _response_json = _response.json() - assert len(_response_json["flags"]) == num_expected_flags - return _response_json # type: ignore[no-any-return] - - return _get_identity_flags_response_json # type: ignore[return-value] diff --git a/api/poetry.lock b/api/poetry.lock index 2de4634ff55b..9d5b8d97bc01 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1998,19 +1998,19 @@ name = "flagsmith-private" version = "0.1.0" description = "Flagsmith-private meta package" optional = false -python-versions = ">=3.10" +python-versions = ">=3.10,<4.0" groups = ["release-pipelines"] files = [] develop = false [package.dependencies] -release-pipelines-logic = {git = "https://github.com/Flagsmith/flagsmith-private/", rev = "feat/release-pipelines-logic", subdirectory = "flagsmith-release-pipelines-logic"} +release-pipelines-logic = {git = "https://github.com/Flagsmith/flagsmith-private/", rev = "feat/release-pipeline-logic-v1", subdirectory = "flagsmith-release-pipelines-logic"} [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-private/" -reference = "feat/release-pipelines-logic" -resolved_reference = "de613e14e91f3ec49baf893133b8468ba13393bb" +reference = "v0.1.0" +resolved_reference = "b75b2dea7a9b0e67fe78800305e73130cbb73509" [[package]] name = "flagsmith-split-testing" @@ -4309,8 +4309,8 @@ develop = false [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-private/" -reference = "feat/release-pipelines-logic" -resolved_reference = "4fde5461096065ca5fa360c909b6f0b37990b918" +reference = "feat/release-pipeline-logic-v1" +resolved_reference = "df9a4b160204fd0eeab69bf678d74b5de941d5ac" subdirectory = "flagsmith-release-pipelines-logic" [[package]] @@ -5405,4 +5405,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">3.11,<3.13" -content-hash = "8563ee55deeaf867a04cfa657bd3377070faa23ddadbd2ba661721ed6f9c2134" +content-hash = "a77fbee7bc35ae5c1732a54ae358a949c10ba017523dcdbc5e999b62d2888236" diff --git a/api/pyproject.toml b/api/pyproject.toml index 31dc1c3a6406..de2650545639 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -206,7 +206,7 @@ flagsmith-split-testing = { git = "https://github.com/flagsmith/flagsmith-split- optional = true [tool.poetry.group.release-pipelines.dependencies] -flagsmith-private = { git = "https://github.com/Flagsmith/flagsmith-private/", rev = "feat/release-pipelines-logic" } +flagsmith-private = { git = "https://github.com/Flagsmith/flagsmith-private/", rev = "v0.1.0" } [tool.poetry.group.dev.dependencies]