Skip to content
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 && \
Expand Down
9 changes: 9 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
"features.multivariate",
"features.versioning",
"features.workflows.core",
"features.release_pipelines.core",
"segments",
"app",
"e2etests",
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions api/audit/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
1 change: 1 addition & 0 deletions api/audit/related_object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 3 additions & 3 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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()


Expand Down
Empty file.
7 changes: 7 additions & 0 deletions api/features/release_pipelines/core/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from core.apps import BaseAppConfig


class ReleasePipelineConfig(BaseAppConfig):
name = "features.release_pipelines.core"
label = "release_pipelines_core"
default = True
1 change: 1 addition & 0 deletions api/features/release_pipelines/core/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MAX_PIPELINE_STAGES = 30
10 changes: 10 additions & 0 deletions api/features/release_pipelines/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -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]
Original file line number Diff line number Diff line change
@@ -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"
),
),
]
Empty file.
Loading
Loading