Skip to content

Commit 550f092

Browse files
committed
wip: release pipeline
1 parent bb9f3f6 commit 550f092

File tree

16 files changed

+514
-9
lines changed

16 files changed

+514
-9
lines changed

api/app/settings/common.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"features.multivariate",
114114
"features.versioning",
115115
"features.workflows.core",
116+
"features.release_pipelines.core",
116117
"segments",
117118
"app",
118119
"e2etests",
@@ -1067,6 +1068,14 @@
10671068
if importlib.util.find_spec("workflows_logic.stale_flags") is not None:
10681069
INSTALLED_APPS.append("workflows_logic.stale_flags")
10691070

1071+
RELEASE_PIPELINES_LOGIC_INSTALLED = (
1072+
importlib.util.find_spec("release_pipelines_logic") is not None
1073+
)
1074+
1075+
if RELEASE_PIPELINES_LOGIC_INSTALLED:
1076+
INSTALLED_APPS.append("release_pipelines_logic")
1077+
1078+
10701079
# Additional functionality for restricting authentication to a set of authentication methods in Flagsmith SaaS
10711080
AUTH_CONTROLLER_INSTALLED = importlib.util.find_spec("auth_controller") is not None
10721081
if AUTH_CONTROLLER_INSTALLED:

api/conftest.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import os
34
import site
@@ -17,6 +18,7 @@
1718
from django.core.cache import caches
1819
from django.db.backends.base.creation import TEST_DATABASE_PREFIX
1920
from django.test.utils import setup_databases
21+
from django.urls import reverse
2022
from flag_engine.segments.constants import EQUAL
2123
from moto import mock_dynamodb # type: ignore[import-untyped]
2224
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table
@@ -25,6 +27,7 @@
2527
from pytest_django.fixtures import SettingsWrapper
2628
from pytest_django.plugin import blocking_manager_key
2729
from pytest_mock import MockerFixture
30+
from rest_framework import status
2831
from rest_framework.authtoken.models import Token
2932
from rest_framework.test import APIClient
3033
from task_processor.task_run_method import TaskRunMethod
@@ -83,6 +86,8 @@
8386
from tests.test_helpers import fix_issue_3869
8487
from tests.types import (
8588
AdminClientAuthType,
89+
GetEnvironmentFlagsResponseJSONCallable,
90+
GetIdentityFlagsResponseJSONCallable,
8691
WithEnvironmentPermissionsCallable,
8792
WithOrganisationPermissionsCallable,
8893
WithProjectPermissionsCallable,
@@ -548,7 +553,7 @@ def multivariate_options(
548553

549554

550555
@pytest.fixture()
551-
def identity_matching_segment(project, trait): # type: ignore[no-untyped-def]
556+
def identity_matching_segment(project: Project, trait: Trait) -> Segment:
552557
segment = Segment.objects.create(name="Matching segment", project=project)
553558
matching_rule = SegmentRule.objects.create(
554559
segment=segment, type=SegmentRule.ALL_RULE
@@ -567,6 +572,13 @@ def api_client(): # type: ignore[no-untyped-def]
567572
return APIClient()
568573

569574

575+
@pytest.fixture()
576+
def sdk_client(environment: Environment) -> APIClient:
577+
client = APIClient()
578+
client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key)
579+
return client
580+
581+
570582
@pytest.fixture()
571583
def feature(project: Project, environment: Environment) -> Feature:
572584
return Feature.objects.create(name="Test Feature1", project=project) # type: ignore[no-any-return]
@@ -1265,3 +1277,46 @@ def set_github_webhook_secret() -> None:
12651277
from django.conf import settings
12661278

12671279
settings.GITHUB_WEBHOOK_SECRET = "secret-key"
1280+
1281+
1282+
# TODO: move _flags_response_json to common?
1283+
@pytest.fixture()
1284+
def get_environment_flags_response_json(
1285+
sdk_client: APIClient,
1286+
) -> GetEnvironmentFlagsResponseJSONCallable:
1287+
get_environment_flags_url = reverse("api-v1:flags")
1288+
1289+
def _get_environment_flags_response_json(num_expected_flags: int) -> typing.Dict: # type: ignore[type-arg]
1290+
_response = sdk_client.get(get_environment_flags_url)
1291+
assert _response.status_code == status.HTTP_200_OK
1292+
_response_json = _response.json()
1293+
assert len(_response_json) == num_expected_flags
1294+
return _response_json # type: ignore[no-any-return]
1295+
1296+
return _get_environment_flags_response_json
1297+
1298+
1299+
@pytest.fixture()
1300+
def get_identity_flags_response_json( # type: ignore[no-untyped-def]
1301+
sdk_client: APIClient, identity: Identity
1302+
) -> GetIdentityFlagsResponseJSONCallable:
1303+
identities_url = reverse("api-v1:sdk-identities")
1304+
1305+
def _get_identity_flags_response_json( # type: ignore[no-untyped-def]
1306+
num_expected_flags: int, identifier: str = identity.identifier, **traits
1307+
) -> typing.Dict: # type: ignore[type-arg]
1308+
traits = traits or {}
1309+
data = {
1310+
"identifier": identifier,
1311+
"traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()],
1312+
}
1313+
1314+
_response = sdk_client.post(
1315+
identities_url, data=json.dumps(data), content_type="application/json"
1316+
)
1317+
assert _response.status_code == status.HTTP_200_OK
1318+
_response_json = _response.json()
1319+
assert len(_response_json["flags"]) == num_expected_flags
1320+
return _response_json # type: ignore[no-any-return]
1321+
1322+
return _get_identity_flags_response_json # type: ignore[return-value]

api/features/release_pipelines/core/__init__.py

Whitespace-only changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ReleasePipelineConfig(AppConfig):
5+
name = "features.release_pipelines.core"
6+
label = "release_pipelines_core"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from rest_framework import status
2+
from rest_framework.exceptions import APIException
3+
4+
5+
class ReleasePipelineError(APIException):
6+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
7+
8+
9+
class InvalidPipelineStateError(ReleasePipelineError):
10+
status_code = status.HTTP_400_BAD_REQUEST # type: ignore[assignment]
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Generated by Django 4.2.21 on 2025-06-09 03:49
2+
3+
from django.conf import settings
4+
import django.core.validators
5+
from django.db import migrations, models
6+
import django.db.models.deletion
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
initial = True
12+
13+
dependencies = [
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
("projects", "0027_add_create_project_level_change_requests_permission"),
16+
("environments", "0037_add_uuid_field"),
17+
]
18+
19+
operations = [
20+
migrations.CreateModel(
21+
name="PipelineStage",
22+
fields=[
23+
(
24+
"id",
25+
models.AutoField(
26+
auto_created=True,
27+
primary_key=True,
28+
serialize=False,
29+
verbose_name="ID",
30+
),
31+
),
32+
("name", models.CharField(max_length=255)),
33+
(
34+
"order",
35+
models.PositiveSmallIntegerField(
36+
validators=[django.core.validators.MaxValueValidator(30)]
37+
),
38+
),
39+
(
40+
"environment",
41+
models.ForeignKey(
42+
on_delete=django.db.models.deletion.CASCADE,
43+
related_name="pipeline_stages",
44+
to="environments.environment",
45+
),
46+
),
47+
],
48+
),
49+
migrations.CreateModel(
50+
name="ReleasePipeline",
51+
fields=[
52+
(
53+
"id",
54+
models.AutoField(
55+
auto_created=True,
56+
primary_key=True,
57+
serialize=False,
58+
verbose_name="ID",
59+
),
60+
),
61+
("name", models.CharField(max_length=255)),
62+
("description", models.TextField(blank=True, null=True)),
63+
("published_at", models.DateTimeField(blank=True, null=True)),
64+
(
65+
"project",
66+
models.ForeignKey(
67+
on_delete=django.db.models.deletion.CASCADE,
68+
related_name="release_pipelines",
69+
to="projects.project",
70+
),
71+
),
72+
(
73+
"published_by",
74+
models.ForeignKey(
75+
blank=True,
76+
null=True,
77+
on_delete=django.db.models.deletion.SET_NULL,
78+
related_name="published_release_pipelines",
79+
to=settings.AUTH_USER_MODEL,
80+
),
81+
),
82+
],
83+
),
84+
migrations.CreateModel(
85+
name="PipelineStageTrigger",
86+
fields=[
87+
(
88+
"id",
89+
models.AutoField(
90+
auto_created=True,
91+
primary_key=True,
92+
serialize=False,
93+
verbose_name="ID",
94+
),
95+
),
96+
(
97+
"trigger_type",
98+
models.CharField(
99+
choices=[
100+
("ON_ENTER", "Trigger when flag enters stage"),
101+
("WAIT_FOR", "Trigger after waiting for x amount of time"),
102+
],
103+
default="ON_ENTER",
104+
max_length=50,
105+
),
106+
),
107+
("trigger_body", models.JSONField(null=True)),
108+
(
109+
"stage",
110+
models.OneToOneField(
111+
on_delete=django.db.models.deletion.CASCADE,
112+
related_name="trigger",
113+
to="release_pipelines_core.pipelinestage",
114+
),
115+
),
116+
],
117+
),
118+
migrations.CreateModel(
119+
name="PipelineStageAction",
120+
fields=[
121+
(
122+
"id",
123+
models.AutoField(
124+
auto_created=True,
125+
primary_key=True,
126+
serialize=False,
127+
verbose_name="ID",
128+
),
129+
),
130+
(
131+
"action_type",
132+
models.CharField(
133+
choices=[
134+
(
135+
"TOGGLE_FEATURE",
136+
"Enable/Disable Feature for the environment",
137+
),
138+
(
139+
"UPDATE_FEATURE_VALUE",
140+
"Update Feature Value for the environment",
141+
),
142+
(
143+
"TOGGLE_FEATURE_FOR_SEGMENT",
144+
"Enable/Disable Feature for a specific segment",
145+
),
146+
(
147+
"UPDATE_FEATURE_VALUE_FOR_SEGMENT",
148+
"Update Feature Value for a specific segment",
149+
),
150+
],
151+
default="TOGGLE_FEATURE",
152+
max_length=50,
153+
),
154+
),
155+
("action_body", models.JSONField(null=True)),
156+
(
157+
"stage",
158+
models.ForeignKey(
159+
on_delete=django.db.models.deletion.CASCADE,
160+
related_name="actions",
161+
to="release_pipelines_core.pipelinestage",
162+
),
163+
),
164+
],
165+
),
166+
migrations.AddField(
167+
model_name="pipelinestage",
168+
name="pipeline",
169+
field=models.ForeignKey(
170+
on_delete=django.db.models.deletion.CASCADE,
171+
related_name="stages",
172+
to="release_pipelines_core.releasepipeline",
173+
),
174+
),
175+
migrations.AddConstraint(
176+
model_name="pipelinestage",
177+
constraint=models.UniqueConstraint(
178+
fields=("pipeline", "order"), name="unique_pipeline_stage_order"
179+
),
180+
),
181+
]

api/features/release_pipelines/core/migrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)