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/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..0d6cede4a12b 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -548,8 +548,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,7 +563,7 @@ 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() 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..40db5b9b013c --- /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) + + 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..9d5b8d97bc01 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,<4.0" +groups = ["release-pipelines"] +files = [] +develop = false + +[package.dependencies] +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 = "v0.1.0" +resolved_reference = "b75b2dea7a9b0e67fe78800305e73130cbb73509" + [[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-pipeline-logic-v1" +resolved_reference = "df9a4b160204fd0eeab69bf678d74b5de941d5ac" +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 = "a77fbee7bc35ae5c1732a54ae358a949c10ba017523dcdbc5e999b62d2888236" 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..de2650545639 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 = "v0.1.0" } + + [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..8088e1750beb --- /dev/null +++ b/api/tests/unit/features/release_pipeline/core/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from features.release_pipelines.core.models import ( + ReleasePipeline, +) +from projects.models import Project + + +@pytest.fixture() +def release_pipeline(project: Project) -> ReleasePipeline: + 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 new file mode 100644 index 000000000000..ff4fdf6f0054 --- /dev/null +++ b/api/tests/unit/features/release_pipeline/core/test_unit_release_pipeline_models.py @@ -0,0 +1,155 @@ +import pytest + +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 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( + 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 # type: ignore[union-attr] + + +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 # 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( + 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", [