From f7220ea12c23ec115522f5e239a4074c850daccb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 15:34:45 +0000 Subject: [PATCH 01/77] chore: update Flagsmith environment document (#4823) Co-authored-by: matthewelwell --- api/integrations/flagsmith/data/environment.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/integrations/flagsmith/data/environment.json b/api/integrations/flagsmith/data/environment.json index 14c697bb401d..f843b8ee43e1 100644 --- a/api/integrations/flagsmith/data/environment.json +++ b/api/integrations/flagsmith/data/environment.json @@ -15,7 +15,7 @@ "multivariate_feature_state_values": [] }, { - "django_id": 615532, + "django_id": 627339, "enabled": true, "feature": { "id": 96656, @@ -24,11 +24,11 @@ }, "feature_segment": null, "feature_state_value": null, - "featurestate_uuid": "49db2260-d4d4-47be-b84c-7197c46b10fb", + "featurestate_uuid": "566228a6-78b0-4696-a023-feb149b61fd2", "multivariate_feature_state_values": [] }, { - "django_id": 615534, + "django_id": 627341, "enabled": true, "feature": { "id": 96657, @@ -37,7 +37,7 @@ }, "feature_segment": null, "feature_state_value": null, - "featurestate_uuid": "7489b387-31f8-436d-a862-e2aa12939b5b", + "featurestate_uuid": "4f7cae64-afed-416c-ada8-e6b034dff436", "multivariate_feature_state_values": [] }, { From cbd60d942c5a58030af548f8b5af0057ada3cf18 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 12 Nov 2024 13:51:36 +0530 Subject: [PATCH 02/77] feat(my-permissions): Add tag based permissions (#4824) --- api/Makefile | 2 +- api/permissions/permissions_calculator.py | 17 ++++++++++++++++- api/permissions/serializers.py | 6 ++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/api/Makefile b/api/Makefile index b62f0cc2ab65..1edac404dedf 100644 --- a/api/Makefile +++ b/api/Makefile @@ -12,7 +12,7 @@ POETRY_VERSION ?= 1.8.3 GUNICORN_LOGGER_CLASS ?= util.logging.GunicornJsonCapableLogger SAML_REVISION ?= v1.6.4 -RBAC_REVISION ?= v0.9.0 +RBAC_REVISION ?= v0.10.0 -include .env-local -include $(DOTENV_OVERRIDE_FILE) diff --git a/api/permissions/permissions_calculator.py b/api/permissions/permissions_calculator.py index ad543ee1c951..14e2b97a36f4 100644 --- a/api/permissions/permissions_calculator.py +++ b/api/permissions/permissions_calculator.py @@ -107,11 +107,26 @@ def permissions(self) -> typing.Set[str]: ).union( reduce( lambda a, b: a.union(b), - [role.permissions for role in self.roles], + [ + role_permission.permissions + for role_permission in self.roles + if not role_permission.role.tags + ], set(), ) ) + @property + def tag_based_permissions(self) -> list[dict]: + return [ + { + "permissions": role_permission.permissions, + "tags": role_permission.role.tags, + } + for role_permission in self.roles + if role_permission.role.tags + ] + def get_project_permission_data(project_id: int, user_id: int) -> PermissionData: project_permission_svc = _ProjectPermissionService(project_id, user_id) diff --git a/api/permissions/serializers.py b/api/permissions/serializers.py index 53b66eae2d68..7406f15e3dc6 100644 --- a/api/permissions/serializers.py +++ b/api/permissions/serializers.py @@ -37,6 +37,12 @@ def update(self, instance, validated_data): return instance +class TagBasedPermissionSerializer(serializers.Serializer): + permissions = serializers.ListField(child=serializers.CharField()) + tags = serializers.ListField(child=serializers.IntegerField()) + + class UserObjectPermissionsSerializer(serializers.Serializer): permissions = serializers.ListField(child=serializers.CharField()) admin = serializers.BooleanField() + tag_based_permissions = TagBasedPermissionSerializer(many=True) From b2d7500c6fffb80522e2c940a0031e8df5387556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20L=C3=B3pez=20Dato?= Date: Tue, 12 Nov 2024 07:02:49 -0300 Subject: [PATCH 03/77] feat: log commands in Docker entrypoint (#4826) --- api/scripts/run-docker.sh | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/api/scripts/run-docker.sh b/api/scripts/run-docker.sh index 88ae045c98f5..2f299ea805af 100755 --- a/api/scripts/run-docker.sh +++ b/api/scripts/run-docker.sh @@ -1,16 +1,16 @@ #!/bin/sh set -e -function waitfordb() { +waitfordb() { if [ -z "${SKIP_WAIT_FOR_DB}" ]; then python manage.py waitfordb "$@" fi } -function migrate () { +migrate () { waitfordb && python manage.py migrate && python manage.py createcachetable } -function serve() { +serve() { # configuration parameters for statsd. Docs can be found here: # https://docs.gunicorn.org/en/stable/instrumentation.html export STATSD_PORT=${STATSD_PORT:-8125} @@ -31,9 +31,9 @@ function serve() { ${STATSD_HOST:+--statsd-prefix $STATSD_PREFIX} \ app.wsgi } -function run_task_processor() { +run_task_processor() { waitfordb --waitfor 30 --migrations - if [[ -n "$ANALYTICS_DATABASE_URL" || -n "$DJANGO_DB_NAME_ANALYTICS" ]]; then + if [ -n "$ANALYTICS_DATABASE_URL" ] || [ -n "$DJANGO_DB_NAME_ANALYTICS" ]; then waitfordb --waitfor 30 --migrations --database analytics fi RUN_BY_PROCESSOR=1 exec python manage.py runprocessor \ @@ -42,29 +42,31 @@ function run_task_processor() { --numthreads ${TASK_PROCESSOR_NUM_THREADS:-5} \ --queuepopsize ${TASK_PROCESSOR_QUEUE_POP_SIZE:-10} } -function migrate_analytics_db(){ +migrate_analytics_db(){ # if `$ANALYTICS_DATABASE_URL` or DJANGO_DB_NAME_ANALYTICS is set # run the migration command - if [[ -z "$ANALYTICS_DATABASE_URL" && -z "$DJANGO_DB_NAME_ANALYTICS" ]]; then + if [ -z "$ANALYTICS_DATABASE_URL" ] && [ -z "$DJANGO_DB_NAME_ANALYTICS" ]; then return 0 fi python manage.py migrate --database analytics } -function bootstrap(){ +bootstrap(){ python manage.py bootstrap } -function default(){ +default(){ python manage.py "$@" } -if [ "$1" == "migrate" ]; then +set -x + +if [ "$1" = "migrate" ]; then migrate migrate_analytics_db -elif [ "$1" == "serve" ]; then +elif [ "$1" = "serve" ]; then serve -elif [ "$1" == "run-task-processor" ]; then +elif [ "$1" = "run-task-processor" ]; then run_task_processor -elif [ "$1" == "migrate-and-serve" ]; then +elif [ "$1" = "migrate-and-serve" ]; then migrate migrate_analytics_db bootstrap From 247857ff606ad0c8c00d16c2af172febe3c9df19 Mon Sep 17 00:00:00 2001 From: Flagsmith Bot <65724737+flagsmithdev@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:03:46 +0000 Subject: [PATCH 04/77] chore(main): release 2.153.0 (#4820) --- .release-please-manifest.json | 2 +- CHANGELOG.md | 15 +++++++++++++++ version.txt | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 68a15e09f046..05cee4cf32d9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.152.0" + ".": "2.153.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dce7cae91d2..492afe808994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2.153.0](https://github.com/Flagsmith/flagsmith/compare/v2.152.0...v2.153.0) (2024-11-12) + + +### Features + +* log commands in Docker entrypoint ([#4826](https://github.com/Flagsmith/flagsmith/issues/4826)) ([b2d7500](https://github.com/Flagsmith/flagsmith/commit/b2d7500c6fffb80522e2c940a0031e8df5387556)) +* **my-permissions:** Add tag based permissions ([#4824](https://github.com/Flagsmith/flagsmith/issues/4824)) ([cbd60d9](https://github.com/Flagsmith/flagsmith/commit/cbd60d942c5a58030af548f8b5af0057ada3cf18)) + + +### Bug Fixes + +* Allow any auth except LDAP and SAML to change email ([#4810](https://github.com/Flagsmith/flagsmith/issues/4810)) ([10eb571](https://github.com/Flagsmith/flagsmith/commit/10eb571bba1821324daf3d56edee96b3d391b977)) +* Edit identity override with prevent flag defaults enabled ([#4809](https://github.com/Flagsmith/flagsmith/issues/4809)) ([0f9b24b](https://github.com/Flagsmith/flagsmith/commit/0f9b24b5df354f01b6de9c9c754c468e65c5c081)) +* make clone_feature_states_async write only ([#4811](https://github.com/Flagsmith/flagsmith/issues/4811)) ([513b088](https://github.com/Flagsmith/flagsmith/commit/513b088edf25b05f2b7c15604826f21c2a0b3b18)) + ## [2.152.0](https://github.com/Flagsmith/flagsmith/compare/v2.151.0...v2.152.0) (2024-11-06) diff --git a/version.txt b/version.txt index 668326db4513..77f9f1571dce 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.152.0 +2.153.0 From 23ab3c1a8a26284e87503b389c597b58866ee27b Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 12 Nov 2024 14:38:10 +0000 Subject: [PATCH 05/77] fix: Handle environment admin not being able to check VIEW_PROJECT permissions (#4827) --- frontend/web/components/EditPermissions.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index 957e83f7e065..d5278568248b 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -62,6 +62,7 @@ import classNames from 'classnames' import OrganisationProvider from 'common/providers/OrganisationProvider' import { useHasPermission } from 'common/providers/Permission' import PlanBasedAccess from './PlanBasedAccess' +import WarningMessage from './WarningMessage' const Project = require('common/project') @@ -255,6 +256,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( createRolePermissionGroup, { data: groupsData, isSuccess: groupAdded }, ] = useCreateRolePermissionGroupMutation() + const [parentWarning, setParentWarning] = useState(false) const [deleteRolePermissionGroup] = useDeleteRolePermissionGroupMutation() @@ -394,6 +396,10 @@ const _EditPermissionsModal: FC = withAdminPermissions( setParentError(false) } }) + .catch(() => { + setParentWarning(true) + return [] + }) } if (!role) { parentGet @@ -758,6 +764,13 @@ const _EditPermissionsModal: FC = withAdminPermissions( .

+ {!!parentWarning && ( + + You do not have permission to verify whether this user has view + access for this {parentLevel}. If you need assistance, please + contact a {parentLevel} administrator. + + )} {parentError && !role && (
From b1df3c0271e43973b8989cf69c4dcb8d773633e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20L=C3=B3pez=20Dato?= Date: Tue, 12 Nov 2024 11:41:53 -0300 Subject: [PATCH 06/77] chore: Only show stale flag warning when feature_versioning enabled (#4825) --- frontend/web/components/StaleFlagWarning.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/web/components/StaleFlagWarning.tsx b/frontend/web/components/StaleFlagWarning.tsx index 641782d25e01..8e4780948036 100644 --- a/frontend/web/components/StaleFlagWarning.tsx +++ b/frontend/web/components/StaleFlagWarning.tsx @@ -8,12 +8,14 @@ import { IonIcon } from '@ionic/react' import { close, warning } from 'ionicons/icons' import Tooltip from './Tooltip' import ProjectStore from 'common/stores/project-store' +import flagsmith from 'flagsmith/isomorphic' type StaleFlagWarningType = { projectFlag: ProjectFlag } const StaleFlagWarning: FC = ({ projectFlag }) => { + if (!flagsmith.hasFeature('feature_versioning')) return null const protectedTags = getProtectedTags(projectFlag, `${projectFlag.project}`) if (protectedTags?.length) { return null From ea6a169d9f84172850b2b01e5fd7d70b8852c9bf Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 12 Nov 2024 15:06:01 +0000 Subject: [PATCH 07/77] fix: flagsmith stale flags check (#4831) --- frontend/web/components/StaleFlagWarning.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/StaleFlagWarning.tsx b/frontend/web/components/StaleFlagWarning.tsx index 8e4780948036..2cb576f7a301 100644 --- a/frontend/web/components/StaleFlagWarning.tsx +++ b/frontend/web/components/StaleFlagWarning.tsx @@ -1,21 +1,20 @@ import { FC } from 'react' -import Tag from './tags/Tag' import Constants from 'common/constants' import moment from 'moment' import { Project, ProjectFlag } from 'common/types/responses' import { getProtectedTags } from 'common/utils/getProtectedTags' import { IonIcon } from '@ionic/react' -import { close, warning } from 'ionicons/icons' +import { warning } from 'ionicons/icons' import Tooltip from './Tooltip' import ProjectStore from 'common/stores/project-store' -import flagsmith from 'flagsmith/isomorphic' +import Utils from 'common/utils/utils' type StaleFlagWarningType = { projectFlag: ProjectFlag } const StaleFlagWarning: FC = ({ projectFlag }) => { - if (!flagsmith.hasFeature('feature_versioning')) return null + if (!Utils.getFlagsmithHasFeature('feature_versioning')) return null const protectedTags = getProtectedTags(projectFlag, `${projectFlag.project}`) if (protectedTags?.length) { return null From 0d1c64a3db04cd7d56f39b5b3fa0fee4340504eb Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 13 Nov 2024 08:33:10 +0000 Subject: [PATCH 08/77] fix: replace alter field with adding a new field (#4817) --- .../unit/users/test_unit_users_migrations.py | 53 +++++++++++++++++++ .../0039_alter_ffadminuser_first_name.py | 52 ++++++++++++++++-- .../0040_alter_ffadminuser_first_name_fix.py | 42 +++++++++++++++ api/users/models.py | 8 ++- 4 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 api/tests/unit/users/test_unit_users_migrations.py create mode 100644 api/users/migrations/0040_alter_ffadminuser_first_name_fix.py diff --git a/api/tests/unit/users/test_unit_users_migrations.py b/api/tests/unit/users/test_unit_users_migrations.py new file mode 100644 index 000000000000..4f55dbc567c8 --- /dev/null +++ b/api/tests/unit/users/test_unit_users_migrations.py @@ -0,0 +1,53 @@ +import pytest +from django.conf import settings +from django_test_migrations.migrator import Migrator + +pytestmark = pytest.mark.skipif( + settings.SKIP_MIGRATION_TESTS is True, + reason="Skip migration tests to speed up tests where necessary", +) + + +def test_0039_ffadminuser_first_name_v2__values_expected(migrator: Migrator) -> None: + # Given + old_state = migrator.apply_initial_migration( + ("users", "0038_create_hubspot_tracker") + ) + OldFFAdminUser = old_state.apps.get_model("users", "FFAdminUser") + + user = OldFFAdminUser.objects.create(first_name="Testfirstname") + + # When + new_state = migrator.apply_tested_migration( + ("users", "0039_alter_ffadminuser_first_name") + ) + NewFFAdminUser = new_state.apps.get_model("users", "FFAdminUser") + + # Then + assert NewFFAdminUser.objects.get(id=user.id).first_name == user.first_name + + +def test_0039_ffadminuser_first_name_v2__reverse__values_expected( + migrator: Migrator, +) -> None: + # Given + old_state = migrator.apply_initial_migration( + ("users", "0039_alter_ffadminuser_first_name") + ) + NewFFAdminUser = old_state.apps.get_model("users", "FFAdminUser") + + user = NewFFAdminUser.objects.create( + first_name="TestfirstnameTestfirstnameTestfirstnameTestfirstname" + ) + + # When + new_state = migrator.apply_tested_migration( + ("users", "0038_create_hubspot_tracker") + ) + OldFFAdminUser = new_state.apps.get_model("users", "FFAdminUser") + + # Then + assert ( + OldFFAdminUser.objects.get(id=user.id).first_name + == "TestfirstnameTestfirstnameTest" + ) diff --git a/api/users/migrations/0039_alter_ffadminuser_first_name.py b/api/users/migrations/0039_alter_ffadminuser_first_name.py index 11406f6da6e9..0731faf64c2e 100644 --- a/api/users/migrations/0039_alter_ffadminuser_first_name.py +++ b/api/users/migrations/0039_alter_ffadminuser_first_name.py @@ -1,6 +1,21 @@ # Generated by Django 4.2.16 on 2024-11-04 17:09 - +from django.apps.registry import Apps from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.models import F +from django.db.models.functions import Left + + +def populate_new_first_name_field(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FFAdminUser = apps.get_model("users", "FFAdminUser") + + FFAdminUser.objects.update(first_name_v2=F("first_name")) + + +def populate_old_first_name_field(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FFAdminUser = apps.get_model("users", "FFAdminUser") + + FFAdminUser.objects.update(first_name=Left("first_name_v2", 30)) class Migration(migrations.Migration): @@ -10,9 +25,38 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AlterField( + migrations.AddField( model_name="ffadminuser", - name="first_name", - field=models.CharField(max_length=150, verbose_name="first name"), + name="first_name_v2", + field=models.CharField(max_length=150, default=""), ), + migrations.RunPython(populate_new_first_name_field, reverse_code=populate_old_first_name_field), + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RenameField( + model_name="ffadminuser", + old_name="first_name", + new_name="_first_name_old", + ), + migrations.RenameField( + model_name="ffadminuser", + old_name="first_name_v2", + new_name="first_name", + ), + migrations.AlterField( + model_name="ffadminuser", + name="_first_name_old", + field=models.CharField( + db_column="first_name", max_length=30, verbose_name="first name" + ), + ), + migrations.AlterField( + model_name="ffadminuser", + name="first_name", + field=models.CharField( + db_column="first_name_v2", max_length=150, verbose_name="first name" + ), + ), + ] + ) ] diff --git a/api/users/migrations/0040_alter_ffadminuser_first_name_fix.py b/api/users/migrations/0040_alter_ffadminuser_first_name_fix.py new file mode 100644 index 000000000000..2f186185185e --- /dev/null +++ b/api/users/migrations/0040_alter_ffadminuser_first_name_fix.py @@ -0,0 +1,42 @@ +""" +This migration exists because 0039 was retrospectively fixed to prevent locking issues +on the users table. This migration ensures that: + + 1. in environments where 0039 was run before the fix was added, the new column is + correctly added to the database to match what the ORM expects and the data + is correctly migrted to the new column. + 2. in environments where 0039 was run after the fix was added, nothing happens. + +Note that we only need to care about Postgres databases here because we did not release +the bad migration for 0039 in a format that can be consumed by oracle / mysql. +""" + +# Generated by Django 4.2.16 on 2024-11-12 18:05 +import logging + +from django.db import migrations + +from core.migration_helpers import PostgresOnlyRunSQL + +forwards_sql = """ +DO +$do$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users_ffadminuser' and column_name='first_name_v2') THEN + ALTER TABLE "users_ffadminuser" ADD COLUMN "first_name_v2" VARCHAR(150) NOT NULL DEFAULT ''; + UPDATE "users_ffadminuser" SET "first_name_v2" = "first_name"; + END IF; +END +$do$ +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0039_alter_ffadminuser_first_name"), + ] + + operations = [ + PostgresOnlyRunSQL(forwards_sql, reverse_sql=""), + ] diff --git a/api/users/models.py b/api/users/models.py index 2030ad05c3f7..32d7a567aa18 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -99,7 +99,9 @@ class FFAdminUser(LifecycleModel, AbstractUser): email = models.EmailField(unique=True, null=False) objects = UserManager() username = models.CharField(unique=True, max_length=150, null=True, blank=True) - first_name = models.CharField("first name", max_length=150) + first_name = models.CharField( + "first name", max_length=150, db_column="first_name_v2" + ) last_name = models.CharField("last name", max_length=150) google_user_id = models.CharField(max_length=50, null=True, blank=True) github_user_id = models.CharField(max_length=50, null=True, blank=True) @@ -111,6 +113,10 @@ class FFAdminUser(LifecycleModel, AbstractUser): choices=SignUpType.choices, max_length=100, blank=True, null=True ) + _first_name_old = models.CharField( + "first name", max_length=30, db_column="first_name" + ) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) USERNAME_FIELD = "email" From 4a310b0c314de4499425fbb5584f94a1f77c640c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 13 Nov 2024 08:53:20 +0000 Subject: [PATCH 09/77] fix: prevent lock when adding FFAdmin.uuid column (#4832) --- api/users/migrations/0037_add_uuid_field_to_user_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/users/migrations/0037_add_uuid_field_to_user_model.py b/api/users/migrations/0037_add_uuid_field_to_user_model.py index f5eb30f549e8..1f51fd867439 100644 --- a/api/users/migrations/0037_add_uuid_field_to_user_model.py +++ b/api/users/migrations/0037_add_uuid_field_to_user_model.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='ffadminuser', name='uuid', - field=models.UUIDField(default=uuid.uuid4), + field=models.UUIDField(default=None, null=True), ), migrations.RunPython(set_default_uuids, reverse_code=migrations.RunPython.noop), migrations.AlterField( From 9bbfdf0d2bc4cb95fa0d029144c4bc61fb23f0bc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 14 Nov 2024 08:00:09 +0000 Subject: [PATCH 10/77] fix: Custom Gunicorn logger not sending StatsD stats (#4819) --- api/tests/unit/util/test_logging.py | 67 +++++++++++++++++++++++++++-- api/util/logging.py | 31 ++++++------- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/api/tests/unit/util/test_logging.py b/api/tests/unit/util/test_logging.py index 205b31860beb..389437ffdaf4 100644 --- a/api/tests/unit/util/test_logging.py +++ b/api/tests/unit/util/test_logging.py @@ -2,12 +2,13 @@ import logging import logging.config import os +from datetime import datetime import pytest -from gunicorn.config import Config +from gunicorn.config import AccessLogFormat, Config from pytest_django.fixtures import SettingsWrapper -from util.logging import JsonFormatter +from util.logging import GunicornAccessLogJsonFormatter, JsonFormatter @pytest.mark.freeze_time("2023-12-08T06:05:47.320000+00:00") @@ -28,7 +29,7 @@ def test_json_formatter__outputs_expected( expected_tb_string = ( "Traceback (most recent call last):\n" f' File "{expected_module_path}",' - " line 37, in _log_traceback\n" + " line 38, in _log_traceback\n" " raise Exception()\nException" ) @@ -64,6 +65,66 @@ def _log_traceback() -> None: ] +@pytest.mark.freeze_time("2023-12-08T06:05:47.320000+00:00") +def test_gunicorn_access_log_json_formatter__outputs_expected() -> None: + # Given + gunicorn_access_log_json_formatter = GunicornAccessLogJsonFormatter() + log_record = logging.LogRecord( + name="gunicorn.access", + level=logging.INFO, + pathname="", + lineno=1, + msg=AccessLogFormat.default, + args={ + "a": "requests", + "b": "-", + "B": None, + "D": 1000000, + "f": "-", + "h": "192.168.0.1", + "H": None, + "l": "-", + "L": "1.0", + "m": "GET", + "M": 1000, + "p": "<42>", + "q": "foo=bar", + "r": "GET", + "s": 200, + "T": 1, + "t": datetime.fromisoformat("2023-12-08T06:05:47.320000+00:00").strftime( + "[%d/%b/%Y:%H:%M:%S %z]" + ), + "u": "-", + "U": "/test", + }, + exc_info=None, + ) + expected_pid = os.getpid() + + # When + json_log = gunicorn_access_log_json_formatter.get_json_record(log_record) + + # Then + assert json_log == { + "duration_in_ms": 1000, + "levelname": "INFO", + "logger_name": "gunicorn.access", + "message": '192.168.0.1 - - [08/Dec/2023:06:05:47 +0000] "GET" 200 - "-" "requests"', + "method": "GET", + "path": "/test?foo=bar", + "pid": "<42>", + "process_id": expected_pid, + "referer": "-", + "remote_ip": "192.168.0.1", + "status": "200", + "thread_name": "MainThread", + "time": "2023-12-08T06:05:47+00:00", + "timestamp": "2023-12-08 06:05:47,319", + "user_agent": "requests", + } + + def test_gunicorn_json_capable_logger__non_existent_setting__not_raises( settings: SettingsWrapper, ) -> None: diff --git a/api/util/logging.py b/api/util/logging.py index 4f4764d44003..45b3fc18fcce 100644 --- a/api/util/logging.py +++ b/api/util/logging.py @@ -6,7 +6,7 @@ from django.conf import settings from gunicorn.config import Config -from gunicorn.glogging import Logger as GunicornLogger +from gunicorn.instrument.statsd import Statsd as GunicornLogger class JsonFormatter(logging.Formatter): @@ -27,30 +27,27 @@ def get_json_record(self, record: logging.LogRecord) -> dict[str, Any]: return json_record def format(self, record: logging.LogRecord) -> str: - try: - return json.dumps(self.get_json_record(record)) - except (ValueError, TypeError) as e: - return json.dumps({"message": f"{e} when dumping log"}) + return json.dumps(self.get_json_record(record)) class GunicornAccessLogJsonFormatter(JsonFormatter): def get_json_record(self, record: logging.LogRecord) -> dict[str, Any]: - response_time = datetime.strptime(record.args["t"], "[%d/%b/%Y:%H:%M:%S %z]") - url = record.args["U"] - if record.args["q"]: - url += f"?{record.args['q']}" + args = record.args + url = args["U"] + if q := args["q"]: + url += f"?{q}" return { **super().get_json_record(record), - "time": response_time.isoformat(), + "time": datetime.strptime(args["t"], "[%d/%b/%Y:%H:%M:%S %z]").isoformat(), "path": url, - "remote_ip": record.args["h"], - "method": record.args["m"], - "status": str(record.args["s"]), - "user_agent": record.args["a"], - "referer": record.args["f"], - "duration_in_ms": record.args["M"], - "pid": record.args["p"], + "remote_ip": args["h"], + "method": args["m"], + "status": str(args["s"]), + "user_agent": args["a"], + "referer": args["f"], + "duration_in_ms": args["M"], + "pid": args["p"], } From 9b21af7652ae2cb5758361c2809a345cc79209d3 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 19 Nov 2024 15:06:18 +0530 Subject: [PATCH 11/77] fix(project/realtime): only allow enterprise to enable realtime (#4843) --- api/conftest.py | 35 ++++++++++++++++++- api/projects/serializers.py | 2 +- .../unit/projects/test_unit_projects_views.py | 21 +++++++---- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/api/conftest.py b/api/conftest.py index 334794d88339..d5249fad1554 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -59,7 +59,13 @@ CREATE_PROJECT, MANAGE_USER_GROUPS, ) -from organisations.subscriptions.constants import CHARGEBEE, XERO +from organisations.subscriptions.constants import ( + CHARGEBEE, + FREE_PLAN_ID, + SCALE_UP, + STARTUP, + XERO, +) from permissions.models import PermissionModel from projects.models import ( Project, @@ -324,6 +330,33 @@ def enterprise_subscription(organisation: Organisation) -> Subscription: return organisation.subscription +@pytest.fixture() +def startup_subscription(organisation: Organisation) -> Subscription: + Subscription.objects.filter(organisation=organisation).update( + plan=STARTUP, subscription_id="subscription-id" + ) + organisation.refresh_from_db() + return organisation.subscription + + +@pytest.fixture() +def scale_up_subscription(organisation: Organisation) -> Subscription: + Subscription.objects.filter(organisation=organisation).update( + plan=SCALE_UP, subscription_id="subscription-id" + ) + organisation.refresh_from_db() + return organisation.subscription + + +@pytest.fixture() +def free_subscription(organisation: Organisation) -> Subscription: + Subscription.objects.filter(organisation=organisation).update( + plan=FREE_PLAN_ID, subscription_id="subscription-id" + ) + organisation.refresh_from_db() + return organisation.subscription + + @pytest.fixture() def project(organisation): return Project.objects.create(name="Test Project", organisation=organisation) diff --git a/api/projects/serializers.py b/api/projects/serializers.py index a331a2fa73e5..14f8af3dfa84 100644 --- a/api/projects/serializers.py +++ b/api/projects/serializers.py @@ -69,7 +69,7 @@ class ProjectUpdateOrCreateSerializer( ReadOnlyIfNotValidPlanMixin, ProjectListSerializer ): invalid_plans_regex = r"^(free|startup.*|scale-up.*)$" - field_names = ("stale_flags_limit_days",) + field_names = ("stale_flags_limit_days", "enable_realtime_updates") def get_subscription(self) -> typing.Optional[Subscription]: view = self.context["view"] diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py index 49a18985201f..7f949f3c6fc5 100644 --- a/api/tests/unit/projects/test_unit_projects_views.py +++ b/api/tests/unit/projects/test_unit_projects_views.py @@ -687,11 +687,20 @@ def test_get_project_by_uuid(client, project, mocker, settings, organisation): @pytest.mark.parametrize( - "client", - [(lazy_fixture("admin_master_api_key_client")), (lazy_fixture("admin_client"))], + "subscription, can_update_realtime", + [ + (lazy_fixture("free_subscription"), False), + (lazy_fixture("startup_subscription"), False), + (lazy_fixture("scale_up_subscription"), False), + (lazy_fixture("enterprise_subscription"), True), + ], ) -def test_can_enable_realtime_updates_for_project( - client, project, mocker, settings, organisation +def test_can_enable_realtime_updates_for_enterprise( + admin_client: APIClient, + project: Project, + organisation: Organisation, + subscription: Subscription, + can_update_realtime: bool, ): # Given url = reverse("api-v1:projects:project-detail", args=[project.id]) @@ -703,12 +712,12 @@ def test_can_enable_realtime_updates_for_project( } # When - response = client.put(url, data=data) + response = admin_client.put(url, data=data) # Then assert response.status_code == status.HTTP_200_OK assert response.json()["uuid"] == str(project.uuid) - assert response.json()["enable_realtime_updates"] is True + assert response.json()["enable_realtime_updates"] is can_update_realtime @pytest.mark.parametrize( From 1021d12f0be609a9ce608c1b3f321ae1f8d734d0 Mon Sep 17 00:00:00 2001 From: Instinct <61635505+uinstinct@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:36:13 +0530 Subject: [PATCH 12/77] refactor: typing for button onclick (#4833) --- frontend/web/components/base/forms/Button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/web/components/base/forms/Button.tsx b/frontend/web/components/base/forms/Button.tsx index 194268535c0f..306070be37be 100644 --- a/frontend/web/components/base/forms/Button.tsx +++ b/frontend/web/components/base/forms/Button.tsx @@ -56,7 +56,7 @@ export const Button: FC = ({ const hasPlan = feature ? Utils.getPlansPermission(feature) : true return href || !hasPlan ? ( Date: Tue, 19 Nov 2024 10:27:45 +0000 Subject: [PATCH 13/77] docs: add clarification about SaaS async trait storage (#4834) --- docs/docs/basic-features/managing-identities.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/docs/basic-features/managing-identities.md b/docs/docs/basic-features/managing-identities.md index 6bdf21c01cfd..23ef28d0b6fb 100644 --- a/docs/docs/basic-features/managing-identities.md +++ b/docs/docs/basic-features/managing-identities.md @@ -88,6 +88,13 @@ engine runs. There are some [exceptions to this rule](/clients#server-side-sdks) with Server Side SDKs running in local evaluation mode. +:::info + +Note that, when using our SaaS platform, there might be a short delay from the initial request to write or update traits +for an identity and them being used in subsequent evaluations. + +::: + ### Using Traits as a data-store Traits can also be used to store additional data about your users that would be cumbersome to store within your From 051cc6fe0db5a311998bece472982e19926a2bc5 Mon Sep 17 00:00:00 2001 From: Jiulong Wang Date: Tue, 19 Nov 2024 02:28:59 -0800 Subject: [PATCH 14/77] fix: Google OAuth broken in unified docker image (#4839) --- api/app/settings/common.py | 1 + docker-compose.yml | 4 ++++ docs/docs/deployment/index.md | 11 +++++++++++ 3 files changed, 16 insertions(+) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index c1b0d2096371..ee76139d98d8 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -543,6 +543,7 @@ SECURE_REDIRECT_EXEMPT = env.list("DJANGO_SECURE_REDIRECT_EXEMPT", default=[]) SECURE_REFERRER_POLICY = env.str("DJANGO_SECURE_REFERRER_POLICY", default="same-origin") +SECURE_CROSS_ORIGIN_OPENER_POLICY = env.str("DJANGO_SECURE_CROSS_ORIGIN_OPENER_POLICY", default="same-origin") SECURE_SSL_HOST = env.str("DJANGO_SECURE_SSL_HOST", default=None) SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=False) diff --git a/docker-compose.yml b/docker-compose.yml index 7eb99a893ff6..2c25b4032850 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,10 @@ services: # Enable Task Processor TASK_RUN_METHOD: TASK_PROCESSOR # other options are: SYNCHRONOUSLY, SEPARATE_THREAD (default) + # Uncomment if you want to enable Google OAuth. Note this does not turn Google OAuth on. You still need to use + # Flagsmith on Flagsmith to enable it - https://docs.flagsmith.com/deployment/#oauth_google + # DJANGO_SECURE_CROSS_ORIGIN_OPENER_POLICY: 'same-origin-allow-popups' + # For more info on configuring E-Mails - https://docs.flagsmith.com/deployment/locally-api#environment-variables # Example SMTP: # EMAIL_BACKEND: django.core.mail.backends.smtp.EmailBackend diff --git a/docs/docs/deployment/index.md b/docs/docs/deployment/index.md index 9d10f26a91f1..fb86659c99f8 100644 --- a/docs/docs/deployment/index.md +++ b/docs/docs/deployment/index.md @@ -566,6 +566,17 @@ Create an OAuth application in the Google Developer Console and then provide the } ``` +If you are using the [unified Docker image](https://hub.docker.com/repository/docker/flagsmith/flagsmith), which serves +both the API and the frontend through Django, ensure you configure the following environment variable in your +deployment: + +``` +DJANGO_SECURE_CROSS_ORIGIN_OPENER_POLICY=same-origin-allow-popups +``` + +For those hosting the frontend independently, make sure you set the `Cross-Origin-Opener-Policy` to +`same-origin-allow-popups` for Google OAuth flow to work. + ### Dark Mode We also have a Segment that manages the ui Dark Mode: From a33633f61f9eaf8efd4322af48a862dbebcbe682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20L=C3=B3pez=20Dato?= Date: Tue, 19 Nov 2024 07:29:14 -0300 Subject: [PATCH 15/77] fix: Handle invalid colour codes on tags, allow default colours (#4822) --- .../tags/migrations/0007_alter_tag_color.py | 22 +++++++++++++ api/projects/tags/models.py | 2 +- frontend/common/constants.ts | 1 + frontend/common/utils/utils.tsx | 15 ++++++++- frontend/web/components/FeatureAction.tsx | 6 ++-- frontend/web/components/ToggleChip.js | 15 +++++---- frontend/web/components/tags/Tag.tsx | 11 +++---- frontend/web/components/tags/TagContent.tsx | 31 ++++++------------- 8 files changed, 62 insertions(+), 41 deletions(-) create mode 100644 api/projects/tags/migrations/0007_alter_tag_color.py diff --git a/api/projects/tags/migrations/0007_alter_tag_color.py b/api/projects/tags/migrations/0007_alter_tag_color.py new file mode 100644 index 000000000000..3b2ef5282bdf --- /dev/null +++ b/api/projects/tags/migrations/0007_alter_tag_color.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.16 on 2024-11-08 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tags", "0006_alter_tag_type"), + ] + + operations = [ + migrations.AlterField( + model_name="tag", + name="color", + field=models.CharField( + default="#6837FC", + help_text="Hexadecimal value of the tag color", + max_length=10, + ), + ), + ] diff --git a/api/projects/tags/models.py b/api/projects/tags/models.py index 35c75eecfb7e..076245248043 100644 --- a/api/projects/tags/models.py +++ b/api/projects/tags/models.py @@ -13,7 +13,7 @@ class TagType(models.Choices): class Tag(AbstractBaseExportableModel): label = models.CharField(max_length=100) color = models.CharField( - max_length=10, help_text="Hexadecimal value of the tag color" + max_length=10, help_text="Hexadecimal value of the tag color", default="#6837FC" ) description = models.CharField(max_length=512, blank=True, null=True) project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="tags") diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index 1c5f7d5f22c6..ecb4b967560c 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -452,6 +452,7 @@ export default { githubIssue: 'GitHub Issue', githubPR: 'Github PR', }, + defaultTagColor: '#3d4db6', isCustomFlagsmithUrl: Project.flagsmithClientAPI !== 'https://edge.api.flagsmith.com/api/v1/', modals: { diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 7412795f691f..4b98764d802c 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -23,6 +23,7 @@ import WarningMessage from 'components/WarningMessage' import Constants from 'common/constants' import Format from './format' import { defaultFlags } from 'common/stores/default-flags' +import Color from 'color' const semver = require('semver') @@ -110,11 +111,23 @@ const Utils = Object.assign({}, require('./base/_utils'), { return typeof value === 'number' }, + colour( + c: string, + fallback = Constants.defaultTagColor, + ): InstanceType { + let res: Color + try { + res = Color(c) + } catch (_) { + res = Color(fallback) + } + return res + }, + copyFeatureName: (featureName: string) => { navigator.clipboard.writeText(featureName) toast('Copied to clipboard') }, - displayLimitAlert(type: string, percentage: number | undefined) { const envOrProject = type === 'segment overrides' ? 'environment' : 'project' diff --git a/frontend/web/components/FeatureAction.tsx b/frontend/web/components/FeatureAction.tsx index 57c69b2091ef..377e78aba8b8 100644 --- a/frontend/web/components/FeatureAction.tsx +++ b/frontend/web/components/FeatureAction.tsx @@ -176,12 +176,12 @@ export const FeatureAction: FC = ({ protectedTags?.length > 1 ? 's' : '' } ${protectedTags ?.map((tag) => { - const tagColor = getTagColor(tag) + const tagColor = Utils.colour(getTagColor(tag)) return ` + )};color:${tagColor.darken(0.1)};'> ${tag.label} ` }) diff --git a/frontend/web/components/ToggleChip.js b/frontend/web/components/ToggleChip.js index b59b28706ef9..96a81b75ce7c 100644 --- a/frontend/web/components/ToggleChip.js +++ b/frontend/web/components/ToggleChip.js @@ -1,19 +1,20 @@ import React from 'react' import cx from 'classnames' -import color from 'color' import Icon from './Icon' +import Utils from 'common/utils/utils' export default function (props) { + const colour = Utils.colour(props.color) return ( = ({ selected, tag, }) => { - const tagColor = getTagColor(tag, selected) + const tagColor = Utils.colour(getTagColor(tag, selected)) if (isDot) { return (
) @@ -72,9 +71,9 @@ const Tag: FC = ({ } }} style={{ - backgroundColor: `${color(tagColor).fade(0.92)}`, - border: `1px solid ${color(tagColor).fade(0.76)}`, - color: `${color(tagColor).darken(0.1)}`, + backgroundColor: `${tagColor.fade(0.92)}`, + border: `1px solid ${tagColor.fade(0.76)}`, + color: `${tagColor.darken(0.1)}`, }} className={cx('chip', className)} > diff --git a/frontend/web/components/tags/TagContent.tsx b/frontend/web/components/tags/TagContent.tsx index e2d0b2bddee7..de2d3d775d0e 100644 --- a/frontend/web/components/tags/TagContent.tsx +++ b/frontend/web/components/tags/TagContent.tsx @@ -1,6 +1,5 @@ import React, { FC } from 'react' import { Tag as TTag } from 'common/types/responses' -import color from 'color' import Format from 'common/utils/format' import { IonIcon } from '@ionic/react' import { alarmOutline, lockClosed } from 'ionicons/icons' @@ -10,6 +9,7 @@ import OrganisationStore from 'common/stores/organisation-store' import Utils from 'common/utils/utils' import classNames from 'classnames' import Icon from 'components/Icon' +import Color from 'color' type TagContent = { tag: Partial } @@ -26,15 +26,10 @@ const renderIcon = ( tagLabel: string, isPermanent: boolean, ) => { + const darkened = tagColor.darken(0.1).string() switch (tagType) { case 'STALE': - return ( - - ) + return case 'GITHUB': switch (tagLabel) { case 'PR Open': @@ -53,13 +48,7 @@ const renderIcon = ( return } default: - return isPermanent ? ( - - ) : null + return isPermanent ? : null } } @@ -90,16 +79,14 @@ const getTooltip = (tag: TTag | undefined) => { tooltip = 'Features marked with this tag are not monitored for staleness and have deletion protection.' } - const tagColor = getTagColor(tag, false) + const tagColor = Utils.colour(getTagColor(tag, false)) if (isTruncated) { return `
= ({ tag }) => { })} > {tagLabel} - {renderIcon(tag.type!, tag.color!, tag.label!, tag.is_permanent)} + {renderIcon(tag.type!, Utils.colour(tag.color), tag.label!)} } > From 9ad8da6a1b8a034d4436e8579ecadbac1327f9ce Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Wed, 20 Nov 2024 10:59:27 +0000 Subject: [PATCH 16/77] fix: Allow teardown to use FLAGSMITH_API_URL (#4849) --- frontend/e2e/init.cafe.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/e2e/init.cafe.js b/frontend/e2e/init.cafe.js index 9378aa6413b6..d979892d58ea 100644 --- a/frontend/e2e/init.cafe.js +++ b/frontend/e2e/init.cafe.js @@ -15,7 +15,7 @@ import versioningTests from './tests/versioning-tests'; require('dotenv').config() const url = `http://localhost:${process.env.PORT || 8080}/` -const e2eTestApi = `${Project.api}e2etests/teardown/` +const e2eTestApi = `${process.env.FLAGSMITH_API_URL || Project.api}e2etests/teardown/` const logger = getLogger() console.log( From c6e7b15c2717df1fd57001906a12fa350c853368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20L=C3=B3pez=20Dato?= Date: Wed, 20 Nov 2024 08:00:29 -0300 Subject: [PATCH 17/77] docs: Add iOS to supported realtime SDKs (#4845) --- docs/docs/advanced-use/real-time-flags.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/advanced-use/real-time-flags.md b/docs/docs/advanced-use/real-time-flags.md index 5cc093f44c62..ac00edef2323 100644 --- a/docs/docs/advanced-use/real-time-flags.md +++ b/docs/docs/advanced-use/real-time-flags.md @@ -81,6 +81,7 @@ The following SDK clients support subscribing to real-time flag updates: - JavaScript - Android +- iOS - Flutter - Python From c10063f261996cd7486153449304e1a262c947ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:03:26 +0000 Subject: [PATCH 18/77] chore(deps): bump cross-spawn and cross-env in /frontend (#4842) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 486 ++++--------------------------------- frontend/package.json | 2 +- 2 files changed, 42 insertions(+), 446 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 391899ddc757..59ef46604695 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,7 +42,7 @@ "colors": "1.4.0", "copy-webpack-plugin": "^9.1.0", "cors": "^2.8.3", - "cross-env": "5.2.0", + "cross-env": "7.0.3", "css-loader": "6.8.1", "data-relay": "^0.0.13", "dompurify": "^3.1.3", @@ -7146,19 +7146,20 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" }, "node_modules/cross-env": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz", - "integrity": "sha512-jtdNFfFW1hB7sMhr/H6rW1Z45LFqyI431m3qU6bFXcQ3Eh7LtBuG3h74o7ohHZ3crrRkkqHlo4jYHFPcjroANg==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", "dependencies": { - "cross-spawn": "^6.0.5", - "is-windows": "^1.0.0" + "cross-spawn": "^7.0.1" }, "bin": { - "cross-env": "dist/bin/cross-env.js", - "cross-env-shell": "dist/bin/cross-env-shell.js" + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" }, "engines": { - "node": ">=4.0" + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" } }, "node_modules/cross-fetch": { @@ -7189,26 +7190,30 @@ } }, "node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=4.8" + "node": ">= 8" } }, - "node_modules/cross-spawn/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, "bin": { - "semver": "bin/semver" + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" } }, "node_modules/crypto-md5": { @@ -8791,20 +8796,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/eslint/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8957,36 +8948,6 @@ "node": ">=8" } }, - "node_modules/eslint/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9012,21 +8973,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/esotope-hammerhead": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/esotope-hammerhead/-/esotope-hammerhead-0.6.8.tgz", @@ -9161,19 +9107,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/execa/node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -9185,47 +9118,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/execa/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/execa/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/execa/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, - "node_modules/execa/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/express": { "version": "4.21.1", "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", @@ -11854,14 +11746,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -13887,11 +13771,6 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, - "node_modules/nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" - }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -14238,19 +14117,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/node-sass/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/node-sass/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -14259,33 +14125,6 @@ "node": ">=8" } }, - "node_modules/node-sass/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/node-sass/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/node-sass/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, "node_modules/node-sass/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -14297,20 +14136,6 @@ "node": ">=8" } }, - "node_modules/node-sass/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/nodemon": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz", @@ -14386,14 +14211,6 @@ "node": ">=8" } }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, "node_modules/npmlog": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", @@ -14897,65 +14714,6 @@ "cross-spawn": "^7.0.3" } }, - "node_modules/password-prompt/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/password-prompt/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/password-prompt/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/password-prompt/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/password-prompt/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -14978,11 +14736,11 @@ "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==" }, "node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/path-parse": { @@ -17289,22 +17047,22 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dependencies": { - "shebang-regex": "^1.0.0" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/side-channel": { @@ -18246,19 +18004,6 @@ "node": ">= 0.10" } }, - "node_modules/testcafe-browser-tools/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/testcafe-browser-tools/node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -18423,14 +18168,6 @@ "node": ">=8" } }, - "node_modules/testcafe-browser-tools/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, "node_modules/testcafe-browser-tools/node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -18454,25 +18191,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/testcafe-browser-tools/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/testcafe-browser-tools/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, "node_modules/testcafe-browser-tools/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -18481,20 +18199,6 @@ "node": ">= 10.0.0" } }, - "node_modules/testcafe-browser-tools/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/testcafe-hammerhead": { "version": "31.7.2", "resolved": "https://registry.npmjs.org/testcafe-hammerhead/-/testcafe-hammerhead-31.7.2.tgz", @@ -18854,19 +18558,6 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, - "node_modules/testcafe/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/testcafe/node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -19025,14 +18716,6 @@ "node": ">=10" } }, - "node_modules/testcafe/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, "node_modules/testcafe/node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -19041,25 +18724,6 @@ "node": ">=0.10.0" } }, - "node_modules/testcafe/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/testcafe/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, "node_modules/testcafe/node_modules/strip-bom": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", @@ -19094,20 +18758,6 @@ "node": ">=4.2.0" } }, - "node_modules/testcafe/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -20155,60 +19805,6 @@ "node": ">= 10" } }, - "node_modules/webpack-cli/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/webpack-cli/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-cli/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-cli/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-cli/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/webpack-dev-middleware": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3a06387c1536..77168009f99b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -65,7 +65,7 @@ "colors": "1.4.0", "copy-webpack-plugin": "^9.1.0", "cors": "^2.8.3", - "cross-env": "5.2.0", + "cross-env": "7.0.3", "css-loader": "6.8.1", "data-relay": "^0.0.13", "dompurify": "^3.1.3", From 08d4ebbd107d384169cb1f810f96f63aaff3d666 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:15:27 +0000 Subject: [PATCH 19/77] chore(deps): bump cross-spawn from 7.0.3 to 7.0.6 in /docs (#4851) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 4f125391c7b7..ab3658e63ce4 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -5315,9 +5315,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -20498,9 +20498,9 @@ } }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", From 86ba762d0e93856209c4b975e7bdd1d207c8bfa8 Mon Sep 17 00:00:00 2001 From: Gagan Date: Wed, 20 Nov 2024 16:45:40 +0530 Subject: [PATCH 20/77] fix(project/serializer): limit edit to only fields that make sense (#4846) --- api/projects/serializers.py | 20 +++++++++--- api/projects/views.py | 15 +++++---- api/tests/integration/conftest.py | 6 ++-- .../unit/projects/test_unit_projects_views.py | 31 +++++++++++++++++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/api/projects/serializers.py b/api/projects/serializers.py index 14f8af3dfa84..7a934861d7c8 100644 --- a/api/projects/serializers.py +++ b/api/projects/serializers.py @@ -44,6 +44,10 @@ class Meta: "stale_flags_limit_days", "edge_v2_migration_status", ) + read_only_fields = ( + "enable_dynamo_db", + "edge_v2_migration_status", + ) def get_migration_status(self, obj: Project) -> str: if not settings.PROJECT_METADATA_TABLE_NAME_DYNAMO: @@ -65,9 +69,7 @@ def get_use_edge_identities(self, obj: Project) -> bool: ) -class ProjectUpdateOrCreateSerializer( - ReadOnlyIfNotValidPlanMixin, ProjectListSerializer -): +class ProjectCreateSerializer(ReadOnlyIfNotValidPlanMixin, ProjectListSerializer): invalid_plans_regex = r"^(free|startup.*|scale-up.*)$" field_names = ("stale_flags_limit_days", "enable_realtime_updates") @@ -84,11 +86,21 @@ def get_subscription(self) -> typing.Optional[Subscription]: # Organisation should only have a single subscription return Subscription.objects.filter(organisation_id=organisation_id).first() elif view.action in ("update", "partial_update"): + # handle instance not being set + # When request comes from yasg2 (as part of schema generation) + if not self.instance: + return None return getattr(self.instance.organisation, "subscription", None) - return None +class ProjectUpdateSerializer(ProjectCreateSerializer): + class Meta(ProjectCreateSerializer.Meta): + read_only_fields = ProjectCreateSerializer.Meta.read_only_fields + ( + "organisation", + ) + + class ProjectRetrieveSerializer(ProjectListSerializer): total_features = serializers.SerializerMethodField() total_segments = serializers.SerializerMethodField() diff --git a/api/projects/views.py b/api/projects/views.py index 42929321015e..27c720f6a138 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -44,9 +44,10 @@ CreateUpdateUserProjectPermissionSerializer, ListUserPermissionGroupProjectPermissionSerializer, ListUserProjectPermissionSerializer, + ProjectCreateSerializer, ProjectListSerializer, ProjectRetrieveSerializer, - ProjectUpdateOrCreateSerializer, + ProjectUpdateSerializer, ) @@ -75,11 +76,13 @@ class ProjectViewSet(viewsets.ModelViewSet): permission_classes = [ProjectPermissions] def get_serializer_class(self): - if self.action == "retrieve": - return ProjectRetrieveSerializer - elif self.action in ("create", "update", "partial_update"): - return ProjectUpdateOrCreateSerializer - return ProjectListSerializer + serializers = { + "retrieve": ProjectRetrieveSerializer, + "create": ProjectCreateSerializer, + "update": ProjectUpdateSerializer, + "partial_update": ProjectUpdateSerializer, + } + return serializers.get(self.action, ProjectListSerializer) pagination_class = None diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 7afc966ccdb8..0a0cf6495c04 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -56,11 +56,13 @@ def organisation_with_persist_trait_data_disabled(organisation): @pytest.fixture() -def dynamo_enabled_project(admin_client, organisation): +def dynamo_enabled_project( + admin_client: APIClient, organisation: Organisation, settings: SettingsWrapper +): + settings.EDGE_ENABLED = True project_data = { "name": "Test Project", "organisation": organisation, - "enable_dynamo_db": True, } url = reverse("api-v1:projects:project-list") response = admin_client.post(url, data=project_data) diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py index 7f949f3c6fc5..ed1d349e5a50 100644 --- a/api/tests/unit/projects/test_unit_projects_views.py +++ b/api/tests/unit/projects/test_unit_projects_views.py @@ -139,6 +139,30 @@ def test_can_update_project( assert response.json()["stale_flags_limit_days"] == new_stale_flags_limit_days +def test_can_not_update_project_organisation( + admin_client: APIClient, + project: Project, + organisation: Organisation, + organisation_two: Organisation, +) -> None: + # Given + new_name = "New project name" + + data = { + "name": new_name, + "organisation": organisation_two.id, + } + url = reverse("api-v1:projects:project-detail", args=[project.id]) + + # When + response = admin_client.put(url, data=data) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == new_name + assert response.json()["organisation"] == organisation.id + + @pytest.mark.parametrize( "client", [(lazy_fixture("admin_master_api_key_client")), (lazy_fixture("admin_client"))], @@ -733,6 +757,9 @@ def test_update_project(client, project, mocker, settings, organisation): "organisation": organisation.id, "only_allow_lower_case_feature_names": False, "feature_name_regex": feature_name_regex, + # read only fields should not be updated + "enable_dynamo_db": not project.enable_dynamo_db, + "edge_v2_migration_status": project.edge_v2_migration_status + "random-string", } # When @@ -742,6 +769,10 @@ def test_update_project(client, project, mocker, settings, organisation): assert response.status_code == status.HTTP_200_OK assert response.json()["only_allow_lower_case_feature_names"] is False assert response.json()["feature_name_regex"] == feature_name_regex + assert response.json()["enable_dynamo_db"] == project.enable_dynamo_db + assert ( + response.json()["edge_v2_migration_status"] == project.edge_v2_migration_status + ) @pytest.mark.parametrize( From 8fe4d413ff3a7a5cb7ddcd2f04a5a26ed2651316 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Thu, 21 Nov 2024 04:44:01 -0500 Subject: [PATCH 21/77] feat: Add Hubspot cookie logging (#4854) --- .../lead_tracking/hubspot/services.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api/integrations/lead_tracking/hubspot/services.py b/api/integrations/lead_tracking/hubspot/services.py index 7cc3f7cebcb8..285fbe5a74d6 100644 --- a/api/integrations/lead_tracking/hubspot/services.py +++ b/api/integrations/lead_tracking/hubspot/services.py @@ -1,16 +1,31 @@ +import logging + from rest_framework.request import Request from integrations.lead_tracking.hubspot.constants import HUBSPOT_COOKIE_NAME from users.models import HubspotTracker +logger = logging.getLogger(__name__) + def register_hubspot_tracker(request: Request) -> None: hubspot_cookie = request.COOKIES.get(HUBSPOT_COOKIE_NAME) + # TODO: Remove this temporary debugging logger statement. + logger.info(f"Request cookies for user {request.user.email}: {request.COOKIES}") + if hubspot_cookie: + logger.info( + f"Creating HubspotTracker instance for user {request.user.email} with cookie {hubspot_cookie}" + ) + HubspotTracker.objects.update_or_create( user=request.user, defaults={ "hubspot_cookie": hubspot_cookie, }, ) + else: + logger.info( + f"Could not create HubspotTracker instance for user {request.user.email} since no cookie" + ) From 793a110b031e89589e8e57734aa7fee8818c0812 Mon Sep 17 00:00:00 2001 From: Gagan Date: Thu, 21 Nov 2024 15:36:43 +0530 Subject: [PATCH 22/77] fix: revert https://github.com/Flagsmith/flagsmith/pull/4817 (#4850) --- .../unit/users/test_unit_users_migrations.py | 53 ------------------- .../0039_alter_ffadminuser_first_name.py | 51 ++---------------- .../0040_alter_ffadminuser_first_name_fix.py | 42 --------------- api/users/models.py | 8 +-- 4 files changed, 4 insertions(+), 150 deletions(-) delete mode 100644 api/tests/unit/users/test_unit_users_migrations.py delete mode 100644 api/users/migrations/0040_alter_ffadminuser_first_name_fix.py diff --git a/api/tests/unit/users/test_unit_users_migrations.py b/api/tests/unit/users/test_unit_users_migrations.py deleted file mode 100644 index 4f55dbc567c8..000000000000 --- a/api/tests/unit/users/test_unit_users_migrations.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest -from django.conf import settings -from django_test_migrations.migrator import Migrator - -pytestmark = pytest.mark.skipif( - settings.SKIP_MIGRATION_TESTS is True, - reason="Skip migration tests to speed up tests where necessary", -) - - -def test_0039_ffadminuser_first_name_v2__values_expected(migrator: Migrator) -> None: - # Given - old_state = migrator.apply_initial_migration( - ("users", "0038_create_hubspot_tracker") - ) - OldFFAdminUser = old_state.apps.get_model("users", "FFAdminUser") - - user = OldFFAdminUser.objects.create(first_name="Testfirstname") - - # When - new_state = migrator.apply_tested_migration( - ("users", "0039_alter_ffadminuser_first_name") - ) - NewFFAdminUser = new_state.apps.get_model("users", "FFAdminUser") - - # Then - assert NewFFAdminUser.objects.get(id=user.id).first_name == user.first_name - - -def test_0039_ffadminuser_first_name_v2__reverse__values_expected( - migrator: Migrator, -) -> None: - # Given - old_state = migrator.apply_initial_migration( - ("users", "0039_alter_ffadminuser_first_name") - ) - NewFFAdminUser = old_state.apps.get_model("users", "FFAdminUser") - - user = NewFFAdminUser.objects.create( - first_name="TestfirstnameTestfirstnameTestfirstnameTestfirstname" - ) - - # When - new_state = migrator.apply_tested_migration( - ("users", "0038_create_hubspot_tracker") - ) - OldFFAdminUser = new_state.apps.get_model("users", "FFAdminUser") - - # Then - assert ( - OldFFAdminUser.objects.get(id=user.id).first_name - == "TestfirstnameTestfirstnameTest" - ) diff --git a/api/users/migrations/0039_alter_ffadminuser_first_name.py b/api/users/migrations/0039_alter_ffadminuser_first_name.py index 0731faf64c2e..75c733eecc65 100644 --- a/api/users/migrations/0039_alter_ffadminuser_first_name.py +++ b/api/users/migrations/0039_alter_ffadminuser_first_name.py @@ -1,21 +1,5 @@ # Generated by Django 4.2.16 on 2024-11-04 17:09 -from django.apps.registry import Apps from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor -from django.db.models import F -from django.db.models.functions import Left - - -def populate_new_first_name_field(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - FFAdminUser = apps.get_model("users", "FFAdminUser") - - FFAdminUser.objects.update(first_name_v2=F("first_name")) - - -def populate_old_first_name_field(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - FFAdminUser = apps.get_model("users", "FFAdminUser") - - FFAdminUser.objects.update(first_name=Left("first_name_v2", 30)) class Migration(migrations.Migration): @@ -25,38 +9,9 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( + migrations.AlterField( model_name="ffadminuser", - name="first_name_v2", - field=models.CharField(max_length=150, default=""), + name="first_name", + field=models.CharField(max_length=150, verbose_name="first name"), ), - migrations.RunPython(populate_new_first_name_field, reverse_code=populate_old_first_name_field), - migrations.SeparateDatabaseAndState( - state_operations=[ - migrations.RenameField( - model_name="ffadminuser", - old_name="first_name", - new_name="_first_name_old", - ), - migrations.RenameField( - model_name="ffadminuser", - old_name="first_name_v2", - new_name="first_name", - ), - migrations.AlterField( - model_name="ffadminuser", - name="_first_name_old", - field=models.CharField( - db_column="first_name", max_length=30, verbose_name="first name" - ), - ), - migrations.AlterField( - model_name="ffadminuser", - name="first_name", - field=models.CharField( - db_column="first_name_v2", max_length=150, verbose_name="first name" - ), - ), - ] - ) ] diff --git a/api/users/migrations/0040_alter_ffadminuser_first_name_fix.py b/api/users/migrations/0040_alter_ffadminuser_first_name_fix.py deleted file mode 100644 index 2f186185185e..000000000000 --- a/api/users/migrations/0040_alter_ffadminuser_first_name_fix.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -This migration exists because 0039 was retrospectively fixed to prevent locking issues -on the users table. This migration ensures that: - - 1. in environments where 0039 was run before the fix was added, the new column is - correctly added to the database to match what the ORM expects and the data - is correctly migrted to the new column. - 2. in environments where 0039 was run after the fix was added, nothing happens. - -Note that we only need to care about Postgres databases here because we did not release -the bad migration for 0039 in a format that can be consumed by oracle / mysql. -""" - -# Generated by Django 4.2.16 on 2024-11-12 18:05 -import logging - -from django.db import migrations - -from core.migration_helpers import PostgresOnlyRunSQL - -forwards_sql = """ -DO -$do$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users_ffadminuser' and column_name='first_name_v2') THEN - ALTER TABLE "users_ffadminuser" ADD COLUMN "first_name_v2" VARCHAR(150) NOT NULL DEFAULT ''; - UPDATE "users_ffadminuser" SET "first_name_v2" = "first_name"; - END IF; -END -$do$ -""" - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0039_alter_ffadminuser_first_name"), - ] - - operations = [ - PostgresOnlyRunSQL(forwards_sql, reverse_sql=""), - ] diff --git a/api/users/models.py b/api/users/models.py index 32d7a567aa18..2030ad05c3f7 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -99,9 +99,7 @@ class FFAdminUser(LifecycleModel, AbstractUser): email = models.EmailField(unique=True, null=False) objects = UserManager() username = models.CharField(unique=True, max_length=150, null=True, blank=True) - first_name = models.CharField( - "first name", max_length=150, db_column="first_name_v2" - ) + first_name = models.CharField("first name", max_length=150) last_name = models.CharField("last name", max_length=150) google_user_id = models.CharField(max_length=50, null=True, blank=True) github_user_id = models.CharField(max_length=50, null=True, blank=True) @@ -113,10 +111,6 @@ class FFAdminUser(LifecycleModel, AbstractUser): choices=SignUpType.choices, max_length=100, blank=True, null=True ) - _first_name_old = models.CharField( - "first name", max_length=30, db_column="first_name" - ) - uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) USERNAME_FIELD = "email" From 294b3526fc01c9c7bbc18c26463d5a07a4aaceb8 Mon Sep 17 00:00:00 2001 From: Flagsmith Bot <65724737+flagsmithdev@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:37:30 +0000 Subject: [PATCH 23/77] chore(main): release 2.154.0 (#4830) --- .release-please-manifest.json | 2 +- CHANGELOG.md | 22 ++++++++++++++++++++++ version.txt | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 05cee4cf32d9..1670b75b9e3a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.153.0" + ".": "2.154.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 492afe808994..0b999b0a7f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [2.154.0](https://github.com/Flagsmith/flagsmith/compare/v2.153.0...v2.154.0) (2024-11-21) + + +### Features + +* Add Hubspot cookie logging ([#4854](https://github.com/Flagsmith/flagsmith/issues/4854)) ([8fe4d41](https://github.com/Flagsmith/flagsmith/commit/8fe4d413ff3a7a5cb7ddcd2f04a5a26ed2651316)) + + +### Bug Fixes + +* Allow teardown to use FLAGSMITH_API_URL ([#4849](https://github.com/Flagsmith/flagsmith/issues/4849)) ([9ad8da6](https://github.com/Flagsmith/flagsmith/commit/9ad8da6a1b8a034d4436e8579ecadbac1327f9ce)) +* Custom Gunicorn logger not sending StatsD stats ([#4819](https://github.com/Flagsmith/flagsmith/issues/4819)) ([9bbfdf0](https://github.com/Flagsmith/flagsmith/commit/9bbfdf0d2bc4cb95fa0d029144c4bc61fb23f0bc)) +* flagsmith stale flags check ([#4831](https://github.com/Flagsmith/flagsmith/issues/4831)) ([ea6a169](https://github.com/Flagsmith/flagsmith/commit/ea6a169d9f84172850b2b01e5fd7d70b8852c9bf)) +* Google OAuth broken in unified docker image ([#4839](https://github.com/Flagsmith/flagsmith/issues/4839)) ([051cc6f](https://github.com/Flagsmith/flagsmith/commit/051cc6fe0db5a311998bece472982e19926a2bc5)) +* Handle environment admin not being able to check VIEW_PROJECT permissions ([#4827](https://github.com/Flagsmith/flagsmith/issues/4827)) ([23ab3c1](https://github.com/Flagsmith/flagsmith/commit/23ab3c1a8a26284e87503b389c597b58866ee27b)) +* Handle invalid colour codes on tags, allow default colours ([#4822](https://github.com/Flagsmith/flagsmith/issues/4822)) ([a33633f](https://github.com/Flagsmith/flagsmith/commit/a33633f61f9eaf8efd4322af48a862dbebcbe682)) +* prevent lock when adding FFAdmin.uuid column ([#4832](https://github.com/Flagsmith/flagsmith/issues/4832)) ([4a310b0](https://github.com/Flagsmith/flagsmith/commit/4a310b0c314de4499425fbb5584f94a1f77c640c)) +* **project/realtime:** only allow enterprise to enable realtime ([#4843](https://github.com/Flagsmith/flagsmith/issues/4843)) ([9b21af7](https://github.com/Flagsmith/flagsmith/commit/9b21af7652ae2cb5758361c2809a345cc79209d3)) +* **project/serializer:** limit edit to only fields that make sense ([#4846](https://github.com/Flagsmith/flagsmith/issues/4846)) ([86ba762](https://github.com/Flagsmith/flagsmith/commit/86ba762d0e93856209c4b975e7bdd1d207c8bfa8)) +* replace alter field with adding a new field ([#4817](https://github.com/Flagsmith/flagsmith/issues/4817)) ([0d1c64a](https://github.com/Flagsmith/flagsmith/commit/0d1c64a3db04cd7d56f39b5b3fa0fee4340504eb)) +* revert https://github.com/Flagsmith/flagsmith/pull/4817 ([#4850](https://github.com/Flagsmith/flagsmith/issues/4850)) ([793a110](https://github.com/Flagsmith/flagsmith/commit/793a110b031e89589e8e57734aa7fee8818c0812)) + ## [2.153.0](https://github.com/Flagsmith/flagsmith/compare/v2.152.0...v2.153.0) (2024-11-12) diff --git a/version.txt b/version.txt index 77f9f1571dce..ebe9185df092 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.153.0 +2.154.0 From fd28c82d78ec9253feea15e52562abcd59671c79 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 21 Nov 2024 16:22:43 +0000 Subject: [PATCH 24/77] deps: bump aws-actions/amazon-ecs-deploy-task-definition action (#4855) --- .github/actions/api-deploy-ecs/action.yml | 2 +- .github/actions/task-processor-deploy-ecs/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/api-deploy-ecs/action.yml b/.github/actions/api-deploy-ecs/action.yml index a9b2dc984bfb..ef6d523ab2db 100644 --- a/.github/actions/api-deploy-ecs/action.yml +++ b/.github/actions/api-deploy-ecs/action.yml @@ -91,7 +91,7 @@ runs: shell: bash - name: Deploy Amazon ECS web task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + uses: aws-actions/amazon-ecs-deploy-task-definition@v2 with: cluster: ${{ inputs.aws_ecs_cluster_name }} service: ${{ inputs.aws_ecs_service_name }} diff --git a/.github/actions/task-processor-deploy-ecs/action.yml b/.github/actions/task-processor-deploy-ecs/action.yml index d622636f1ba9..a3665fc89eac 100644 --- a/.github/actions/task-processor-deploy-ecs/action.yml +++ b/.github/actions/task-processor-deploy-ecs/action.yml @@ -41,7 +41,7 @@ runs: image: ${{ inputs.api_ecr_image_url }} - name: Deploy Amazon ECS Task Processor task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + uses: aws-actions/amazon-ecs-deploy-task-definition@v2 with: cluster: ${{ inputs.aws_ecs_cluster_name }} service: ${{ inputs.aws_ecs_service_name }} From 42ef04b4ab2b69ced70a029a9d8e789b824c535a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20L=C3=B3pez=20Dato?= Date: Thu, 21 Nov 2024 13:40:32 -0300 Subject: [PATCH 25/77] docs: Track cocoapods/github versions separately (#4857) --- docs/docs/clients/client-side/ios.mdx | 29 ++++++++++++++++-------- docs/plugins/flagsmith-versions/index.js | 18 ++++++++++----- docs/src/components/SdkVersions.js | 3 ++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/docs/docs/clients/client-side/ios.mdx b/docs/docs/clients/client-side/ios.mdx index 17e65492e778..499a0b0d2516 100644 --- a/docs/docs/clients/client-side/ios.mdx +++ b/docs/docs/clients/client-side/ios.mdx @@ -6,37 +6,48 @@ slug: /clients/ios --- import CodeBlock from '@theme/CodeBlock'; -import { IOSVersion } from '@site/src/components/SdkVersions.js'; +import { CocoapodsVersion, SwiftPMVersion } from '@site/src/components/SdkVersions.js'; This library can be used with iOS and Mac applications. The source code for the client is available on [GitHub](https://github.com/flagsmith/flagsmith-ios-client). ## Installation -### CocoaPods +
+CocoaPods -[CocoaPods](https://cocoapods.org) is a dependency manager for Cocoa projects. For usage and installation instructions, -visit their website. To integrate Flagsmith into your Xcode project using CocoaPods, specify it in your `Podfile`: +Add the Flagsmith SDK as a dependency to your Podfile: {`pod 'FlagsmithClient', '~> `} - + {`'`} -### Swift Package Manager +
-The Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the swift -compiler. You can use it to install Flagsmith by adding the description to your `Package.swift` file: +
+ +Swift Package Manager + +Add the Flagsmith SDK as a dependency to your Package.swift file: {`dependencies: [ .package(url: "https://github.com/Flagsmith/flagsmith-ios-client.git", from: "`} - + {`"), ]`} +Alternatively, you can add the Flagsmith SDK as a dependency from its repository URL using Xcode: + +``` +https://github.com/Flagsmith/flagsmith-ios-client.git +``` + +
+ ## Basic Usage The SDK is initialised against a single environment within a project on [https://flagsmith.com](https://flagsmith.com), diff --git a/docs/plugins/flagsmith-versions/index.js b/docs/plugins/flagsmith-versions/index.js index 03608ea3f0d7..b68835d55499 100644 --- a/docs/plugins/flagsmith-versions/index.js +++ b/docs/plugins/flagsmith-versions/index.js @@ -26,12 +26,16 @@ const fetchJavaVersions = async () => artifactId: 'flagsmith-java-client', }); -const fetchAndroidVersions = async () => { - const data = await fetchJSON('https://api.github.com/repos/flagsmith/flagsmith-kotlin-android-client/releases'); +const fetchGitHubReleases = async (repo) => { + const data = await fetchJSON(`https://api.github.com/repos/${repo}/releases`); return data.map((release) => (release.tag_name.startsWith('v') ? release.tag_name.slice(1) : release.tag_name)); }; -const fetchIOSVersions = async () => { +const fetchAndroidVersions = async () => fetchGitHubReleases('flagsmith/flagsmith-kotlin-android-client'); + +const fetchSwiftPMVersions = async () => fetchGitHubReleases('Flagsmith/flagsmith-ios-client'); + +const fetchCocoapodsVersions = async () => { // retrieved from https://cocoapods.org/pods/FlagsmithClient const data = await fetchJSON('https://api.github.com/repos/CocoaPods/Specs/contents/Specs/2/8/0/FlagsmithClient'); return data.map((entry) => entry.name); @@ -65,12 +69,13 @@ export default async function fetchFlagsmithVersions(context, options) { return { name: 'flagsmith-versions', async loadContent() { - const [js, java, android, ios, dotnet, rust, elixir] = await Promise.all( + const [js, java, android, swiftpm, cocoapods, dotnet, rust, elixir] = await Promise.all( [ fetchNpmVersions('flagsmith'), fetchJavaVersions(), fetchAndroidVersions(), - fetchIOSVersions(), + fetchSwiftPMVersions(), + fetchCocoapodsVersions(), fetchDotnetVersions(), fetchRustVersions(), fetchElixirVersions(), @@ -80,7 +85,8 @@ export default async function fetchFlagsmithVersions(context, options) { js, java, android, - ios, + swiftpm, + cocoapods, dotnet, rust, elixir, diff --git a/docs/src/components/SdkVersions.js b/docs/src/components/SdkVersions.js index 41c0daccd305..47c96bf871d7 100644 --- a/docs/src/components/SdkVersions.js +++ b/docs/src/components/SdkVersions.js @@ -21,7 +21,8 @@ const Version = ({ sdk, spec = '*', options = {} }) => { export const JavaVersion = ({ spec = '~7' }) => Version({ sdk: 'java', spec }); export const AndroidVersion = ({ spec = '~1' }) => Version({ sdk: 'android', spec }); -export const IOSVersion = ({ spec = '~3' }) => Version({ sdk: 'ios', spec }); +export const CocoapodsVersion = ({ spec = '~3' }) => Version({ sdk: 'cocoapods', spec }); +export const SwiftPMVersion = ({ spec = '~3' }) => Version({ sdk: 'swiftpm', spec }); export const DotnetVersion = ({ spec = '~5' }) => Version({ sdk: 'dotnet', spec }); export const ElixirVersion = ({ spec = '~2' }) => Version({ sdk: 'elixir', spec }); export const RustVersion = ({ spec = '~2' }) => Version({ sdk: 'rust', spec }); From 355f4e2827dc0a0cba169a320c7a0db8b522089e Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Thu, 21 Nov 2024 14:35:15 -0500 Subject: [PATCH 26/77] feat: Create change requests for segments (#4265) Co-authored-by: Kim Gustyr Co-authored-by: Matthew Elwell --- api/app/urls.py | 2 +- api/audit/permissions.py | 2 +- api/conftest.py | 21 +- api/e2etests/e2e_seed_data.py | 410 +++++++++--------- api/edge_api/identities/permissions.py | 8 +- api/edge_api/identities/views.py | 5 +- api/environments/identities/traits/views.py | 5 +- api/environments/identities/views.py | 5 +- api/environments/managers.py | 6 +- .../migrations/0010_auto_20200219_2343.py | 22 +- api/environments/models.py | 2 +- api/environments/permissions/constants.py | 10 - ...002_add_update_feature_state_permission.py | 4 +- .../0003_add_manage_identities_permission.py | 2 +- .../0004_add_change_request_permissions.py | 11 +- .../0005_add_view_identity_permissions.py | 5 +- ...add_manage_segment_overrides_permission.py | 11 +- api/environments/permissions/permissions.py | 4 +- api/environments/serializers.py | 5 +- api/environments/views.py | 2 +- api/features/feature_segments/permissions.py | 2 +- api/features/feature_segments/serializers.py | 2 +- api/features/feature_segments/views.py | 2 +- api/features/import_export/permissions.py | 2 +- api/features/multivariate/views.py | 7 +- api/features/permissions.py | 26 +- api/features/serializers.py | 5 +- api/features/versioning/permissions.py | 14 +- api/features/versioning/views.py | 4 +- api/features/views.py | 2 +- .../0011_add_project_to_change_requests.py | 75 ++++ api/features/workflows/core/models.py | 63 ++- api/import_export/export.py | 4 +- api/integrations/common/views.py | 5 +- api/integrations/dynatrace/dynatrace.py | 2 +- api/integrations/launch_darkly/services.py | 6 +- api/integrations/launch_darkly/views.py | 7 +- api/metadata/serializers.py | 70 --- api/permissions/migrations/0001_initial.py | 66 ++- .../0008_add_view_audit_log_permission.py | 2 +- .../0009_move_view_audit_log_permission.py | 13 +- .../0010_add_manage_tags_permission.py | 10 +- api/poetry.lock | 20 +- .../migrations/0003_auto_20200216_2050.py | 18 +- ..._add_change_request_project_permissions.py | 48 ++ ...ange_request_approval_limit_to_projects.py | 18 + api/projects/models.py | 29 +- api/projects/permissions.py | 25 +- api/projects/serializers.py | 3 +- api/projects/services.py | 35 ++ api/projects/tags/permissions.py | 2 +- api/projects/tags/views.py | 3 +- api/projects/urls.py | 13 + api/projects/views.py | 8 +- api/pyproject.toml | 4 +- .../sales_dashboard/organisation.html | 2 +- api/segments/managers.py | 4 + .../0026_add_change_request_to_segments.py | 38 ++ api/segments/models.py | 38 +- api/segments/permissions.py | 2 +- api/segments/serializers.py | 244 +---------- api/segments/views.py | 9 +- api/tests/unit/audit/conftest.py | 2 +- .../test_edge_api_identities_views.py | 10 +- .../edge_api/identities/test_permissions.py | 3 +- api/tests/unit/environments/helpers.py | 2 +- ...st_unit_identities_feature_states_views.py | 8 +- .../identities/test_unit_identities_views.py | 5 +- .../identities/traits/test_traits_views.py | 12 +- .../test_unit_environments_permissions.py | 3 +- ...nit_environments_permissions_migrations.py | 5 +- .../test_unit_environments_views.py | 2 +- ..._unit_environments_feature_states_views.py | 8 +- .../test_unit_environments_views.py | 10 +- ...unit_environments_views_sdk_environment.py | 23 +- .../test_unit_feature_segments_views.py | 12 +- .../test_unit_features_import_export_views.py | 2 +- .../test_unit_multivariate_views.py | 7 +- ...t_unit_feature_external_resources_views.py | 2 +- .../test_unit_features_permissions.py | 12 +- .../unit/features/test_unit_features_views.py | 12 +- .../versioning/test_unit_versioning_views.py | 10 +- .../core/test_unit_workflows_migrations.py | 40 ++ .../core/test_unit_workflows_models.py | 110 ++++- api/tests/unit/metadata/test_serializers.py | 2 +- ...est_get_permitted_environments_for_user.py | 6 +- .../test_get_permitted_projects_for_user.py | 10 +- .../test_unit_permissions_calculator.py | 6 +- .../test_unit_projects_tags_permissions.py | 3 +- .../tags/test_unit_projects_tags_views.py | 2 +- api/tests/unit/projects/test_migrations.py | 3 +- .../projects/test_unit_projects_models.py | 33 +- .../test_unit_projects_permissions.py | 7 +- .../unit/projects/test_unit_projects_views.py | 17 +- .../segments/test_unit_segments_models.py | 8 +- .../test_unit_segments_permissions.py | 3 +- .../unit/segments/test_unit_segments_views.py | 18 +- .../unit/users/test_unit_users_models.py | 2 +- api/util/mappers/engine.py | 9 +- 99 files changed, 1067 insertions(+), 856 deletions(-) delete mode 100644 api/environments/permissions/constants.py create mode 100644 api/features/workflows/core/migrations/0011_add_project_to_change_requests.py create mode 100644 api/projects/migrations/0025_add_change_request_project_permissions.py create mode 100644 api/projects/migrations/0026_add_change_request_approval_limit_to_projects.py create mode 100644 api/projects/services.py create mode 100644 api/segments/migrations/0026_add_change_request_to_segments.py create mode 100644 api/tests/unit/features/workflows/core/test_unit_workflows_migrations.py diff --git a/api/app/urls.py b/api/app/urls.py index 65caa58df8ba..e7338cd5988d 100644 --- a/api/app/urls.py +++ b/api/app/urls.py @@ -52,7 +52,7 @@ if settings.SAML_INSTALLED: urlpatterns.append(path("api/v1/auth/saml/", include("saml.urls"))) -if settings.WORKFLOWS_LOGIC_INSTALLED: +if settings.WORKFLOWS_LOGIC_INSTALLED: # pragma: no cover workflow_views = importlib.import_module("workflows_logic.views") urlpatterns.extend( [ diff --git a/api/audit/permissions.py b/api/audit/permissions.py index 6695ac696fec..b8b0ae3b48e7 100644 --- a/api/audit/permissions.py +++ b/api/audit/permissions.py @@ -1,10 +1,10 @@ +from common.projects.permissions import VIEW_AUDIT_LOG from django.views import View from rest_framework.permissions import BasePermission from rest_framework.request import Request from organisations.models import Organisation from projects.models import Project -from projects.permissions import VIEW_AUDIT_LOG class OrganisationAuditLogPermissions(BasePermission): diff --git a/api/conftest.py b/api/conftest.py index d5249fad1554..6f3600650e4c 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -5,6 +5,12 @@ import boto3 import pytest +from common.environments.permissions import ( + MANAGE_IDENTITIES, + VIEW_ENVIRONMENT, + VIEW_IDENTITIES, +) +from common.projects.permissions import VIEW_PROJECT from django.contrib.contenttypes.models import ContentType from django.core.cache import caches from django.db.backends.base.creation import TEST_DATABASE_PREFIX @@ -27,11 +33,6 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment, EnvironmentAPIKey -from environments.permissions.constants import ( - MANAGE_IDENTITIES, - VIEW_ENVIRONMENT, - VIEW_IDENTITIES, -) from environments.permissions.models import ( UserEnvironmentPermission, UserPermissionGroupEnvironmentPermission, @@ -72,7 +73,6 @@ UserPermissionGroupProjectPermission, UserProjectPermission, ) -from projects.permissions import VIEW_PROJECT from projects.tags.models import Tag from segments.models import Condition, Segment, SegmentRule from tests.test_helpers import fix_issue_3869 @@ -547,12 +547,19 @@ def feature(project: Project, environment: Environment) -> Feature: @pytest.fixture() -def change_request(environment, admin_user): +def change_request(environment: Environment, admin_user: FFAdminUser) -> ChangeRequest: return ChangeRequest.objects.create( environment=environment, title="Test CR", user_id=admin_user.id ) +@pytest.fixture() +def project_change_request(project: Project, admin_user: FFAdminUser) -> ChangeRequest: + return ChangeRequest.objects.create( + project=project, title="Test Project CR", user_id=admin_user.id + ) + + @pytest.fixture() def feature_state(feature: Feature, environment: Environment) -> FeatureState: return FeatureState.objects.get(environment=environment, feature=feature) diff --git a/api/e2etests/e2e_seed_data.py b/api/e2etests/e2e_seed_data.py index eba97bd65cb8..37b7605e2a17 100644 --- a/api/e2etests/e2e_seed_data.py +++ b/api/e2etests/e2e_seed_data.py @@ -1,205 +1,205 @@ -from django.conf import settings -from flag_engine.identities.models import IdentityModel as EngineIdentity - -from edge_api.identities.models import EdgeIdentity -from environments.identities.models import Identity -from environments.models import Environment -from environments.permissions.constants import ( - UPDATE_FEATURE_STATE, - VIEW_ENVIRONMENT, - VIEW_IDENTITIES, -) -from environments.permissions.models import UserEnvironmentPermission -from organisations.models import Organisation, OrganisationRole, Subscription -from organisations.permissions.models import UserOrganisationPermission -from organisations.permissions.permissions import ( - CREATE_PROJECT, - MANAGE_USER_GROUPS, -) -from organisations.subscriptions.constants import SCALE_UP -from projects.models import Project, UserProjectPermission -from projects.permissions import ( - CREATE_ENVIRONMENT, - CREATE_FEATURE, - VIEW_AUDIT_LOG, - VIEW_PROJECT, -) -from users.models import FFAdminUser, UserPermissionGroup - -# Password used by all the test users -PASSWORD = "Str0ngp4ssw0rd!" - -PROJECT_PERMISSION_PROJECT = "My Test Project 5 Project Permission" -ENV_PERMISSION_PROJECT = "My Test Project 6 Env Permission" - - -def delete_user_and_its_organisations(user_email: str) -> None: - user: FFAdminUser | None = FFAdminUser.objects.filter(email=user_email).first() - - if user: - user.organisations.all().delete() - user.delete() - - -def teardown() -> None: - # delete users and their orgs created for e2e test by front end - delete_user_and_its_organisations(user_email=settings.E2E_SIGNUP_USER) - delete_user_and_its_organisations(user_email=settings.E2E_USER) - delete_user_and_its_organisations(user_email=settings.E2E_CHANGE_EMAIL_USER) - delete_user_and_its_organisations( - user_email=settings.E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS - ) - delete_user_and_its_organisations( - user_email=settings.E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS - ) - delete_user_and_its_organisations( - user_email=settings.E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS - ) - delete_user_and_its_organisations( - user_email=settings.E2E_NON_ADMIN_USER_WITH_A_ROLE - ) - - -def seed_data() -> None: - # create user and organisation for e2e test by front end - organisation: Organisation = Organisation.objects.create(name="Bullet Train Ltd") - org_admin: FFAdminUser = FFAdminUser.objects.create_user( - email=settings.E2E_USER, - password=PASSWORD, - username=settings.E2E_USER, - ) - org_admin.add_organisation(organisation, OrganisationRole.ADMIN) - non_admin_user_with_org_permissions: FFAdminUser = FFAdminUser.objects.create_user( - email=settings.E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, - password=PASSWORD, - ) - non_admin_user_with_project_permissions: FFAdminUser = ( - FFAdminUser.objects.create_user( - email=settings.E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, - password=PASSWORD, - ) - ) - non_admin_user_with_env_permissions: FFAdminUser = FFAdminUser.objects.create_user( - email=settings.E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, - password=PASSWORD, - ) - non_admin_user_with_a_role: FFAdminUser = FFAdminUser.objects.create_user( - email=settings.E2E_NON_ADMIN_USER_WITH_A_ROLE, - password=PASSWORD, - ) - non_admin_user_with_org_permissions.add_organisation( - organisation, - ) - non_admin_user_with_project_permissions.add_organisation( - organisation, - ) - non_admin_user_with_env_permissions.add_organisation( - organisation, - ) - non_admin_user_with_a_role.add_organisation( - organisation, - ) - - # Add permissions to the non-admin user with org permissions - user_org_permission = UserOrganisationPermission.objects.create( - user=non_admin_user_with_org_permissions, organisation=organisation - ) - user_org_permission.add_permission(CREATE_PROJECT) - user_org_permission.add_permission(MANAGE_USER_GROUPS) - UserPermissionGroup.objects.create(name="TestGroup", organisation=organisation) - - # We add different projects and environments to give each e2e test its own isolated context. - project_test_data = [ - { - "name": "My Test Project", - "environments": [ - "Development", - "Production", - ], - }, - {"name": "My Test Project 2", "environments": ["Development"]}, - {"name": "My Test Project 3", "environments": ["Development"]}, - {"name": "My Test Project 4", "environments": ["Development"]}, - { - "name": PROJECT_PERMISSION_PROJECT, - "environments": ["Development"], - }, - {"name": ENV_PERMISSION_PROJECT, "environments": ["Development"]}, - {"name": "My Test Project 7 Role", "environments": ["Development"]}, - ] - # Upgrade organisation seats - Subscription.objects.filter(organisation__in=org_admin.organisations.all()).update( - max_seats=8, plan=SCALE_UP, subscription_id="test_subscription_id" - ) - - # Create projects and environments - projects = [] - environments = [] - for project_info in project_test_data: - project = Project.objects.create( - name=project_info["name"], organisation=organisation - ) - if project_info["name"] == PROJECT_PERMISSION_PROJECT: - # Add permissions to the non-admin user with project permissions - user_proj_permission: UserProjectPermission = ( - UserProjectPermission.objects.create( - user=non_admin_user_with_project_permissions, project=project - ) - ) - [ - user_proj_permission.add_permission(permission_key) - for permission_key in [ - VIEW_PROJECT, - CREATE_ENVIRONMENT, - CREATE_FEATURE, - VIEW_AUDIT_LOG, - ] - ] - projects.append(project) - - for env_name in project_info["environments"]: - environment = Environment.objects.create(name=env_name, project=project) - - if project_info["name"] == ENV_PERMISSION_PROJECT: - # Add permissions to the non-admin user with env permissions - user_env_permission = UserEnvironmentPermission.objects.create( - user=non_admin_user_with_env_permissions, environment=environment - ) - user_env_proj_permission: UserProjectPermission = ( - UserProjectPermission.objects.create( - user=non_admin_user_with_env_permissions, project=project - ) - ) - user_env_proj_permission.add_permission(VIEW_PROJECT) - user_env_proj_permission.add_permission(CREATE_FEATURE) - [ - user_env_permission.add_permission(permission_key) - for permission_key in [ - VIEW_ENVIRONMENT, - UPDATE_FEATURE_STATE, - VIEW_IDENTITIES, - ] - ] - environments.append(environment) - - # We're only creating identities for 6 of the 7 environments because - # they are necessary for the environments created above and to keep - # the e2e tests isolated." - identities_test_data = [ - {"identifier": settings.E2E_IDENTITY, "environment": environments[2]}, - {"identifier": settings.E2E_IDENTITY, "environment": environments[3]}, - {"identifier": settings.E2E_IDENTITY, "environment": environments[4]}, - {"identifier": settings.E2E_IDENTITY, "environment": environments[5]}, - {"identifier": settings.E2E_IDENTITY, "environment": environments[6]}, - {"identifier": settings.E2E_IDENTITY, "environment": environments[7]}, - ] - - for identity_info in identities_test_data: - if settings.IDENTITIES_TABLE_NAME_DYNAMO: - engine_identity = EngineIdentity( - identifier=identity_info["identifier"], - environment_api_key=identity_info["environment"].api_key, - ) - EdgeIdentity(engine_identity).save() - else: - Identity.objects.create(**identity_info) +from common.environments.permissions import ( + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, + VIEW_IDENTITIES, +) +from common.projects.permissions import ( + CREATE_ENVIRONMENT, + CREATE_FEATURE, + VIEW_AUDIT_LOG, + VIEW_PROJECT, +) +from django.conf import settings +from flag_engine.identities.models import IdentityModel as EngineIdentity + +from edge_api.identities.models import EdgeIdentity +from environments.identities.models import Identity +from environments.models import Environment +from environments.permissions.models import UserEnvironmentPermission +from organisations.models import Organisation, OrganisationRole, Subscription +from organisations.permissions.models import UserOrganisationPermission +from organisations.permissions.permissions import ( + CREATE_PROJECT, + MANAGE_USER_GROUPS, +) +from organisations.subscriptions.constants import SCALE_UP +from projects.models import Project, UserProjectPermission +from users.models import FFAdminUser, UserPermissionGroup + +# Password used by all the test users +PASSWORD = "Str0ngp4ssw0rd!" + +PROJECT_PERMISSION_PROJECT = "My Test Project 5 Project Permission" +ENV_PERMISSION_PROJECT = "My Test Project 6 Env Permission" + + +def delete_user_and_its_organisations(user_email: str) -> None: + user: FFAdminUser | None = FFAdminUser.objects.filter(email=user_email).first() + + if user: + user.organisations.all().delete() + user.delete() + + +def teardown() -> None: + # delete users and their orgs created for e2e test by front end + delete_user_and_its_organisations(user_email=settings.E2E_SIGNUP_USER) + delete_user_and_its_organisations(user_email=settings.E2E_USER) + delete_user_and_its_organisations(user_email=settings.E2E_CHANGE_EMAIL_USER) + delete_user_and_its_organisations( + user_email=settings.E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS + ) + delete_user_and_its_organisations( + user_email=settings.E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS + ) + delete_user_and_its_organisations( + user_email=settings.E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS + ) + delete_user_and_its_organisations( + user_email=settings.E2E_NON_ADMIN_USER_WITH_A_ROLE + ) + + +def seed_data() -> None: + # create user and organisation for e2e test by front end + organisation: Organisation = Organisation.objects.create(name="Bullet Train Ltd") + org_admin: FFAdminUser = FFAdminUser.objects.create_user( + email=settings.E2E_USER, + password=PASSWORD, + username=settings.E2E_USER, + ) + org_admin.add_organisation(organisation, OrganisationRole.ADMIN) + non_admin_user_with_org_permissions: FFAdminUser = FFAdminUser.objects.create_user( + email=settings.E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, + password=PASSWORD, + ) + non_admin_user_with_project_permissions: FFAdminUser = ( + FFAdminUser.objects.create_user( + email=settings.E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, + password=PASSWORD, + ) + ) + non_admin_user_with_env_permissions: FFAdminUser = FFAdminUser.objects.create_user( + email=settings.E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, + password=PASSWORD, + ) + non_admin_user_with_a_role: FFAdminUser = FFAdminUser.objects.create_user( + email=settings.E2E_NON_ADMIN_USER_WITH_A_ROLE, + password=PASSWORD, + ) + non_admin_user_with_org_permissions.add_organisation( + organisation, + ) + non_admin_user_with_project_permissions.add_organisation( + organisation, + ) + non_admin_user_with_env_permissions.add_organisation( + organisation, + ) + non_admin_user_with_a_role.add_organisation( + organisation, + ) + + # Add permissions to the non-admin user with org permissions + user_org_permission = UserOrganisationPermission.objects.create( + user=non_admin_user_with_org_permissions, organisation=organisation + ) + user_org_permission.add_permission(CREATE_PROJECT) + user_org_permission.add_permission(MANAGE_USER_GROUPS) + UserPermissionGroup.objects.create(name="TestGroup", organisation=organisation) + + # We add different projects and environments to give each e2e test its own isolated context. + project_test_data = [ + { + "name": "My Test Project", + "environments": [ + "Development", + "Production", + ], + }, + {"name": "My Test Project 2", "environments": ["Development"]}, + {"name": "My Test Project 3", "environments": ["Development"]}, + {"name": "My Test Project 4", "environments": ["Development"]}, + { + "name": PROJECT_PERMISSION_PROJECT, + "environments": ["Development"], + }, + {"name": ENV_PERMISSION_PROJECT, "environments": ["Development"]}, + {"name": "My Test Project 7 Role", "environments": ["Development"]}, + ] + # Upgrade organisation seats + Subscription.objects.filter(organisation__in=org_admin.organisations.all()).update( + max_seats=8, plan=SCALE_UP, subscription_id="test_subscription_id" + ) + + # Create projects and environments + projects = [] + environments = [] + for project_info in project_test_data: + project = Project.objects.create( + name=project_info["name"], organisation=organisation + ) + if project_info["name"] == PROJECT_PERMISSION_PROJECT: + # Add permissions to the non-admin user with project permissions + user_proj_permission: UserProjectPermission = ( + UserProjectPermission.objects.create( + user=non_admin_user_with_project_permissions, project=project + ) + ) + [ + user_proj_permission.add_permission(permission_key) + for permission_key in [ + VIEW_PROJECT, + CREATE_ENVIRONMENT, + CREATE_FEATURE, + VIEW_AUDIT_LOG, + ] + ] + projects.append(project) + + for env_name in project_info["environments"]: + environment = Environment.objects.create(name=env_name, project=project) + + if project_info["name"] == ENV_PERMISSION_PROJECT: + # Add permissions to the non-admin user with env permissions + user_env_permission = UserEnvironmentPermission.objects.create( + user=non_admin_user_with_env_permissions, environment=environment + ) + user_env_proj_permission: UserProjectPermission = ( + UserProjectPermission.objects.create( + user=non_admin_user_with_env_permissions, project=project + ) + ) + user_env_proj_permission.add_permission(VIEW_PROJECT) + user_env_proj_permission.add_permission(CREATE_FEATURE) + [ + user_env_permission.add_permission(permission_key) + for permission_key in [ + VIEW_ENVIRONMENT, + UPDATE_FEATURE_STATE, + VIEW_IDENTITIES, + ] + ] + environments.append(environment) + + # We're only creating identities for 6 of the 7 environments because + # they are necessary for the environments created above and to keep + # the e2e tests isolated." + identities_test_data = [ + {"identifier": settings.E2E_IDENTITY, "environment": environments[2]}, + {"identifier": settings.E2E_IDENTITY, "environment": environments[3]}, + {"identifier": settings.E2E_IDENTITY, "environment": environments[4]}, + {"identifier": settings.E2E_IDENTITY, "environment": environments[5]}, + {"identifier": settings.E2E_IDENTITY, "environment": environments[6]}, + {"identifier": settings.E2E_IDENTITY, "environment": environments[7]}, + ] + + for identity_info in identities_test_data: + if settings.IDENTITIES_TABLE_NAME_DYNAMO: + engine_identity = EngineIdentity( # pragma: no cover + identifier=identity_info["identifier"], + environment_api_key=identity_info["environment"].api_key, + ) + EdgeIdentity(engine_identity).save() # pragma: no cover + else: + Identity.objects.create(**identity_info) diff --git a/api/edge_api/identities/permissions.py b/api/edge_api/identities/permissions.py index fd67d235589c..580bdd81e14c 100644 --- a/api/edge_api/identities/permissions.py +++ b/api/edge_api/identities/permissions.py @@ -1,14 +1,14 @@ from contextlib import suppress +from common.environments.permissions import ( + UPDATE_FEATURE_STATE, + VIEW_IDENTITIES, +) from django.http import HttpRequest from django.views import View from rest_framework.permissions import BasePermission from environments.models import Environment -from environments.permissions.constants import ( - UPDATE_FEATURE_STATE, - VIEW_IDENTITIES, -) class EdgeIdentityWithIdentifierViewPermissions(BasePermission): diff --git a/api/edge_api/identities/views.py b/api/edge_api/identities/views.py index ac2994277e89..d49ae3ec4250 100644 --- a/api/edge_api/identities/views.py +++ b/api/edge_api/identities/views.py @@ -3,6 +3,7 @@ import typing import pydantic +from common.environments.permissions import MANAGE_IDENTITIES, VIEW_IDENTITIES from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_yasg.utils import swagger_auto_schema @@ -51,10 +52,6 @@ IdentityAllFeatureStatesSerializer, ) from environments.models import Environment -from environments.permissions.constants import ( - MANAGE_IDENTITIES, - VIEW_IDENTITIES, -) from environments.permissions.permissions import NestedEnvironmentPermissions from features.models import FeatureState from features.permissions import IdentityFeatureStatePermissions diff --git a/api/environments/identities/traits/views.py b/api/environments/identities/traits/views.py index 201512643e65..4b434ce539fd 100644 --- a/api/environments/identities/traits/views.py +++ b/api/environments/identities/traits/views.py @@ -1,3 +1,4 @@ +from common.environments.permissions import MANAGE_IDENTITIES, VIEW_IDENTITIES from django.conf import settings from django.core.exceptions import BadRequest from django.db.models import Q @@ -21,10 +22,6 @@ TraitSerializerBasic, TraitSerializerFull, ) -from environments.permissions.constants import ( - MANAGE_IDENTITIES, - VIEW_IDENTITIES, -) from environments.permissions.permissions import ( EnvironmentKeyPermissions, NestedEnvironmentPermissions, diff --git a/api/environments/identities/views.py b/api/environments/identities/views.py index a86d240f73d1..49f0f365ebac 100644 --- a/api/environments/identities/views.py +++ b/api/environments/identities/views.py @@ -1,6 +1,7 @@ import typing from collections import namedtuple +from common.environments.permissions import MANAGE_IDENTITIES, VIEW_IDENTITIES from core.constants import FLAGSMITH_UPDATED_AT_HEADER from core.request_origin import RequestOrigin from django.conf import settings @@ -22,10 +23,6 @@ SDKIdentitiesResponseSerializer, ) from environments.models import Environment -from environments.permissions.constants import ( - MANAGE_IDENTITIES, - VIEW_IDENTITIES, -) from environments.permissions.permissions import NestedEnvironmentPermissions from environments.sdk.serializers import ( IdentifyWithTraitsSerializer, diff --git a/api/environments/managers.py b/api/environments/managers.py index fe0e0a4b51df..c170bc44ea28 100644 --- a/api/environments/managers.py +++ b/api/environments/managers.py @@ -3,6 +3,7 @@ from features.models import FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureStateValue +from segments.models import Segment class EnvironmentManager(SoftDeleteManager): @@ -21,7 +22,10 @@ def filter_for_document_builder( *extra_select_related or (), ) .prefetch_related( - "project__segments", + Prefetch( + "project__segments", + queryset=Segment.live_objects.all(), + ), "project__segments__rules", "project__segments__rules__rules", "project__segments__rules__conditions", diff --git a/api/environments/migrations/0010_auto_20200219_2343.py b/api/environments/migrations/0010_auto_20200219_2343.py index 119513c81c84..48c221f0e0b7 100644 --- a/api/environments/migrations/0010_auto_20200219_2343.py +++ b/api/environments/migrations/0010_auto_20200219_2343.py @@ -1,7 +1,14 @@ # Generated by Django 2.2.10 on 2020-02-19 23:43 +from common.environments.permissions import ( + APPROVE_CHANGE_REQUEST, + CREATE_CHANGE_REQUEST, + MANAGE_IDENTITIES, + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, +) from django.db import migrations -from environments.permissions.constants import VIEW_ENVIRONMENT, UPDATE_FEATURE_STATE, MANAGE_IDENTITIES, CREATE_CHANGE_REQUEST, APPROVE_CHANGE_REQUEST + ENVIRONMENT_PERMISSIONS = [ (VIEW_ENVIRONMENT, "View permission for the given environment."), (UPDATE_FEATURE_STATE, "Update the state or value for a given feature state."), @@ -16,12 +23,15 @@ ), ] + def create_default_permissions(apps, schema_editor): - EnvironmentPermission = apps.get_model('environments', 'EnvironmentPermission') + EnvironmentPermission = apps.get_model("environments", "EnvironmentPermission") environment_permissions = [] for permission in ENVIRONMENT_PERMISSIONS: - environment_permissions.append(EnvironmentPermission(key=permission[0], description=permission[1])) + environment_permissions.append( + EnvironmentPermission(key=permission[0], description=permission[1]) + ) EnvironmentPermission.objects.bulk_create(environment_permissions) @@ -29,9 +39,11 @@ def create_default_permissions(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('environments', '0009_auto_20200219_1922'), + ("environments", "0009_auto_20200219_1922"), ] operations = [ - migrations.RunPython(create_default_permissions, reverse_code=lambda *args: None) + migrations.RunPython( + create_default_permissions, reverse_code=lambda *args: None + ) ] diff --git a/api/environments/models.py b/api/environments/models.py index 11bf982fd5a5..f1cf5adc5132 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -335,7 +335,7 @@ def get_segments_from_cache(self) -> typing.List[Segment]: segments = environment_segments_cache.get(self.id) if not segments: segments = list( - Segment.objects.filter( + Segment.live_objects.filter( feature_segments__feature_states__environment=self ).prefetch_related( "rules", diff --git a/api/environments/permissions/constants.py b/api/environments/permissions/constants.py deleted file mode 100644 index 90b4f6a192ea..000000000000 --- a/api/environments/permissions/constants.py +++ /dev/null @@ -1,10 +0,0 @@ -# Maintain a list of permissions here -VIEW_ENVIRONMENT = "VIEW_ENVIRONMENT" -UPDATE_FEATURE_STATE = "UPDATE_FEATURE_STATE" -MANAGE_IDENTITIES = "MANAGE_IDENTITIES" -VIEW_IDENTITIES = "VIEW_IDENTITIES" -CREATE_CHANGE_REQUEST = "CREATE_CHANGE_REQUEST" -APPROVE_CHANGE_REQUEST = "APPROVE_CHANGE_REQUEST" -MANAGE_SEGMENT_OVERRIDES = "MANAGE_SEGMENT_OVERRIDES" - -TAG_SUPPORTED_PERMISSIONS = [UPDATE_FEATURE_STATE] diff --git a/api/environments/permissions/migrations/0002_add_update_feature_state_permission.py b/api/environments/permissions/migrations/0002_add_update_feature_state_permission.py index 2d01699d970e..8833af04f959 100644 --- a/api/environments/permissions/migrations/0002_add_update_feature_state_permission.py +++ b/api/environments/permissions/migrations/0002_add_update_feature_state_permission.py @@ -1,8 +1,8 @@ # Generated by Django 2.2.24 on 2021-12-07 17:48 +from common.environments.permissions import UPDATE_FEATURE_STATE from django.db import migrations -from environments.permissions.constants import UPDATE_FEATURE_STATE from permissions.models import ENVIRONMENT_PERMISSION_TYPE @@ -26,7 +26,7 @@ class Migration(migrations.Migration): dependencies = [ ("environment_permissions", "0001_initial"), - ("features", "0035_auto_20211109_0603") + ("features", "0035_auto_20211109_0603"), ] operations = [ diff --git a/api/environments/permissions/migrations/0003_add_manage_identities_permission.py b/api/environments/permissions/migrations/0003_add_manage_identities_permission.py index 958d51a08e5b..27a4ac2e3626 100644 --- a/api/environments/permissions/migrations/0003_add_manage_identities_permission.py +++ b/api/environments/permissions/migrations/0003_add_manage_identities_permission.py @@ -1,8 +1,8 @@ # Generated by Django 2.2.24 on 2021-12-07 17:48 +from common.environments.permissions import MANAGE_IDENTITIES from django.db import migrations -from environments.permissions.constants import MANAGE_IDENTITIES from permissions.models import ENVIRONMENT_PERMISSION_TYPE diff --git a/api/environments/permissions/migrations/0004_add_change_request_permissions.py b/api/environments/permissions/migrations/0004_add_change_request_permissions.py index f9c637fed995..5cf1fea24f36 100644 --- a/api/environments/permissions/migrations/0004_add_change_request_permissions.py +++ b/api/environments/permissions/migrations/0004_add_change_request_permissions.py @@ -1,16 +1,15 @@ # Generated by Django 3.2.15 on 2022-09-13 19:18 import typing -from django.db import migrations - -from environments.permissions.constants import ( - CREATE_CHANGE_REQUEST, +from common.environments.permissions import ( APPROVE_CHANGE_REQUEST, + CREATE_CHANGE_REQUEST, UPDATE_FEATURE_STATE, ) -from permissions.models import ENVIRONMENT_PERMISSION_TYPE - from core.migration_helpers import create_new_environment_permissions +from django.db import migrations + +from permissions.models import ENVIRONMENT_PERMISSION_TYPE def add_change_request_permissions(apps, schema_editor): diff --git a/api/environments/permissions/migrations/0005_add_view_identity_permissions.py b/api/environments/permissions/migrations/0005_add_view_identity_permissions.py index 4c852f4bea1d..4c7ef9fbb805 100644 --- a/api/environments/permissions/migrations/0005_add_view_identity_permissions.py +++ b/api/environments/permissions/migrations/0005_add_view_identity_permissions.py @@ -1,9 +1,8 @@ # Generated by Django 3.2.16 on 2022-12-19 10:18 -from django.db import migrations - -from environments.permissions.constants import VIEW_IDENTITIES, MANAGE_IDENTITIES +from common.environments.permissions import MANAGE_IDENTITIES, VIEW_IDENTITIES from core.migration_helpers import create_new_environment_permissions +from django.db import migrations from permissions.models import ENVIRONMENT_PERMISSION_TYPE diff --git a/api/environments/permissions/migrations/0008_add_manage_segment_overrides_permission.py b/api/environments/permissions/migrations/0008_add_manage_segment_overrides_permission.py index 4ac562b6ceeb..313946651d58 100644 --- a/api/environments/permissions/migrations/0008_add_manage_segment_overrides_permission.py +++ b/api/environments/permissions/migrations/0008_add_manage_segment_overrides_permission.py @@ -1,11 +1,15 @@ # Generated by Django 3.2.20 on 2023-11-01 19:54 -from django.db import migrations -from environments.permissions.constants import MANAGE_SEGMENT_OVERRIDES, UPDATE_FEATURE_STATE +from common.environments.permissions import ( + MANAGE_SEGMENT_OVERRIDES, + UPDATE_FEATURE_STATE, +) from core.migration_helpers import create_new_environment_permissions +from django.db import migrations from permissions.models import ENVIRONMENT_PERMISSION_TYPE + def add_manage_segment_overrides_permission(apps, schema_editor): PermissionModel = apps.get_model("permissions", "PermissionModel") UserEnvironmentPermission = apps.get_model( @@ -41,10 +45,11 @@ def remove_manage_segment_overrides_permission(apps, schema_editor): PermissionModel = apps.get_model("permissions", "PermissionModel") PermissionModel.objects.filter(key=MANAGE_SEGMENT_OVERRIDES).delete() + class Migration(migrations.Migration): dependencies = [ - ('environment_permissions', '0007_add_unique_permission_constraint'), + ("environment_permissions", "0007_add_unique_permission_constraint"), ] operations = [ diff --git a/api/environments/permissions/permissions.py b/api/environments/permissions/permissions.py index 52530853f667..21cfc8a95b94 100644 --- a/api/environments/permissions/permissions.py +++ b/api/environments/permissions/permissions.py @@ -1,13 +1,13 @@ import typing +from common.environments.permissions import VIEW_ENVIRONMENT +from common.projects.permissions import CREATE_ENVIRONMENT from django.db.models import Model, Q from rest_framework import exceptions from rest_framework.permissions import BasePermission, IsAuthenticated from environments.models import Environment -from environments.permissions.constants import VIEW_ENVIRONMENT from projects.models import Project -from projects.permissions import CREATE_ENVIRONMENT class EnvironmentKeyPermissions(BasePermission): diff --git a/api/environments/serializers.py b/api/environments/serializers.py index 508eac9fbc9d..4d2836732859 100644 --- a/api/environments/serializers.py +++ b/api/environments/serializers.py @@ -1,10 +1,13 @@ import typing +from common.metadata.serializers import ( + MetadataSerializer, + SerializerWithMetadata, +) from rest_framework import serializers from environments.models import Environment, EnvironmentAPIKey, Webhook from features.serializers import FeatureStateSerializerFull -from metadata.serializers import MetadataSerializer, SerializerWithMetadata from organisations.models import Subscription from organisations.subscriptions.serializers.mixins import ( ReadOnlyIfNotValidPlanMixin, diff --git a/api/environments/views.py b/api/environments/views.py index e32e4ccab848..b60b8a1ab929 100644 --- a/api/environments/views.py +++ b/api/environments/views.py @@ -1,5 +1,6 @@ import logging +from common.environments.permissions import TAG_SUPPORTED_PERMISSIONS from django.db.models import Count, Q from django.utils.decorators import method_decorator from drf_yasg import openapi @@ -11,7 +12,6 @@ from rest_framework.request import Request from rest_framework.response import Response -from environments.permissions.constants import TAG_SUPPORTED_PERMISSIONS from environments.permissions.permissions import ( EnvironmentAdminPermission, EnvironmentPermissions, diff --git a/api/features/feature_segments/permissions.py b/api/features/feature_segments/permissions.py index 134e0320e89f..f057754e4f89 100644 --- a/api/features/feature_segments/permissions.py +++ b/api/features/feature_segments/permissions.py @@ -1,9 +1,9 @@ from contextlib import suppress +from common.environments.permissions import MANAGE_SEGMENT_OVERRIDES from rest_framework.permissions import IsAuthenticated from environments.models import Environment -from environments.permissions.constants import MANAGE_SEGMENT_OVERRIDES class FeatureSegmentPermissions(IsAuthenticated): diff --git a/api/features/feature_segments/serializers.py b/api/features/feature_segments/serializers.py index 1064d807dad1..0919328724c9 100644 --- a/api/features/feature_segments/serializers.py +++ b/api/features/feature_segments/serializers.py @@ -1,3 +1,4 @@ +from common.environments.permissions import MANAGE_SEGMENT_OVERRIDES from common.features.serializers import ( CreateSegmentOverrideFeatureSegmentSerializer, ) @@ -5,7 +6,6 @@ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied -from environments.permissions.constants import MANAGE_SEGMENT_OVERRIDES from features.feature_segments.limits import ( SEGMENT_OVERRIDE_LIMIT_EXCEEDED_MESSAGE, exceeds_segment_override_limit, diff --git a/api/features/feature_segments/views.py b/api/features/feature_segments/views.py index 691c916fff16..320ad2a298ae 100644 --- a/api/features/feature_segments/views.py +++ b/api/features/feature_segments/views.py @@ -1,5 +1,6 @@ import logging +from common.projects.permissions import VIEW_PROJECT from django.utils.decorators import method_decorator from drf_yasg.utils import swagger_auto_schema from rest_framework import viewsets @@ -18,7 +19,6 @@ from features.versioning.versioning_service import ( get_current_live_environment_feature_version, ) -from projects.permissions import VIEW_PROJECT from .permissions import FeatureSegmentPermissions diff --git a/api/features/import_export/permissions.py b/api/features/import_export/permissions.py index 8325e5d1c15e..ce20e525e72a 100644 --- a/api/features/import_export/permissions.py +++ b/api/features/import_export/permissions.py @@ -1,3 +1,4 @@ +from common.projects.permissions import VIEW_PROJECT from rest_framework.generics import ListAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request @@ -5,7 +6,6 @@ from environments.models import Environment from features.import_export.models import FeatureExport from projects.models import Project -from projects.permissions import VIEW_PROJECT class FeatureImportPermissions(IsAuthenticated): diff --git a/api/features/multivariate/views.py b/api/features/multivariate/views.py index 4f115d7634a6..9bd08fc036cb 100644 --- a/api/features/multivariate/views.py +++ b/api/features/multivariate/views.py @@ -1,3 +1,4 @@ +from common.projects.permissions import CREATE_FEATURE, VIEW_PROJECT from drf_yasg.utils import swagger_auto_schema from rest_framework import viewsets from rest_framework.decorators import api_view @@ -5,11 +6,7 @@ from rest_framework.response import Response from features.models import Feature -from projects.permissions import ( - CREATE_FEATURE, - VIEW_PROJECT, - NestedProjectPermissions, -) +from projects.permissions import NestedProjectPermissions from .models import MultivariateFeatureOption from .serializers import MultivariateFeatureOptionSerializer diff --git a/api/features/permissions.py b/api/features/permissions.py index 57cd146fe153..15f471d06829 100644 --- a/api/features/permissions.py +++ b/api/features/permissions.py @@ -1,26 +1,26 @@ from contextlib import suppress +from common.environments.permissions import MANAGE_SEGMENT_OVERRIDES +from common.environments.permissions import ( + TAG_SUPPORTED_PERMISSIONS as TAG_SUPPORTED_ENVIRONMENT_PERMISSIONS, +) +from common.environments.permissions import ( + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, +) +from common.projects.permissions import CREATE_FEATURE, DELETE_FEATURE +from common.projects.permissions import ( + TAG_SUPPORTED_PERMISSIONS as TAG_SUPPORTED_PROJECT_PERMISSIONS, +) +from common.projects.permissions import VIEW_PROJECT from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.viewsets import GenericViewSet from environments.models import Environment -from environments.permissions.constants import MANAGE_SEGMENT_OVERRIDES -from environments.permissions.constants import ( - TAG_SUPPORTED_PERMISSIONS as TAG_SUPPORTED_ENVIRONMENT_PERMISSIONS, -) -from environments.permissions.constants import ( - UPDATE_FEATURE_STATE, - VIEW_ENVIRONMENT, -) from features.models import Feature, FeatureState from projects.models import Project -from projects.permissions import CREATE_FEATURE, DELETE_FEATURE -from projects.permissions import ( - TAG_SUPPORTED_PERMISSIONS as TAG_SUPPORTED_PROJECT_PERMISSIONS, -) -from projects.permissions import VIEW_PROJECT ACTION_PERMISSIONS_MAP = { "retrieve": VIEW_PROJECT, diff --git a/api/features/serializers.py b/api/features/serializers.py index a00f597cf2f9..7c954758f96f 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -9,6 +9,10 @@ CreateSegmentOverrideFeatureStateSerializer, FeatureStateValueSerializer, ) +from common.metadata.serializers import ( + MetadataSerializer, + SerializerWithMetadata, +) from drf_writable_nested import WritableNestedModelSerializer from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -20,7 +24,6 @@ ) from integrations.github.constants import GitHubEventType from integrations.github.github import call_github_task -from metadata.serializers import MetadataSerializer, SerializerWithMetadata from projects.models import Project from users.serializers import ( UserIdsSerializer, diff --git a/api/features/versioning/permissions.py b/api/features/versioning/permissions.py index 0023ff33b6f6..b0a955c31605 100644 --- a/api/features/versioning/permissions.py +++ b/api/features/versioning/permissions.py @@ -1,15 +1,15 @@ -from rest_framework.permissions import BasePermission -from rest_framework.request import Request -from rest_framework.viewsets import GenericViewSet - -from environments.models import Environment -from environments.permissions.constants import ( +from common.environments.permissions import ( TAG_SUPPORTED_PERMISSIONS as TAG_SUPPORTED_ENVIRONMENT_PERMISSIONS, ) -from environments.permissions.constants import ( +from common.environments.permissions import ( UPDATE_FEATURE_STATE, VIEW_ENVIRONMENT, ) +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import GenericViewSet + +from environments.models import Environment from features.models import Feature, FeatureState from features.versioning.models import EnvironmentFeatureVersion diff --git a/api/features/versioning/views.py b/api/features/versioning/views.py index cef5f5fab9dc..268094028af7 100644 --- a/api/features/versioning/views.py +++ b/api/features/versioning/views.py @@ -1,5 +1,7 @@ from datetime import timedelta +from common.environments.permissions import VIEW_ENVIRONMENT +from common.projects.permissions import VIEW_PROJECT from django.db.models import BooleanField, ExpressionWrapper, Q, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -21,7 +23,6 @@ from app.pagination import CustomPagination from environments.models import Environment -from environments.permissions.constants import VIEW_ENVIRONMENT from features.models import Feature, FeatureState from features.serializers import ( CustomCreateSegmentOverrideFeatureStateSerializer, @@ -41,7 +42,6 @@ EnvironmentFeatureVersionRetrieveSerializer, EnvironmentFeatureVersionSerializer, ) -from projects.permissions import VIEW_PROJECT from users.models import FFAdminUser diff --git a/api/features/views.py b/api/features/views.py index b62973aceca1..c12084565f38 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -5,6 +5,7 @@ from app_analytics.analytics_db_service import get_feature_evaluation_data from app_analytics.influxdb_wrapper import get_multiple_event_list_for_feature +from common.projects.permissions import VIEW_PROJECT from core.constants import FLAGSMITH_UPDATED_AT_HEADER from core.request_origin import RequestOrigin from django.conf import settings @@ -38,7 +39,6 @@ ) from features.value_types import BOOLEAN, INTEGER, STRING from projects.models import Project -from projects.permissions import VIEW_PROJECT from users.models import FFAdminUser, UserPermissionGroup from webhooks.webhooks import WebhookEventType diff --git a/api/features/workflows/core/migrations/0011_add_project_to_change_requests.py b/api/features/workflows/core/migrations/0011_add_project_to_change_requests.py new file mode 100644 index 000000000000..d5df27aeca58 --- /dev/null +++ b/api/features/workflows/core/migrations/0011_add_project_to_change_requests.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.15 on 2024-09-17 15:34 + +import django.db.models.deletion +from django.db import migrations, models + + +def set_project_for_existing_change_requests(apps, schema_model): + ChangeRequest = apps.get_model("workflows_core", "ChangeRequest") + + change_requests = [] + for change_request in ChangeRequest.objects.filter( + environment_id__isnull=False + ).select_related("environment", "environment__project"): + change_request.project = change_request.environment.project + change_requests.append(change_request) + + ChangeRequest.objects.bulk_update(change_requests, ["project"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("environments", "0035_add_use_identity_overrides_in_local_eval"), + ("projects", "0025_add_change_request_project_permissions"), + ("workflows_core", "0010_add_ignore_conflicts_option"), + ] + + operations = [ + migrations.AddField( + model_name="changerequest", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="change_requests", + to="projects.project", + ), + ), + migrations.AddField( + model_name="historicalchangerequest", + name="project", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="projects.project", + ), + ), + migrations.AlterField( + model_name="changerequest", + name="environment", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="change_requests", + to="environments.environment", + ), + ), + migrations.RunPython( + set_project_for_existing_change_requests, + reverse_code=migrations.RunPython.noop, + ), + migrations.AlterField( + model_name="changerequest", + name="project", + field=models.ForeignKey( + null=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="change_requests", + to="projects.project", + ), + ), + ] diff --git a/api/features/workflows/core/models.py b/api/features/workflows/core/models.py index 00d18d652f2a..3d1c99bf9d27 100644 --- a/api/features/workflows/core/models.py +++ b/api/features/workflows/core/models.py @@ -18,6 +18,7 @@ AFTER_CREATE, AFTER_SAVE, AFTER_UPDATE, + BEFORE_CREATE, BEFORE_DELETE, LifecycleModel, LifecycleModelMixin, @@ -77,10 +78,18 @@ class ChangeRequest( null=True, ) + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="change_requests", + null=False, + ) + environment = models.ForeignKey( "environments.Environment", on_delete=models.CASCADE, related_name="change_requests", + null=True, ) committed_at = models.DateTimeField(null=True) @@ -114,6 +123,7 @@ def commit(self, committed_by: "FFAdminUser"): self._publish_feature_states() self._publish_environment_feature_versions(committed_by) self._publish_change_sets(committed_by) + self._publish_segments() self.committed_at = timezone.now() self.committed_by = committed_by @@ -181,6 +191,30 @@ def _publish_change_sets(self, published_by: "FFAdminUser") -> None: for change_set in self.change_sets.all(): change_set.publish(user=published_by) + def _publish_segments(self) -> None: + for segment in self.segments.all(): + target_segment = segment.version_of + assert target_segment != segment + + # Deep clone the segment to establish historical version this is required + # because the target segment will be altered when the segment is published. + # Think of it like a regular update to a segment where we create the clone + # to create the version, then modifying the new 'draft' version with the + # data from the change request. + target_segment.deep_clone() + + # Set the properties of the change request's segment to the properties + # of the target (i.e., canonical) segment. + target_segment.name = segment.name + target_segment.description = segment.description + target_segment.feature = segment.feature + target_segment.save() + + # Delete the rules in order to replace them with copies of the segment. + target_segment.rules.all().delete() + for rule in segment.rules.all(): + rule.deep_clone(target_segment) + def get_create_log_message(self, history_instance) -> typing.Optional[str]: return CHANGE_REQUEST_CREATED_MESSAGE % self.title @@ -208,10 +242,21 @@ def get_audit_log_author(self, history_instance) -> typing.Optional["FFAdminUser def _get_environment(self) -> typing.Optional["Environment"]: return self.environment - def _get_project(self) -> typing.Optional["Project"]: - return self.environment.project + def _get_project(self) -> "Project": + return self.project def is_approved(self): + if self.environment: + return self.is_approved_via_environment() + return self.is_approved_via_project() + + def is_approved_via_project(self): + return self.project.minimum_change_request_approvals is None or ( + self.approvals.filter(approved_at__isnull=False).count() + >= self.project.minimum_change_request_approvals + ) + + def is_approved_via_environment(self): return self.environment.minimum_change_request_approvals is None or ( self.approvals.filter(approved_at__isnull=False).count() >= self.environment.minimum_change_request_approvals @@ -228,8 +273,11 @@ def url(self): "Change request must be saved before it has a url attribute." ) url = get_current_site_url() - url += f"/project/{self.environment.project_id}" - url += f"/environment/{self.environment.api_key}" + if self.environment: + url += f"/project/{self.environment.project_id}" + url += f"/environment/{self.environment.api_key}" + else: + url += f"/projects/{self.project_id}" url += f"/change-requests/{self.id}" return url @@ -237,6 +285,10 @@ def url(self): def email_subject(self): return f"Flagsmith Change Request: {self.title} (#{self.id})" + @hook(BEFORE_CREATE, when="project", is_now=None) + def set_project_from_environment(self): + self.project_id = self.environment.project_id + @hook(AFTER_CREATE, when="committed_at", is_not=None) @hook(AFTER_SAVE, when="committed_at", was=None, is_not=None) def create_audit_log_for_related_feature_state(self): @@ -368,6 +420,9 @@ def get_audit_log_author(self, history_instance) -> "FFAdminUser": def _get_environment(self): return self.change_request.environment + def _get_project(self): + return self.change_request._get_project() + class ChangeRequestGroupAssignment(AbstractBaseExportableModel, LifecycleModel): change_request = models.ForeignKey( diff --git a/api/import_export/export.py b/api/import_export/export.py index fd373bbc7255..efcdf630a1e5 100644 --- a/api/import_export/export.py +++ b/api/import_export/export.py @@ -114,7 +114,9 @@ def export_projects(organisation_id: int) -> typing.List[dict]: return _export_entities( _EntityExportConfig(Project, Q(organisation__id=organisation_id)), - _EntityExportConfig(Segment, default_filter), + _EntityExportConfig( + Segment, Q(project__organisation__id=organisation_id, id=F("version_of")) + ), _EntityExportConfig( SegmentRule, Q( diff --git a/api/integrations/common/views.py b/api/integrations/common/views.py index f4ed2d230608..64a37f91f352 100644 --- a/api/integrations/common/views.py +++ b/api/integrations/common/views.py @@ -1,3 +1,5 @@ +from common.environments.permissions import VIEW_ENVIRONMENT +from common.projects.permissions import VIEW_PROJECT from django.db.models import QuerySet from django.shortcuts import get_object_or_404 from rest_framework import viewsets @@ -7,12 +9,11 @@ from rest_framework.serializers import BaseSerializer from environments.models import Environment -from environments.permissions.constants import VIEW_ENVIRONMENT from environments.permissions.permissions import NestedEnvironmentPermissions from organisations.permissions.permissions import ( NestedOrganisationEntityPermission, ) -from projects.permissions import VIEW_PROJECT, NestedProjectPermissions +from projects.permissions import NestedProjectPermissions class EnvironmentIntegrationCommonViewSet(viewsets.ModelViewSet): diff --git a/api/integrations/dynatrace/dynatrace.py b/api/integrations/dynatrace/dynatrace.py index 0a448d24a27b..d888e668c0ef 100644 --- a/api/integrations/dynatrace/dynatrace.py +++ b/api/integrations/dynatrace/dynatrace.py @@ -95,7 +95,7 @@ def _get_deployment_name_for_feature( def _get_deployment_name_for_segment(object_id: int) -> str: - if segment := Segment.objects.all_with_deleted().filter(id=object_id).first(): + if segment := Segment.live_objects.all_with_deleted().filter(id=object_id).first(): return f"Flagsmith Deployment - Segment Changed: {segment.name}" return DEFAULT_DEPLOYMENT_NAME diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py index c4650a44b6b1..a0a480733b50 100644 --- a/api/integrations/launch_darkly/services.py +++ b/api/integrations/launch_darkly/services.py @@ -241,7 +241,7 @@ def _create_feature_segments_for_segment_match_clauses( targeted_segment_name = segments_by_ld_key[targeted_segment_key].name # We assume segment is already created. - segment = Segment.objects.get(name=targeted_segment_name, project=project) + segment = Segment.live_objects.get(name=targeted_segment_name, project=project) feature_segment, _ = FeatureSegment.objects.update_or_create( feature=feature, @@ -368,7 +368,7 @@ def _create_feature_segment_from_clauses( ) # Create a feature specific segment for the rule. - segment, _ = Segment.objects.update_or_create( + segment, _ = Segment.live_objects.update_or_create( name=rule_name, project=project, feature=feature ) @@ -961,7 +961,7 @@ def _create_segments_from_ld( continue # Make sure consecutive updates do not create the same segment. - segment, _ = Segment.objects.update_or_create( + segment, _ = Segment.live_objects.update_or_create( name=_get_segment_name(ld_segment["name"], env), project_id=project_id, ) diff --git a/api/integrations/launch_darkly/views.py b/api/integrations/launch_darkly/views.py index 93e2ddc0cc81..a781d416a8e5 100644 --- a/api/integrations/launch_darkly/views.py +++ b/api/integrations/launch_darkly/views.py @@ -1,3 +1,4 @@ +from common.projects.permissions import CREATE_ENVIRONMENT, VIEW_PROJECT from django.db.models import QuerySet from django.db.utils import IntegrityError from django.shortcuts import get_object_or_404 @@ -18,11 +19,7 @@ process_launch_darkly_import_request, ) from projects.models import Project -from projects.permissions import ( - CREATE_ENVIRONMENT, - VIEW_PROJECT, - NestedProjectPermissions, -) +from projects.permissions import NestedProjectPermissions class LaunchDarklyImportRequestViewSet( diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index a6fdddf89582..be46aea7a8ce 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -1,15 +1,11 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import Model from rest_framework import serializers -from organisations.models import Organisation -from projects.models import Project from util.drf_writable_nested.serializers import ( DeleteBeforeUpdateWritableNestedModelSerializer, ) from .models import ( - Metadata, MetadataField, MetadataModelField, MetadataModelFieldRequirement, @@ -71,69 +67,3 @@ class ContentTypeSerializer(serializers.ModelSerializer): class Meta: model = ContentType fields = ("id", "app_label", "model") - - -class MetadataSerializer(serializers.ModelSerializer): - class Meta: - model = Metadata - fields = ("id", "model_field", "field_value") - - def validate(self, data): - data = super().validate(data) - if not data["model_field"].field.is_field_value_valid(data["field_value"]): - raise serializers.ValidationError( - f"Invalid value for field {data['model_field'].field.name}" - ) - - return data - - -class SerializerWithMetadata(serializers.BaseSerializer): - def get_organisation(self, validated_data: dict = None) -> Organisation: - return self.get_project(validated_data).organisation - - def get_project(self, validated_data: dict = None) -> Project: - raise NotImplementedError() - - def get_required_for_object( - self, requirement: MetadataModelFieldRequirement, data: dict - ) -> Model: - model_name = requirement.content_type.model - try: - return getattr(self, f"get_{model_name}")(data) - except AttributeError: - raise ValueError( - f"`get_{model_name}_from_validated_data` method does not exist" - ) - - def validate_required_metadata(self, data): - metadata = data.get("metadata", []) - - content_type = ContentType.objects.get_for_model(self.Meta.model) - - organisation = self.get_organisation(data) - - requirements = MetadataModelFieldRequirement.objects.filter( - model_field__content_type=content_type, - model_field__field__organisation=organisation, - ) - - for requirement in requirements: - required_for = self.get_required_for_object(requirement, data) - if required_for.id == requirement.object_id: - if not any( - [ - field["model_field"] == requirement.model_field - for field in metadata - ] - ): - raise serializers.ValidationError( - { - "metadata": f"Missing required metadata field: {requirement.model_field.field.name}" - } - ) - - def validate(self, data): - data = super().validate(data) - self.validate_required_metadata(data) - return data diff --git a/api/permissions/migrations/0001_initial.py b/api/permissions/migrations/0001_initial.py index 33a0f25ce2d8..5c2e4a57233b 100644 --- a/api/permissions/migrations/0001_initial.py +++ b/api/permissions/migrations/0001_initial.py @@ -1,10 +1,20 @@ # Generated by Django 2.2.10 on 2020-02-20 00:24 +from common.environments.permissions import ( + APPROVE_CHANGE_REQUEST, + CREATE_CHANGE_REQUEST, + MANAGE_IDENTITIES, + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, +) +from common.projects.permissions import PROJECT_PERMISSIONS from django.db import migrations, models -from permissions.models import PROJECT_PERMISSION_TYPE, ENVIRONMENT_PERMISSION_TYPE -from projects.permissions import PROJECT_PERMISSIONS -from environments.permissions.constants import VIEW_ENVIRONMENT, UPDATE_FEATURE_STATE, MANAGE_IDENTITIES, CREATE_CHANGE_REQUEST, APPROVE_CHANGE_REQUEST +from permissions.models import ( + ENVIRONMENT_PERMISSION_TYPE, + PROJECT_PERMISSION_TYPE, +) + ENVIRONMENT_PERMISSIONS = [ (VIEW_ENVIRONMENT, "View permission for the given environment."), (UPDATE_FEATURE_STATE, "Update the state or value for a given feature state."), @@ -21,23 +31,33 @@ def insert_default_project_permissions(apps, schema_model): - PermissionModel = apps.get_model('permissions', 'PermissionModel') + PermissionModel = apps.get_model("permissions", "PermissionModel") project_permissions = [] for permission in PROJECT_PERMISSIONS: project_permissions.append( - PermissionModel(key=permission[0], description=permission[1], type=PROJECT_PERMISSION_TYPE)) + PermissionModel( + key=permission[0], + description=permission[1], + type=PROJECT_PERMISSION_TYPE, + ) + ) PermissionModel.objects.bulk_create(project_permissions) def insert_default_environment_permissions(apps, schema_model): - PermissionModel = apps.get_model('permissions', 'PermissionModel') + PermissionModel = apps.get_model("permissions", "PermissionModel") environment_permissions = [] for permission in ENVIRONMENT_PERMISSIONS: environment_permissions.append( - PermissionModel(key=permission[0], description=permission[1], type=ENVIRONMENT_PERMISSION_TYPE)) + PermissionModel( + key=permission[0], + description=permission[1], + type=ENVIRONMENT_PERMISSION_TYPE, + ) + ) PermissionModel.objects.bulk_create(environment_permissions) @@ -46,18 +66,34 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='PermissionModel', + name="PermissionModel", fields=[ - ('key', models.CharField(max_length=100, primary_key=True, serialize=False)), - ('description', models.TextField()), - ('type', models.CharField(choices=[('PROJECT', 'Project'), ('ENVIRONMENT', 'Environment')], max_length=100, null=True)), + ( + "key", + models.CharField(max_length=100, primary_key=True, serialize=False), + ), + ("description", models.TextField()), + ( + "type", + models.CharField( + choices=[ + ("PROJECT", "Project"), + ("ENVIRONMENT", "Environment"), + ], + max_length=100, + null=True, + ), + ), ], ), - migrations.RunPython(insert_default_project_permissions, reverse_code=lambda *args: None), - migrations.RunPython(insert_default_environment_permissions, reverse_code=lambda *args: None), + migrations.RunPython( + insert_default_project_permissions, reverse_code=lambda *args: None + ), + migrations.RunPython( + insert_default_environment_permissions, reverse_code=lambda *args: None + ), ] diff --git a/api/permissions/migrations/0008_add_view_audit_log_permission.py b/api/permissions/migrations/0008_add_view_audit_log_permission.py index 3e1b351602c1..fb4e2a712f11 100644 --- a/api/permissions/migrations/0008_add_view_audit_log_permission.py +++ b/api/permissions/migrations/0008_add_view_audit_log_permission.py @@ -1,10 +1,10 @@ # Generated by Django 3.2.16 on 2022-12-08 11:02 +from common.projects.permissions import VIEW_AUDIT_LOG from django.apps.registry import Apps from django.db import migrations from django.db.backends.base.schema import BaseDatabaseSchemaEditor -from projects.permissions import VIEW_AUDIT_LOG from permissions.models import ORGANISATION_PERMISSION_TYPE diff --git a/api/permissions/migrations/0009_move_view_audit_log_permission.py b/api/permissions/migrations/0009_move_view_audit_log_permission.py index b3c03233867d..e11762440d37 100644 --- a/api/permissions/migrations/0009_move_view_audit_log_permission.py +++ b/api/permissions/migrations/0009_move_view_audit_log_permission.py @@ -1,16 +1,17 @@ # Generated by Django 3.2.16 on 2022-12-08 11:02 +from common.projects.permissions import VIEW_AUDIT_LOG from django.apps.registry import Apps from django.db import migrations from django.db.backends.base.schema import BaseDatabaseSchemaEditor -from projects.permissions import VIEW_AUDIT_LOG -from permissions.models import ORGANISATION_PERMISSION_TYPE, PROJECT_PERMISSION_TYPE +from permissions.models import ( + ORGANISATION_PERMISSION_TYPE, + PROJECT_PERMISSION_TYPE, +) -def move_permission_to_project( - apps: Apps, schema_editor: BaseDatabaseSchemaEditor -): +def move_permission_to_project(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): permission_model_class = apps.get_model("permissions", "PermissionModel") permission_model_class.objects.filter( @@ -43,4 +44,4 @@ class Migration(migrations.Migration): migrations.RunPython( move_permission_to_project, reverse_code=move_permission_to_organisation ) - ] \ No newline at end of file + ] diff --git a/api/permissions/migrations/0010_add_manage_tags_permission.py b/api/permissions/migrations/0010_add_manage_tags_permission.py index c7ddfd24e538..7e10801b6bde 100644 --- a/api/permissions/migrations/0010_add_manage_tags_permission.py +++ b/api/permissions/migrations/0010_add_manage_tags_permission.py @@ -1,13 +1,15 @@ # Generated by Django 4.2.15 on 2024-09-13 16:18 +from common.projects.permissions import MANAGE_TAGS from django.apps.registry import Apps from django.db import migrations from django.db.backends.base.schema import BaseDatabaseSchemaEditor from permissions.models import PROJECT_PERMISSION_TYPE -from projects.permissions import MANAGE_TAGS -def add_manage_tags_permission(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: +def add_manage_tags_permission( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +) -> None: permission_model_class = apps.get_model("permissions", "permissionmodel") permission_model_class.objects.get_or_create( key=MANAGE_TAGS, @@ -16,7 +18,9 @@ def add_manage_tags_permission(apps: Apps, schema_editor: BaseDatabaseSchemaEdit ) -def reverse(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: # pragma: no cover +def reverse( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +) -> None: # pragma: no cover permission_model_class = apps.get_model("permissions", "permissionmodel") permission_model_class.objects.filter(key=MANAGE_TAGS).delete() diff --git a/api/poetry.lock b/api/poetry.lock index a9160aeebb48..ba834dbae554 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1353,15 +1353,17 @@ files = [] develop = false [package.dependencies] -django = "*" +django = "<5.0.0" djangorestframework = "*" +djangorestframework-recursive = "*" drf-writable-nested = "*" +flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-common" -reference = "v1.0.0" -resolved_reference = "f3809f6d592b2c6cfdfa88e0b345ce722ac47727" +reference = "v1.1.0" +resolved_reference = "27fbd8b7d889dc1529df08972a8c1bfaba5a7e03" [[package]] name = "flagsmith-flag-engine" @@ -3338,7 +3340,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -4057,13 +4058,16 @@ files = [] develop = false [package.dependencies] -flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.0.0"} +djangorestframework = "*" +djangorestframework-recursive = "*" +flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.1.0"} +flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/flagsmith/flagsmith-workflows" -reference = "v2.5.0" -resolved_reference = "9fd951a470de537389c8d08c186656464500f3ed" +reference = "v2.6.0" +resolved_reference = "06b03d428484f5aed2f400cef431ab5aae0d0df0" [[package]] name = "wrapt" @@ -4182,4 +4186,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "27333f5bbd3bb607cdb7d728dae6d0a6a11658cba09b45d69a6ee5a744111ad5" +content-hash = "df2e02787204f56111c560faa7f7800a09e87e9bdc0b9802660af5bd08aa03ee" diff --git a/api/projects/migrations/0003_auto_20200216_2050.py b/api/projects/migrations/0003_auto_20200216_2050.py index 21e8b7ea115d..eb61d86910b7 100644 --- a/api/projects/migrations/0003_auto_20200216_2050.py +++ b/api/projects/migrations/0003_auto_20200216_2050.py @@ -1,16 +1,17 @@ # Generated by Django 2.2.10 on 2020-02-16 20:50 +from common.projects.permissions import PROJECT_PERMISSIONS from django.db import migrations -from projects.permissions import PROJECT_PERMISSIONS - def insert_default_permissions(apps, schema_model): - ProjectPermission = apps.get_model('projects', 'ProjectPermission') + ProjectPermission = apps.get_model("projects", "ProjectPermission") project_permissions = [] for permission in PROJECT_PERMISSIONS: - project_permissions.append(ProjectPermission(key=permission[0], description=permission[1])) + project_permissions.append( + ProjectPermission(key=permission[0], description=permission[1]) + ) ProjectPermission.objects.bulk_create(project_permissions) @@ -18,9 +19,14 @@ def insert_default_permissions(apps, schema_model): class Migration(migrations.Migration): dependencies = [ - ('projects', '0002_projectpermission_userpermissiongroupprojectpermission_userprojectpermission'), + ( + "projects", + "0002_projectpermission_userpermissiongroupprojectpermission_userprojectpermission", + ), ] operations = [ - migrations.RunPython(insert_default_permissions, reverse_code=lambda *args: None) + migrations.RunPython( + insert_default_permissions, reverse_code=lambda *args: None + ) ] diff --git a/api/projects/migrations/0025_add_change_request_project_permissions.py b/api/projects/migrations/0025_add_change_request_project_permissions.py new file mode 100644 index 000000000000..336c209eeb82 --- /dev/null +++ b/api/projects/migrations/0025_add_change_request_project_permissions.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.15 on 2024-09-17 14:07 + +from common.projects.permissions import ( + APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS, + MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS, + PROJECT_PERMISSIONS, +) +from django.db import migrations + +from permissions.models import PROJECT_PERMISSION_TYPE + + +def remove_default_project_permissions(apps, schema_model): # pragma: no cover + PermissionModel = apps.get_model("permissions", "PermissionModel") + PermissionModel.objects.get(key=MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS).delete() + PermissionModel.objects.get(key=APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS).delete() + + +def insert_default_project_permissions(apps, schema_model): + PermissionModel = apps.get_model("permissions", "PermissionModel") + + manage_description = "Ability to manage change requests associated with a project." + approve_description = "Ability to approve project level change requests." + + PermissionModel.objects.get_or_create( + key=MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS, + description=manage_description, + type=PROJECT_PERMISSION_TYPE, + ) + PermissionModel.objects.get_or_create( + key=APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS, + description=approve_description, + type=PROJECT_PERMISSION_TYPE, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0024_add_project_edge_v2_migration_read_capacity_budget"), + ] + + operations = [ + migrations.RunPython( + insert_default_project_permissions, + reverse_code=remove_default_project_permissions, + ), + ] diff --git a/api/projects/migrations/0026_add_change_request_approval_limit_to_projects.py b/api/projects/migrations/0026_add_change_request_approval_limit_to_projects.py new file mode 100644 index 000000000000..209e6d1f5519 --- /dev/null +++ b/api/projects/migrations/0026_add_change_request_approval_limit_to_projects.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-09-20 14:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0025_add_change_request_project_permissions"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="minimum_change_request_approvals", + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/api/projects/models.py b/api/projects/models.py index d20c26eea833..0eee8a630b66 100644 --- a/api/projects/models.py +++ b/api/projects/models.py @@ -25,13 +25,13 @@ PermissionModel, ) from projects.managers import ProjectManager +from projects.services import get_project_segments_from_cache from projects.tasks import ( handle_cascade_delete, migrate_project_environments_to_v2, write_environments_to_dynamodb, ) -project_segments_cache = caches[settings.PROJECT_SEGMENTS_CACHE_LOCATION] environment_cache = caches[settings.ENVIRONMENT_CACHE_NAME] @@ -106,6 +106,7 @@ class Project(LifecycleModelMixin, SoftDeleteExportableModel): default=30, help_text="Number of days without modification in any environment before a flag is considered stale.", ) + minimum_change_request_approvals = models.IntegerField(blank=True, null=True) objects = ProjectManager() @@ -119,7 +120,7 @@ def __str__(self): def is_too_large(self) -> bool: return ( self.features.count() > self.max_features_allowed - or self.segments.count() > self.max_segments_allowed + or self.live_segment_count() > self.max_segments_allowed or self.environments.annotate( segment_override_count=Count("feature_segments") ) @@ -142,24 +143,7 @@ def edge_v2_identity_overrides_migrated(self) -> bool: return self.edge_v2_migration_status == EdgeV2MigrationStatus.COMPLETE def get_segments_from_cache(self): - segments = project_segments_cache.get(self.id) - - if not segments: - # This is optimised to account for rules nested one levels deep (since we - # don't support anything above that from the UI at the moment). Anything - # past that will require additional queries / thought on how to optimise. - segments = self.segments.all().prefetch_related( - "rules", - "rules__conditions", - "rules__rules", - "rules__rules__conditions", - "rules__rules__rules", - ) - project_segments_cache.set( - self.id, segments, timeout=settings.CACHE_PROJECT_SEGMENTS_SECONDS - ) - - return segments + return get_project_segments_from_cache(self.id) @hook(BEFORE_CREATE) def set_enable_dynamo_db(self): @@ -204,6 +188,11 @@ def is_edge_project_by_default(self) -> bool: and self.created_date >= settings.EDGE_RELEASE_DATETIME ) + def live_segment_count(self) -> int: + from segments.models import Segment + + return Segment.live_objects.filter(project=self).count() + def is_feature_name_valid(self, feature_name: str) -> bool: """ Validate the feature name based on the feature_name_regex attribute. diff --git a/api/projects/permissions.py b/api/projects/permissions.py index 1903c94a6b18..abd5c6f74de2 100644 --- a/api/projects/permissions.py +++ b/api/projects/permissions.py @@ -1,5 +1,6 @@ import typing +from common.projects.permissions import VIEW_PROJECT from django.db.models import Model from rest_framework.exceptions import APIException, PermissionDenied from rest_framework.permissions import BasePermission, IsAuthenticated @@ -8,30 +9,6 @@ from organisations.permissions.permissions import CREATE_PROJECT from projects.models import Project -VIEW_AUDIT_LOG = "VIEW_AUDIT_LOG" - -# Maintain a list of permissions here -VIEW_PROJECT = "VIEW_PROJECT" -CREATE_ENVIRONMENT = "CREATE_ENVIRONMENT" -DELETE_FEATURE = "DELETE_FEATURE" -CREATE_FEATURE = "CREATE_FEATURE" -EDIT_FEATURE = "EDIT_FEATURE" -MANAGE_SEGMENTS = "MANAGE_SEGMENTS" -MANAGE_TAGS = "MANAGE_TAGS" - -TAG_SUPPORTED_PERMISSIONS = [DELETE_FEATURE] - -PROJECT_PERMISSIONS = [ - (VIEW_PROJECT, "View permission for the given project."), - (CREATE_ENVIRONMENT, "Ability to create an environment in the given project."), - (DELETE_FEATURE, "Ability to delete features in the given project."), - (CREATE_FEATURE, "Ability to create features in the given project."), - (EDIT_FEATURE, "Ability to edit features in the given project."), - (MANAGE_SEGMENTS, "Ability to manage segments in the given project."), - (VIEW_AUDIT_LOG, "Allows the user to view the audit logs for this organisation."), - (MANAGE_TAGS, "Allows the user to manage tags in the given project."), -] - class ProjectPermissions(IsAuthenticated): def has_permission(self, request, view): diff --git a/api/projects/serializers.py b/api/projects/serializers.py index 7a934861d7c8..339b1b8fa90a 100644 --- a/api/projects/serializers.py +++ b/api/projects/serializers.py @@ -43,6 +43,7 @@ class Meta: "show_edge_identity_overrides_for_feature", "stale_flags_limit_days", "edge_v2_migration_status", + "minimum_change_request_approvals", ) read_only_fields = ( "enable_dynamo_db", @@ -130,7 +131,7 @@ def get_total_features(self, instance: Project) -> int: def get_total_segments(self, instance: Project) -> int: # added here to prevent need for annotate(Count("segments", distinct=True)) # which causes performance issues. - return instance.segments.count() + return instance.live_segment_count() class CreateUpdateUserProjectPermissionSerializer( diff --git a/api/projects/services.py b/api/projects/services.py new file mode 100644 index 000000000000..f69414101df5 --- /dev/null +++ b/api/projects/services.py @@ -0,0 +1,35 @@ +import typing + +from django.apps import apps +from django.conf import settings +from django.core.cache import caches + +if typing.TYPE_CHECKING: + from django.db.models import QuerySet + + from segments.models import Segment + +project_segments_cache = caches[settings.PROJECT_SEGMENTS_CACHE_LOCATION] + + +def get_project_segments_from_cache(project_id: int) -> "QuerySet[Segment]": + Segment = apps.get_model("segments", "Segment") + + segments = project_segments_cache.get(project_id) + if not segments: + # This is optimised to account for rules nested one levels deep (since we + # don't support anything above that from the UI at the moment). Anything + # past that will require additional queries / thought on how to optimise. + segments = Segment.live_objects.filter(project_id=project_id).prefetch_related( + "rules", + "rules__conditions", + "rules__rules", + "rules__rules__conditions", + "rules__rules__rules", + ) + + project_segments_cache.set( + project_id, segments, timeout=settings.CACHE_PROJECT_SEGMENTS_SECONDS + ) + + return segments diff --git a/api/projects/tags/permissions.py b/api/projects/tags/permissions.py index 88a480624952..41c7f2d9dfec 100644 --- a/api/projects/tags/permissions.py +++ b/api/projects/tags/permissions.py @@ -1,7 +1,7 @@ +from common.projects.permissions import MANAGE_TAGS, VIEW_PROJECT from rest_framework.permissions import BasePermission from projects.models import Project -from projects.permissions import MANAGE_TAGS, VIEW_PROJECT class TagPermissions(BasePermission): diff --git a/api/projects/tags/views.py b/api/projects/tags/views.py index 49b163316240..bd0a07a73217 100644 --- a/api/projects/tags/views.py +++ b/api/projects/tags/views.py @@ -1,3 +1,4 @@ +from common.projects.permissions import VIEW_PROJECT from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 @@ -5,8 +6,6 @@ from rest_framework.request import Request from rest_framework.response import Response -from projects.permissions import VIEW_PROJECT - from . import serializers from .models import Tag from .permissions import TagPermissions diff --git a/api/projects/urls.py b/api/projects/urls.py index 4a38160dce16..b0150651f82a 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -1,3 +1,6 @@ +import importlib + +from django.conf import settings from django.urls import include, path, re_path from rest_framework_nested import routers @@ -67,6 +70,16 @@ ProjectAuditLogViewSet, basename="project-audit", ) + +if settings.WORKFLOWS_LOGIC_INSTALLED: # pragma: no cover + workflow_views = importlib.import_module("workflows_logic.views") + projects_router.register( + r"change-requests", + workflow_views.ProjectChangeRequestViewSet, + basename="project-change-requests", + ) + + nested_features_router = routers.NestedSimpleRouter( projects_router, r"features", lookup="feature" ) diff --git a/api/projects/views.py b/api/projects/views.py index 27c720f6a138..f75269e45036 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from common.projects.permissions import TAG_SUPPORTED_PERMISSIONS, VIEW_PROJECT from django.conf import settings from django.utils.decorators import method_decorator from drf_yasg import openapi @@ -33,12 +34,7 @@ UserPermissionGroupProjectPermission, UserProjectPermission, ) -from projects.permissions import ( - TAG_SUPPORTED_PERMISSIONS, - VIEW_PROJECT, - IsProjectAdmin, - ProjectPermissions, -) +from projects.permissions import IsProjectAdmin, ProjectPermissions from projects.serializers import ( CreateUpdateUserPermissionGroupProjectPermissionSerializer, CreateUpdateUserProjectPermissionSerializer, diff --git a/api/pyproject.toml b/api/pyproject.toml index aaac71259708..90a75efd26d8 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -170,7 +170,7 @@ hubspot-api-client = "^8.2.1" djangorestframework-dataclasses = "^1.3.1" pyotp = "^2.9.0" flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.0.2" } -flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.0.0" } +flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.1.0" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" @@ -196,7 +196,7 @@ flagsmith-ldap = { git = "https://github.com/flagsmith/flagsmith-ldap", tag = "v optional = true [tool.poetry.group.workflows.dependencies] -workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.5.0" } +workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.6.0" } [tool.poetry.group.dev.dependencies] django-test-migrations = "~1.2.0" diff --git a/api/sales_dashboard/templates/sales_dashboard/organisation.html b/api/sales_dashboard/templates/sales_dashboard/organisation.html index f0e7563a973f..3ae4faa87db0 100644 --- a/api/sales_dashboard/templates/sales_dashboard/organisation.html +++ b/api/sales_dashboard/templates/sales_dashboard/organisation.html @@ -129,7 +129,7 @@

Projects

{{project.name}} {{project.environments.all.count}} {{project.features.all.count}} - {{project.segments.all.count}} + {{project.live_segment_count}} {{project.enable_dynamo_db}} {{identity_count_dict|get_item:project.id|intcomma}} {{identity_migration_status_dict|get_item:project.id}} diff --git a/api/segments/managers.py b/api/segments/managers.py index d1f977ca06ed..48f4b6da0eac 100644 --- a/api/segments/managers.py +++ b/api/segments/managers.py @@ -3,6 +3,10 @@ class SegmentManager(SoftDeleteExportableManager): + pass + + +class LiveSegmentManager(SoftDeleteExportableManager): def get_queryset(self): """ Returns only the canonical segments, which will always be diff --git a/api/segments/migrations/0026_add_change_request_to_segments.py b/api/segments/migrations/0026_add_change_request_to_segments.py new file mode 100644 index 000000000000..04c51be17e20 --- /dev/null +++ b/api/segments/migrations/0026_add_change_request_to_segments.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.15 on 2024-09-06 15:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("workflows_core", "0009_prevent_cascade_delete_from_user_delete"), + ("segments", "0025_set_default_version_on_segment"), + ] + + operations = [ + migrations.AddField( + model_name="historicalsegment", + name="change_request", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="workflows_core.changerequest", + ), + ), + migrations.AddField( + model_name="segment", + name="change_request", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="segments", + to="workflows_core.changerequest", + ), + ), + ] diff --git a/api/segments/models.py b/api/segments/models.py index 8b8553484985..22b8c30d19d1 100644 --- a/api/segments/models.py +++ b/api/segments/models.py @@ -4,7 +4,6 @@ from copy import deepcopy from core.models import ( - SoftDeleteExportableManager, SoftDeleteExportableModel, abstract_base_auditable_model_factory, ) @@ -31,7 +30,7 @@ from projects.models import Project from .helpers import segment_audit_log_helper -from .managers import SegmentManager +from .managers import LiveSegmentManager, SegmentManager logger = logging.getLogger(__name__) @@ -60,7 +59,6 @@ class Segment( # This defaults to 1 for newly created segments. version = models.IntegerField(null=True) - # The related_name is not useful without specifying all_objects as a manager. version_of = models.ForeignKey( "self", on_delete=models.CASCADE, @@ -68,16 +66,24 @@ class Segment( null=True, blank=True, ) + + change_request = models.ForeignKey( + "workflows_core.ChangeRequest", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="segments", + ) + metadata = GenericRelation(Metadata) created_at = models.DateTimeField(null=True, auto_now_add=True) updated_at = models.DateTimeField(null=True, auto_now=True) - # Only serves segments that are the canonical version. objects = SegmentManager() - # Includes versioned segments. - all_objects = SoftDeleteExportableManager() + # Only serves segments that are the canonical version. + live_objects = LiveSegmentManager() class Meta: ordering = ("id",) # explicit ordering to prevent pagination warnings @@ -145,6 +151,26 @@ def set_version_of_to_self_if_none(self): self.save() segment_audit_log_helper.unset_skip_audit_log(self.id) + def shallow_clone( + self, + name: str, + description: str, + change_request: typing.Optional["ChangeRequest"], # noqa: F821 + ) -> "Segment": + cloned_segment = Segment( + version_of=self, + uuid=uuid.uuid4(), + name=name, + description=description, + change_request=change_request, + project=self.project, + feature=self.feature, + version=None, + ) + cloned_segment.history.update() + cloned_segment.save() + return cloned_segment + def deep_clone(self) -> "Segment": cloned_segment = deepcopy(self) cloned_segment.id = None diff --git a/api/segments/permissions.py b/api/segments/permissions.py index efb901bc0177..283acec53596 100644 --- a/api/segments/permissions.py +++ b/api/segments/permissions.py @@ -1,7 +1,7 @@ +from common.projects.permissions import MANAGE_SEGMENTS, VIEW_PROJECT from rest_framework.permissions import IsAuthenticated from projects.models import Project -from projects.permissions import MANAGE_SEGMENTS, VIEW_PROJECT class SegmentPermissions(IsAuthenticated): diff --git a/api/segments/serializers.py b/api/segments/serializers.py index 053d119d0499..644a8a4d804b 100644 --- a/api/segments/serializers.py +++ b/api/segments/serializers.py @@ -1,248 +1,6 @@ -import logging -import typing - -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from flag_engine.segments.constants import PERCENTAGE_SPLIT from rest_framework import serializers -from rest_framework.exceptions import ValidationError -from rest_framework.serializers import ListSerializer -from rest_framework_recursive.fields import RecursiveField - -from metadata.models import Metadata -from metadata.serializers import MetadataSerializer, SerializerWithMetadata -from projects.models import Project -from segments.models import Condition, Segment, SegmentRule - -logger = logging.getLogger(__name__) - - -class ConditionSerializer(serializers.ModelSerializer): - delete = serializers.BooleanField(write_only=True, required=False) - - class Meta: - model = Condition - fields = ("id", "operator", "property", "value", "description", "delete") - - def validate(self, attrs): - super(ConditionSerializer, self).validate(attrs) - if attrs.get("operator") != PERCENTAGE_SPLIT and not attrs.get("property"): - raise ValidationError({"property": ["This field may not be blank."]}) - return attrs - - def to_internal_value(self, data): - # convert value to a string - conversion to correct value type is handled elsewhere - data["value"] = str(data["value"]) if "value" in data else None - return super(ConditionSerializer, self).to_internal_value(data) - - -class RuleSerializer(serializers.ModelSerializer): - delete = serializers.BooleanField(write_only=True, required=False) - conditions = ConditionSerializer(many=True, required=False) - rules = ListSerializer(child=RecursiveField(), required=False) - - class Meta: - model = SegmentRule - fields = ("id", "type", "rules", "conditions", "delete") - - -class SegmentSerializer(serializers.ModelSerializer, SerializerWithMetadata): - rules = RuleSerializer(many=True) - metadata = MetadataSerializer(required=False, many=True) - - class Meta: - model = Segment - fields = "__all__" - - def validate(self, attrs): - attrs = super().validate(attrs) - self.validate_required_metadata(attrs) - if not attrs.get("rules"): - raise ValidationError( - {"rules": "Segment cannot be created without any rules."} - ) - return attrs - - def get_project(self, validated_data: dict = None) -> Project: - return validated_data.get("project") or Project.objects.get( - id=self.context["view"].kwargs["project_pk"] - ) - - def create(self, validated_data): - project = validated_data["project"] - self.validate_project_segment_limit(project) - - rules_data = validated_data.pop("rules", []) - metadata_data = validated_data.pop("metadata", []) - self.validate_segment_rules_conditions_limit(rules_data) - - # create segment with nested rules and conditions - segment = Segment.objects.create(**validated_data) - self._update_or_create_segment_rules( - rules_data, segment=segment, is_create=True - ) - self._update_or_create_metadata(metadata_data, segment=segment) - return segment - - def update(self, instance: Segment, validated_data: dict[str, typing.Any]) -> None: - # use the initial data since we need the ids included to determine which to update & which to create - rules_data = self.initial_data.pop("rules", []) - metadata_data = validated_data.pop("metadata", []) - self.validate_segment_rules_conditions_limit(rules_data) - - # Create a version of the segment now that we're updating. - cloned_segment = instance.deep_clone() - logger.info( - f"Updating cloned segment {cloned_segment.id} for original segment {instance.id}" - ) - - try: - self._update_segment_rules(rules_data, segment=instance) - self._update_or_create_metadata(metadata_data, segment=instance) - - # remove rules from validated data to prevent error trying to create segment with nested rules - del validated_data["rules"] - response = super().update(instance, validated_data) - except Exception: - # Since there was a problem during the update we now delete the cloned segment, - # since we no longer need a versioned segment. - instance.refresh_from_db() - instance.version = cloned_segment.version - instance.save() - cloned_segment.hard_delete() - raise - return response - - def validate_project_segment_limit(self, project: Project) -> None: - if project.segments.count() >= project.max_segments_allowed: - raise ValidationError( - { - "project": "The project has reached the maximum allowed segments limit." - } - ) - - def validate_segment_rules_conditions_limit( - self, rules_data: dict[str, object] - ) -> None: - if self.instance and getattr(self.instance, "whitelisted_segment", None): - return - - count = self._calculate_condition_count(rules_data) - - if self.instance: - logger.info(f"Segment {self.instance.id} has count of conditions {count}") - - if count > settings.SEGMENT_RULES_CONDITIONS_LIMIT: - raise ValidationError( - { - "segment": f"The segment has {count} conditions, which exceeds the maximum " - f"condition count of {settings.SEGMENT_RULES_CONDITIONS_LIMIT}." - } - ) - - def _calculate_condition_count( - self, - rules_data: dict[str, object], - ) -> None: - count: int = 0 - - for rule_data in rules_data: - child_rules = rule_data.get("rules", []) - if child_rules: - count += self._calculate_condition_count(child_rules) - conditions = rule_data.get("conditions", []) - for condition in conditions: - if condition.get("delete", False) is True: - continue - count += 1 - return count - - def _update_segment_rules(self, rules_data, segment=None): - """ - Since we don't have a unique identifier for the rules / conditions for the update, we assume that the client - passes up the new configuration for the rules of the segment and simply wipe the old ones and create new ones - """ - # traverse the rules / conditions tree - if no ids are provided, then maintain the previous behaviour (clear - # existing rules and create the ones that were sent) - # note: we do this to preserve backwards compatibility after adding logic to include the id in requests - if not Segment.id_exists_in_rules_data(rules_data): - segment.rules.set([]) - - self._update_or_create_segment_rules(rules_data, segment=segment) - - def _update_or_create_segment_rules( - self, rules_data, segment=None, rule=None, is_create: bool = False - ): - if all(x is None for x in {segment, rule}): - raise RuntimeError("Can't create rule without parent segment or rule") - - for rule_data in rules_data: - child_rules = rule_data.pop("rules", []) - conditions = rule_data.pop("conditions", []) - - child_rule = self._update_or_create_segment_rule( - rule_data, segment=segment, rule=rule - ) - if not child_rule: - # child rule was deleted - continue - - self._update_or_create_conditions( - conditions, child_rule, is_create=is_create - ) - - self._update_or_create_segment_rules( - child_rules, rule=child_rule, is_create=is_create - ) - - def _update_or_create_metadata( - self, metadata_data: typing.Dict, segment: typing.Optional[Segment] = None - ) -> None: - if len(metadata_data) == 0: - Metadata.objects.filter(object_id=segment.id).delete() - return - if metadata_data is not None: - for metadata_item in metadata_data: - metadata_model_field = metadata_item.pop("model_field", None) - if metadata_item.get("delete"): - Metadata.objects.filter(model_field=metadata_model_field).delete() - continue - - Metadata.objects.update_or_create( - model_field=metadata_model_field, - defaults={ - **metadata_item, - "content_type": ContentType.objects.get_for_model(Segment), - "object_id": segment.id, - }, - ) - - @staticmethod - def _update_or_create_segment_rule( - rule_data: dict, segment: Segment = None, rule: SegmentRule = None - ) -> typing.Optional[SegmentRule]: - rule_id = rule_data.pop("id", None) - if rule_data.get("delete"): - SegmentRule.objects.filter(id=rule_id).delete() - return - - segment_rule, _ = SegmentRule.objects.update_or_create( - id=rule_id, defaults={"segment": segment, "rule": rule, **rule_data} - ) - return segment_rule - - @staticmethod - def _update_or_create_conditions(conditions_data, rule, is_create: bool = False): - for condition in conditions_data: - condition_id = condition.pop("id", None) - if condition.get("delete"): - Condition.objects.filter(id=condition_id).delete() - continue - Condition.objects.update_or_create( - id=condition_id, - defaults={**condition, "created_with_segment": is_create, "rule": rule}, - ) +from segments.models import Segment class SegmentSerializerBasic(serializers.ModelSerializer): diff --git a/api/segments/views.py b/api/segments/views.py index c64e949d529a..c740ef90e6c4 100644 --- a/api/segments/views.py +++ b/api/segments/views.py @@ -1,5 +1,7 @@ import logging +from common.projects.permissions import VIEW_PROJECT +from common.segments.serializers import SegmentSerializer from django.utils.decorators import method_decorator from drf_yasg.utils import swagger_auto_schema from rest_framework import viewsets @@ -17,11 +19,10 @@ SegmentAssociatedFeatureStateSerializer, ) from features.versioning.models import EnvironmentFeatureVersion -from projects.permissions import VIEW_PROJECT from .models import Segment from .permissions import SegmentPermissions -from .serializers import SegmentListQuerySerializer, SegmentSerializer +from .serializers import SegmentListQuerySerializer logger = logging.getLogger() @@ -44,7 +45,7 @@ def get_queryset(self): ) project = get_object_or_404(permitted_projects, pk=self.kwargs["project_pk"]) - queryset = project.segments.all() + queryset = Segment.live_objects.filter(project=project) if self.action == "list": # TODO: at the moment, the UI only shows the name and description of the segment in the list view. @@ -121,7 +122,7 @@ def associated_features(self, request, *args, **kwargs): @api_view(["GET"]) def get_segment_by_uuid(request, uuid): accessible_projects = request.user.get_permitted_projects(VIEW_PROJECT) - qs = Segment.objects.filter(project__in=accessible_projects) + qs = Segment.live_objects.filter(project__in=accessible_projects) segment = get_object_or_404(qs, uuid=uuid) serializer = SegmentSerializer(instance=segment) return Response(serializer.data) diff --git a/api/tests/unit/audit/conftest.py b/api/tests/unit/audit/conftest.py index 93b0741394a4..605341072cc4 100644 --- a/api/tests/unit/audit/conftest.py +++ b/api/tests/unit/audit/conftest.py @@ -1,12 +1,12 @@ import typing import pytest as pytest +from common.projects.permissions import VIEW_AUDIT_LOG from django.db.models import Model from organisations.models import OrganisationRole from permissions.models import PermissionModel from projects.models import Project, UserProjectPermission -from projects.permissions import VIEW_AUDIT_LOG @pytest.fixture() diff --git a/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py b/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py index 6aaea120e978..8f0d3c5c1a87 100644 --- a/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py +++ b/api/tests/unit/edge_api/identities/test_edge_api_identities_views.py @@ -1,3 +1,8 @@ +from common.environments.permissions import ( + MANAGE_IDENTITIES, + VIEW_ENVIRONMENT, + VIEW_IDENTITIES, +) from django.urls import reverse from pytest_mock import MockerFixture from rest_framework import status @@ -7,11 +12,6 @@ from edge_api.identities.models import EdgeIdentity from edge_api.identities.views import EdgeIdentityViewSet from environments.models import Environment -from environments.permissions.constants import ( - MANAGE_IDENTITIES, - VIEW_ENVIRONMENT, - VIEW_IDENTITIES, -) from environments.permissions.permissions import NestedEnvironmentPermissions from features.models import Feature from tests.types import WithEnvironmentPermissionsCallable diff --git a/api/tests/unit/edge_api/identities/test_permissions.py b/api/tests/unit/edge_api/identities/test_permissions.py index 32361dcfa4ee..6bb7c4de592d 100644 --- a/api/tests/unit/edge_api/identities/test_permissions.py +++ b/api/tests/unit/edge_api/identities/test_permissions.py @@ -1,7 +1,8 @@ +from common.environments.permissions import UPDATE_FEATURE_STATE + from edge_api.identities.permissions import ( EdgeIdentityWithIdentifierViewPermissions, ) -from environments.permissions.constants import UPDATE_FEATURE_STATE def test_edge_identity_with_identifier_view_permissions_has_permissions_calls_has_environment_permission( diff --git a/api/tests/unit/environments/helpers.py b/api/tests/unit/environments/helpers.py index bf55c8ffab51..c6e89569c79b 100644 --- a/api/tests/unit/environments/helpers.py +++ b/api/tests/unit/environments/helpers.py @@ -1,5 +1,6 @@ import typing +from common.projects.permissions import VIEW_PROJECT from rest_framework.test import APIClient from environments.models import Environment @@ -8,7 +9,6 @@ UserEnvironmentPermission, ) from projects.models import ProjectPermissionModel, UserProjectPermission -from projects.permissions import VIEW_PROJECT from users.models import FFAdminUser diff --git a/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py b/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py index a2d3c3185c03..eae6add8bcd5 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py +++ b/api/tests/unit/environments/identities/test_unit_identities_feature_states_views.py @@ -1,6 +1,10 @@ import json import pytest +from common.environments.permissions import ( + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, +) from core.constants import STRING from django.test import Client from django.urls import reverse @@ -8,10 +12,6 @@ from environments.identities.models import Identity from environments.models import Environment -from environments.permissions.constants import ( - UPDATE_FEATURE_STATE, - VIEW_ENVIRONMENT, -) from features.models import Feature, FeatureState, FeatureStateValue from features.multivariate.models import ( MultivariateFeatureOption, diff --git a/api/tests/unit/environments/identities/test_unit_identities_views.py b/api/tests/unit/environments/identities/test_unit_identities_views.py index b1e31cdb50b2..e62c12dbf88e 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_views.py +++ b/api/tests/unit/environments/identities/test_unit_identities_views.py @@ -4,6 +4,7 @@ from unittest import mock import pytest +from common.environments.permissions import MANAGE_IDENTITIES, VIEW_IDENTITIES from core.constants import FLAGSMITH_UPDATED_AT_HEADER, STRING from django.test import override_settings from django.urls import reverse @@ -21,10 +22,6 @@ from environments.identities.traits.models import Trait from environments.identities.views import IdentityViewSet from environments.models import Environment, EnvironmentAPIKey -from environments.permissions.constants import ( - MANAGE_IDENTITIES, - VIEW_IDENTITIES, -) from environments.permissions.permissions import NestedEnvironmentPermissions from features.models import Feature, FeatureSegment, FeatureState from integrations.amplitude.models import AmplitudeConfiguration diff --git a/api/tests/unit/environments/identities/traits/test_traits_views.py b/api/tests/unit/environments/identities/traits/test_traits_views.py index 321b76915463..61831fe93871 100644 --- a/api/tests/unit/environments/identities/traits/test_traits_views.py +++ b/api/tests/unit/environments/identities/traits/test_traits_views.py @@ -1,6 +1,12 @@ import json from unittest import mock +from common.environments.permissions import ( + MANAGE_IDENTITIES, + VIEW_ENVIRONMENT, + VIEW_IDENTITIES, +) +from common.projects.permissions import VIEW_PROJECT from core.constants import INTEGER, STRING from django.test import override_settings from django.urls import reverse @@ -15,17 +21,11 @@ from environments.identities.traits.models import Trait from environments.identities.traits.views import TraitViewSet from environments.models import Environment, EnvironmentAPIKey -from environments.permissions.constants import ( - MANAGE_IDENTITIES, - VIEW_ENVIRONMENT, - VIEW_IDENTITIES, -) from environments.permissions.models import UserEnvironmentPermission from environments.permissions.permissions import NestedEnvironmentPermissions from organisations.models import Organisation from permissions.models import PermissionModel from projects.models import Project, UserProjectPermission -from projects.permissions import VIEW_PROJECT def test_can_set_trait_for_an_identity( diff --git a/api/tests/unit/environments/permissions/test_unit_environments_permissions.py b/api/tests/unit/environments/permissions/test_unit_environments_permissions.py index 8c36ea8cabe5..f1fbcddbbf45 100644 --- a/api/tests/unit/environments/permissions/test_unit_environments_permissions.py +++ b/api/tests/unit/environments/permissions/test_unit_environments_permissions.py @@ -1,5 +1,7 @@ from unittest import mock +from common.projects.permissions import CREATE_ENVIRONMENT + from environments.identities.models import Identity from environments.models import Environment from environments.permissions.models import UserEnvironmentPermission @@ -13,7 +15,6 @@ ProjectPermissionModel, UserProjectPermission, ) -from projects.permissions import CREATE_ENVIRONMENT from users.models import FFAdminUser mock_view = mock.MagicMock() diff --git a/api/tests/unit/environments/permissions/test_unit_environments_permissions_migrations.py b/api/tests/unit/environments/permissions/test_unit_environments_permissions_migrations.py index 6a6a33b726e9..3cc3b0b557f6 100644 --- a/api/tests/unit/environments/permissions/test_unit_environments_permissions_migrations.py +++ b/api/tests/unit/environments/permissions/test_unit_environments_permissions_migrations.py @@ -1,13 +1,12 @@ import pytest -from django.conf import settings - -from environments.permissions.constants import ( +from common.environments.permissions import ( APPROVE_CHANGE_REQUEST, CREATE_CHANGE_REQUEST, MANAGE_IDENTITIES, UPDATE_FEATURE_STATE, VIEW_IDENTITIES, ) +from django.conf import settings if settings.SKIP_MIGRATION_TESTS is True: pytest.skip( diff --git a/api/tests/unit/environments/permissions/test_unit_environments_views.py b/api/tests/unit/environments/permissions/test_unit_environments_views.py index 9001397f57d5..78e5847f1fac 100644 --- a/api/tests/unit/environments/permissions/test_unit_environments_views.py +++ b/api/tests/unit/environments/permissions/test_unit_environments_views.py @@ -1,12 +1,12 @@ import json import pytest +from common.environments.permissions import VIEW_ENVIRONMENT from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient from environments.models import Environment -from environments.permissions.constants import VIEW_ENVIRONMENT from environments.permissions.models import ( EnvironmentPermissionModel, UserEnvironmentPermission, diff --git a/api/tests/unit/environments/test_unit_environments_feature_states_views.py b/api/tests/unit/environments/test_unit_environments_feature_states_views.py index ec335dcf8cb7..205c189ab899 100644 --- a/api/tests/unit/environments/test_unit_environments_feature_states_views.py +++ b/api/tests/unit/environments/test_unit_environments_feature_states_views.py @@ -1,13 +1,13 @@ import json import pytest -from django.urls import reverse -from rest_framework import status - -from environments.permissions.constants import ( +from common.environments.permissions import ( UPDATE_FEATURE_STATE, VIEW_ENVIRONMENT, ) +from django.urls import reverse +from rest_framework import status + from tests.unit.environments.helpers import get_environment_user_client diff --git a/api/tests/unit/environments/test_unit_environments_views.py b/api/tests/unit/environments/test_unit_environments_views.py index d3b84a1d2880..bbe4102330f7 100644 --- a/api/tests/unit/environments/test_unit_environments_views.py +++ b/api/tests/unit/environments/test_unit_environments_views.py @@ -2,6 +2,11 @@ from unittest import mock import pytest +from common.environments.permissions import ( + TAG_SUPPORTED_PERMISSIONS, + VIEW_ENVIRONMENT, +) +from common.projects.permissions import CREATE_ENVIRONMENT from core.constants import STRING from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -18,17 +23,12 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment, EnvironmentAPIKey, Webhook -from environments.permissions.constants import ( - TAG_SUPPORTED_PERMISSIONS, - VIEW_ENVIRONMENT, -) from environments.permissions.models import UserEnvironmentPermission from features.models import Feature, FeatureState from features.versioning.models import EnvironmentFeatureVersion from metadata.models import Metadata, MetadataModelField from organisations.models import Organisation from projects.models import Project -from projects.permissions import CREATE_ENVIRONMENT from segments.models import Condition, Segment, SegmentRule from tests.types import WithEnvironmentPermissionsCallable from users.models import FFAdminUser diff --git a/api/tests/unit/environments/test_unit_environments_views_sdk_environment.py b/api/tests/unit/environments/test_unit_environments_views_sdk_environment.py index c14b976d5bcd..6726b67e3840 100644 --- a/api/tests/unit/environments/test_unit_environments_views_sdk_environment.py +++ b/api/tests/unit/environments/test_unit_environments_views_sdk_environment.py @@ -17,24 +17,27 @@ FeatureStateValue, ) from features.multivariate.models import MultivariateFeatureOption +from projects.models import Project from segments.models import Condition, Segment, SegmentRule if TYPE_CHECKING: from pytest_django import DjangoAssertNumQueries from organisations.models import Organisation - from projects.models import Project def test_get_environment_document( organisation_one: "Organisation", + organisation_two: "Organisation", organisation_one_project_one: "Project", django_assert_num_queries: "DjangoAssertNumQueries", ) -> None: # Given project = organisation_one_project_one + project2 = Project.objects.create( + name="standin_project", organisation=organisation_two + ) - # an environment environment = Environment.objects.create(name="Test Environment", project=project) api_key = EnvironmentAPIKey.objects.create(environment=environment) client = APIClient() @@ -44,6 +47,21 @@ def test_get_environment_document( feature = Feature.objects.create(name="test_feature", project=project) for i in range(10): segment = Segment.objects.create(project=project) + + # Create a shallow clone which should not be returned in the document. + segment.shallow_clone( + name=f"disregarded-clone-{i}", + description=f"some-disregarded-clone-{i}", + change_request=None, + ) + + # Create some other segments to ensure that the segments manager was + # properly set. + Segment.objects.create( + project=project2, + name=f"standin_segment{i}", + description=f"Should not be selected {i}", + ) segment_rule = SegmentRule.objects.create( segment=segment, type=SegmentRule.ALL_RULE ) @@ -114,6 +132,7 @@ def test_get_environment_document( # Then assert response.status_code == status.HTTP_200_OK assert response.json() + assert len(response.data["project"]["segments"]) == 10 assert response.headers[FLAGSMITH_UPDATED_AT_HEADER] == str( environment.updated_at.timestamp() ) diff --git a/api/tests/unit/features/feature_segments/test_unit_feature_segments_views.py b/api/tests/unit/features/feature_segments/test_unit_feature_segments_views.py index f6a68460015e..0e984f2ae543 100644 --- a/api/tests/unit/features/feature_segments/test_unit_feature_segments_views.py +++ b/api/tests/unit/features/feature_segments/test_unit_feature_segments_views.py @@ -1,6 +1,12 @@ import json import pytest +from common.environments.permissions import ( + MANAGE_SEGMENT_OVERRIDES, + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, +) +from common.projects.permissions import VIEW_PROJECT from django.conf import settings from django.urls import reverse from pytest_django import DjangoAssertNumQueries @@ -12,15 +18,9 @@ from audit.models import AuditLog from audit.related_object_type import RelatedObjectType from environments.models import Environment -from environments.permissions.constants import ( - MANAGE_SEGMENT_OVERRIDES, - UPDATE_FEATURE_STATE, - VIEW_ENVIRONMENT, -) from features.models import Feature, FeatureSegment, FeatureState from features.versioning.models import EnvironmentFeatureVersion from projects.models import Project, UserProjectPermission -from projects.permissions import VIEW_PROJECT from segments.models import Segment from tests.types import ( WithEnvironmentPermissionsCallable, diff --git a/api/tests/unit/features/import_export/test_unit_features_import_export_views.py b/api/tests/unit/features/import_export/test_unit_features_import_export_views.py index 7c45edbd4580..55dd5551674d 100644 --- a/api/tests/unit/features/import_export/test_unit_features_import_export_views.py +++ b/api/tests/unit/features/import_export/test_unit_features_import_export_views.py @@ -1,6 +1,7 @@ import json import pytest +from common.projects.permissions import VIEW_PROJECT from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from pytest_django.fixtures import SettingsWrapper @@ -16,7 +17,6 @@ FlagsmithOnFlagsmithFeatureExport, ) from projects.models import Project -from projects.permissions import VIEW_PROJECT from projects.tags.models import Tag from tests.types import WithProjectPermissionsCallable from users.models import FFAdminUser diff --git a/api/tests/unit/features/multivariate/test_unit_multivariate_views.py b/api/tests/unit/features/multivariate/test_unit_multivariate_views.py index 89f2b355b6c8..84b7c53294cf 100644 --- a/api/tests/unit/features/multivariate/test_unit_multivariate_views.py +++ b/api/tests/unit/features/multivariate/test_unit_multivariate_views.py @@ -1,16 +1,13 @@ import uuid import pytest +from common.projects.permissions import CREATE_FEATURE, VIEW_PROJECT from django.urls import reverse from pytest_lazyfixture import lazy_fixture from rest_framework import status from features.multivariate.views import MultivariateFeatureOptionViewSet -from projects.permissions import ( - CREATE_FEATURE, - VIEW_PROJECT, - NestedProjectPermissions, -) +from projects.permissions import NestedProjectPermissions def test_multivariate_feature_options_view_set_get_permissions(): diff --git a/api/tests/unit/features/test_unit_feature_external_resources_views.py b/api/tests/unit/features/test_unit_feature_external_resources_views.py index 370c25ec1bd9..8d2af473f346 100644 --- a/api/tests/unit/features/test_unit_feature_external_resources_views.py +++ b/api/tests/unit/features/test_unit_feature_external_resources_views.py @@ -4,6 +4,7 @@ import pytest import responses import simplejson as json +from common.environments.permissions import UPDATE_FEATURE_STATE from django.core.serializers.json import DjangoJSONEncoder from django.urls import reverse from django.utils.formats import get_format @@ -12,7 +13,6 @@ from rest_framework.test import APIClient from environments.models import Environment -from environments.permissions.constants import UPDATE_FEATURE_STATE from features.feature_external_resources.models import FeatureExternalResource from features.models import Feature, FeatureSegment, FeatureState from features.serializers import ( diff --git a/api/tests/unit/features/test_unit_features_permissions.py b/api/tests/unit/features/test_unit_features_permissions.py index e1dc4f864e64..46f437bbaa40 100644 --- a/api/tests/unit/features/test_unit_features_permissions.py +++ b/api/tests/unit/features/test_unit_features_permissions.py @@ -2,6 +2,11 @@ from unittest.mock import MagicMock import pytest +from common.projects.permissions import ( + CREATE_FEATURE, + DELETE_FEATURE, + VIEW_PROJECT, +) from features.models import Feature from features.permissions import FeaturePermissions @@ -12,12 +17,7 @@ UserPermissionGroupProjectPermission, UserProjectPermission, ) -from projects.permissions import ( - CREATE_FEATURE, - DELETE_FEATURE, - VIEW_PROJECT, - NestedProjectPermissions, -) +from projects.permissions import NestedProjectPermissions from tests.types import WithProjectPermissionsCallable from users.models import FFAdminUser, UserPermissionGroup diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 02705c03c8ef..248ecf656d3d 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -6,6 +6,12 @@ import pytest import pytz from app_analytics.dataclasses import FeatureEvaluationData +from common.environments.permissions import ( + MANAGE_SEGMENT_OVERRIDES, + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, +) +from common.projects.permissions import CREATE_FEATURE, VIEW_PROJECT from core.constants import FLAGSMITH_UPDATED_AT_HEADER from django.conf import settings from django.forms import model_to_dict @@ -27,11 +33,6 @@ from audit.models import AuditLog, RelatedObjectType from environments.identities.models import Identity from environments.models import Environment, EnvironmentAPIKey -from environments.permissions.constants import ( - MANAGE_SEGMENT_OVERRIDES, - UPDATE_FEATURE_STATE, - VIEW_ENVIRONMENT, -) from environments.permissions.models import UserEnvironmentPermission from features.feature_types import MULTIVARIATE from features.models import Feature, FeatureSegment, FeatureState @@ -41,7 +42,6 @@ from metadata.models import MetadataModelField from organisations.models import Organisation, OrganisationRole from projects.models import Project, UserProjectPermission -from projects.permissions import CREATE_FEATURE, VIEW_PROJECT from projects.tags.models import Tag from segments.models import Segment from tests.types import ( diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index e0da742be6c0..7eba620a1b06 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -3,6 +3,11 @@ from datetime import datetime, timedelta import pytest +from common.environments.permissions import ( + UPDATE_FEATURE_STATE, + VIEW_ENVIRONMENT, +) +from common.projects.permissions import VIEW_PROJECT from core.constants import STRING from django.urls import reverse from django.utils import timezone @@ -17,10 +22,6 @@ from audit.models import AuditLog from audit.related_object_type import RelatedObjectType from environments.models import Environment -from environments.permissions.constants import ( - UPDATE_FEATURE_STATE, - VIEW_ENVIRONMENT, -) from features.feature_segments.limits import ( SEGMENT_OVERRIDE_LIMIT_EXCEEDED_MESSAGE, ) @@ -34,7 +35,6 @@ ) from organisations.subscriptions.constants import SubscriptionPlanFamily from projects.models import Project -from projects.permissions import VIEW_PROJECT from segments.models import Segment from tests.types import ( WithEnvironmentPermissionsCallable, diff --git a/api/tests/unit/features/workflows/core/test_unit_workflows_migrations.py b/api/tests/unit/features/workflows/core/test_unit_workflows_migrations.py new file mode 100644 index 000000000000..38736946e5a8 --- /dev/null +++ b/api/tests/unit/features/workflows/core/test_unit_workflows_migrations.py @@ -0,0 +1,40 @@ +import pytest +from django.conf import settings +from django_test_migrations.migrator import Migrator + + +@pytest.mark.skipif( + settings.SKIP_MIGRATION_TESTS is True, + reason="Skip migration tests to speed up tests where necessary", +) +def test_migrate_add_project_to_change_request(migrator: Migrator) -> None: + old_state = migrator.apply_initial_migration( + ("workflows_core", "0010_add_ignore_conflicts_option"), + ) + OldOrganisation = old_state.apps.get_model("organisations", "Organisation") + OldProject = old_state.apps.get_model("projects", "Project") + OldEnvironment = old_state.apps.get_model("environments", "Environment") + OldFFAdminUser = old_state.apps.get_model("users", "FFAdminUser") + OldChangeRequest = old_state.apps.get_model("workflows_core", "ChangeRequest") + + organisation = OldOrganisation.objects.create(name="Test Org") + project = OldProject.objects.create(name="Test Project", organisation=organisation) + environment = OldEnvironment.objects.create( + name="Test Environment", project=project + ) + user = OldFFAdminUser.objects.create(email="staff@example.co") + change_request = OldChangeRequest.objects.create( + environment=environment, title="Test CR", user_id=user.id + ) + + assert hasattr(change_request, "project") is False + + # When + new_state = migrator.apply_tested_migration( + ("workflows_core", "0011_add_project_to_change_requests") + ) + + # Then + NewChangeRequest = new_state.apps.get_model("workflows_core", "ChangeRequest") + new_change_request = NewChangeRequest.objects.get(id=change_request.id) + assert new_change_request.project_id == project.id diff --git a/api/tests/unit/features/workflows/core/test_unit_workflows_models.py b/api/tests/unit/features/workflows/core/test_unit_workflows_models.py index 447394c2793c..37edd3e7059a 100644 --- a/api/tests/unit/features/workflows/core/test_unit_workflows_models.py +++ b/api/tests/unit/features/workflows/core/test_unit_workflows_models.py @@ -3,10 +3,11 @@ import freezegun import pytest +from core.helpers import get_current_site_url from django.contrib.sites.models import Site from django.db.models import Q from django.utils import timezone -from flag_engine.segments.constants import PERCENTAGE_SPLIT +from flag_engine.segments.constants import EQUAL, PERCENTAGE_SPLIT from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture @@ -744,6 +745,26 @@ def test_committing_change_request_with_environment_feature_versions_creates_pub ).exists() +def test_retrieving_segments( + change_request: ChangeRequest, +) -> None: + # Given + base_segment = Segment.objects.create( + name="Base Segment", + description="Segment description", + project=change_request.environment.project, + ) + + # When + segment = base_segment.shallow_clone( + name="New Name", description="New description", change_request=change_request + ) + + # Then + assert change_request.segments.count() == 1 + assert change_request.segments.first() == segment + + def test_change_request_live_from_for_change_request_with_change_set( feature: Feature, environment_v2_versioning: Environment, @@ -781,6 +802,65 @@ def test_change_request_live_from_for_change_request_with_change_set( assert change_request.live_from == now +def test_publishing_segments_as_part_of_commit( + segment: Segment, + change_request: ChangeRequest, + admin_user: FFAdminUser, +) -> None: + # Given + assert segment.version == 2 + cr_segment = segment.shallow_clone("Test Name", "Test Description", change_request) + assert cr_segment.rules.count() == 0 + + # Add some rules that the original segment will be cloning from + parent_rule = SegmentRule.objects.create( + segment=cr_segment, type=SegmentRule.ALL_RULE + ) + + child_rule1 = SegmentRule.objects.create( + rule=parent_rule, type=SegmentRule.ANY_RULE + ) + child_rule2 = SegmentRule.objects.create( + rule=parent_rule, type=SegmentRule.NONE_RULE + ) + Condition.objects.create( + rule=child_rule1, + property="child_rule1", + operator=EQUAL, + value="condition1", + created_with_segment=True, + ) + Condition.objects.create( + rule=child_rule2, + property="child_rule2", + operator=PERCENTAGE_SPLIT, + value="0.2", + created_with_segment=False, + ) + + # When + change_request.commit(admin_user) + + # Then + segment.refresh_from_db() + assert segment.version == 3 + assert segment.name == "Test Name" + assert segment.description == "Test Description" + assert segment.rules.count() == 1 + parent_rule2 = segment.rules.first() + assert parent_rule2.type == SegmentRule.ALL_RULE + assert parent_rule2.rules.count() == 2 + child_rule3, child_rule4 = list(parent_rule2.rules.all()) + assert child_rule3.type == SegmentRule.ANY_RULE + assert child_rule4.type == SegmentRule.NONE_RULE + assert child_rule3.conditions.count() == 1 + assert child_rule4.conditions.count() == 1 + condition1 = child_rule3.conditions.first() + condition2 = child_rule4.conditions.first() + assert condition1.value == "condition1" + assert condition2.value == "0.2" + + def test_ignore_conflicts_for_multiple_scheduled_change_requests( feature: Feature, environment_v2_versioning: Environment, @@ -897,3 +977,31 @@ def _create_segment(percentage_value: int) -> Segment: ) assert len(after_cr_1_flags) == 1 assert after_cr_1_flags[0].feature_segment.segment == twenty_percent_segment + + +def test_approval_via_project(project_change_request: ChangeRequest) -> None: + # Given - The project change request fixture + assert project_change_request.environment is None + assert project_change_request.project.minimum_change_request_approvals is None + + # When + is_approved = project_change_request.is_approved() + + # Then + assert is_approved is True + + +def test_url_via_project(project_change_request: ChangeRequest) -> None: + # Given + assert project_change_request.environment is None + + # When + url = project_change_request.url + + # Then + project_id = project_change_request.project_id + expected_url = get_current_site_url() + expected_url += ( + f"/projects/{project_id}/change-requests/{project_change_request.id}" + ) + assert url == expected_url diff --git a/api/tests/unit/metadata/test_serializers.py b/api/tests/unit/metadata/test_serializers.py index 3603288943bb..eb0ee7f4434c 100644 --- a/api/tests/unit/metadata/test_serializers.py +++ b/api/tests/unit/metadata/test_serializers.py @@ -1,11 +1,11 @@ import pytest +from common.metadata.serializers import MetadataSerializer from metadata.models import ( FIELD_VALUE_MAX_LENGTH, MetadataField, MetadataModelField, ) -from metadata.serializers import MetadataSerializer @pytest.mark.parametrize( diff --git a/api/tests/unit/permissions/permission_service/test_get_permitted_environments_for_user.py b/api/tests/unit/permissions/permission_service/test_get_permitted_environments_for_user.py index 0383b98d7e98..6e2a5d442f50 100644 --- a/api/tests/unit/permissions/permission_service/test_get_permitted_environments_for_user.py +++ b/api/tests/unit/permissions/permission_service/test_get_permitted_environments_for_user.py @@ -1,11 +1,11 @@ import pytest -from pytest_lazyfixture import lazy_fixture - -from environments.permissions.constants import ( +from common.environments.permissions import ( MANAGE_IDENTITIES, UPDATE_FEATURE_STATE, VIEW_ENVIRONMENT, ) +from pytest_lazyfixture import lazy_fixture + from environments.permissions.models import EnvironmentPermissionModel from permissions.permission_service import get_permitted_environments_for_user diff --git a/api/tests/unit/permissions/permission_service/test_get_permitted_projects_for_user.py b/api/tests/unit/permissions/permission_service/test_get_permitted_projects_for_user.py index 2af30708882c..5820fde1bed2 100644 --- a/api/tests/unit/permissions/permission_service/test_get_permitted_projects_for_user.py +++ b/api/tests/unit/permissions/permission_service/test_get_permitted_projects_for_user.py @@ -1,13 +1,13 @@ import pytest -from pytest_lazyfixture import lazy_fixture - -from permissions.permission_service import get_permitted_projects_for_user -from projects.models import ProjectPermissionModel -from projects.permissions import ( +from common.projects.permissions import ( CREATE_ENVIRONMENT, DELETE_FEATURE, VIEW_PROJECT, ) +from pytest_lazyfixture import lazy_fixture + +from permissions.permission_service import get_permitted_projects_for_user +from projects.models import ProjectPermissionModel def test_get_permitted_projects_for_user_returns_all_projects_for_org_admin( diff --git a/api/tests/unit/permissions/test_unit_permissions_calculator.py b/api/tests/unit/permissions/test_unit_permissions_calculator.py index 76a240d6dfa7..cd89119b03de 100644 --- a/api/tests/unit/permissions/test_unit_permissions_calculator.py +++ b/api/tests/unit/permissions/test_unit_permissions_calculator.py @@ -1,9 +1,10 @@ import pytest - -from environments.permissions.constants import ( +from common.environments.permissions import ( UPDATE_FEATURE_STATE, VIEW_ENVIRONMENT, ) +from common.projects.permissions import CREATE_ENVIRONMENT, VIEW_PROJECT + from environments.permissions.models import ( EnvironmentPermissionModel, UserEnvironmentPermission, @@ -29,7 +30,6 @@ UserPermissionGroupProjectPermission, UserProjectPermission, ) -from projects.permissions import CREATE_ENVIRONMENT, VIEW_PROJECT from users.models import UserPermissionGroup diff --git a/api/tests/unit/projects/tags/test_unit_projects_tags_permissions.py b/api/tests/unit/projects/tags/test_unit_projects_tags_permissions.py index e79c96828e1f..46121e7c4d28 100644 --- a/api/tests/unit/projects/tags/test_unit_projects_tags_permissions.py +++ b/api/tests/unit/projects/tags/test_unit_projects_tags_permissions.py @@ -1,7 +1,8 @@ from unittest import mock +from common.projects.permissions import MANAGE_TAGS, VIEW_PROJECT + from projects.models import Project -from projects.permissions import MANAGE_TAGS, VIEW_PROJECT from projects.tags.models import Tag from projects.tags.permissions import TagPermissions from tests.types import WithProjectPermissionsCallable diff --git a/api/tests/unit/projects/tags/test_unit_projects_tags_views.py b/api/tests/unit/projects/tags/test_unit_projects_tags_views.py index d1942cdbb6f6..f278e6821ca1 100644 --- a/api/tests/unit/projects/tags/test_unit_projects_tags_views.py +++ b/api/tests/unit/projects/tags/test_unit_projects_tags_views.py @@ -1,13 +1,13 @@ import json import pytest +from common.projects.permissions import VIEW_PROJECT from django.urls import reverse from pytest_lazyfixture import lazy_fixture from rest_framework import status from rest_framework.test import APIClient from projects.models import Project -from projects.permissions import VIEW_PROJECT from projects.tags.models import Tag from tests.types import WithProjectPermissionsCallable diff --git a/api/tests/unit/projects/test_migrations.py b/api/tests/unit/projects/test_migrations.py index 23c51b363c6d..20f3ef4b19dd 100644 --- a/api/tests/unit/projects/test_migrations.py +++ b/api/tests/unit/projects/test_migrations.py @@ -1,8 +1,7 @@ import pytest +from common.projects.permissions import CREATE_ENVIRONMENT, VIEW_PROJECT from django.conf import settings -from projects.permissions import CREATE_ENVIRONMENT, VIEW_PROJECT - @pytest.mark.skipif( settings.SKIP_MIGRATION_TESTS is True, diff --git a/api/tests/unit/projects/test_unit_projects_models.py b/api/tests/unit/projects/test_unit_projects_models.py index a377fa4bae10..6448e190e864 100644 --- a/api/tests/unit/projects/test_unit_projects_models.py +++ b/api/tests/unit/projects/test_unit_projects_models.py @@ -8,6 +8,7 @@ from organisations.models import Organisation from projects.models import EdgeV2MigrationStatus, Project +from segments.models import Segment now = timezone.now() tomorrow = now + timedelta(days=1) @@ -21,7 +22,7 @@ def test_get_segments_from_cache(project, monkeypatch): mock_project_segments_cache.get.return_value = None monkeypatch.setattr( - "projects.models.project_segments_cache", mock_project_segments_cache + "projects.services.project_segments_cache", mock_project_segments_cache ) # When @@ -41,7 +42,7 @@ def test_get_segments_from_cache_set_not_called(project, segments, monkeypatch): mock_project_segments_cache.get.return_value = project.segments.all() monkeypatch.setattr( - "projects.models.project_segments_cache", mock_project_segments_cache + "projects.services.project_segments_cache", mock_project_segments_cache ) # When @@ -55,6 +56,34 @@ def test_get_segments_from_cache_set_not_called(project, segments, monkeypatch): mock_project_segments_cache.set.assert_not_called() +def test_get_segments_from_cache_set_to_empty_list( + project: Project, + segment: Segment, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given + mock_project_segments_cache = mock.MagicMock() + mock_project_segments_cache.get.return_value = [] + + monkeypatch.setattr( + "projects.services.project_segments_cache", mock_project_segments_cache + ) + + # When + segments = project.get_segments_from_cache() + + # Then + # Since we're calling the live_objects manager in the method, + # only one copy of the segment should be returned, not the + # other versioned copy of the segment. + assert segments.count() == 1 + assert segments.first() == segment + + # And correct calls to cache are made + mock_project_segments_cache.get.assert_called_once_with(project.id) + mock_project_segments_cache.set.assert_called_once() + + @pytest.mark.parametrize( "edge_enabled, expected_enable_dynamo_db_value", ((True, True), (False, False)), diff --git a/api/tests/unit/projects/test_unit_projects_permissions.py b/api/tests/unit/projects/test_unit_projects_permissions.py index f7b874ab0b81..496aab1c0b68 100644 --- a/api/tests/unit/projects/test_unit_projects_permissions.py +++ b/api/tests/unit/projects/test_unit_projects_permissions.py @@ -2,17 +2,14 @@ from unittest import mock import pytest +from common.projects.permissions import VIEW_PROJECT from django.conf import settings from rest_framework.exceptions import APIException, PermissionDenied from organisations.models import Organisation, OrganisationRole from organisations.permissions.permissions import CREATE_PROJECT from projects.models import Project, UserPermissionGroupProjectPermission -from projects.permissions import ( - VIEW_PROJECT, - IsProjectAdmin, - ProjectPermissions, -) +from projects.permissions import IsProjectAdmin, ProjectPermissions from tests.types import ( WithOrganisationPermissionsCallable, WithProjectPermissionsCallable, diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py index ed1d349e5a50..71f078356dbc 100644 --- a/api/tests/unit/projects/test_unit_projects_views.py +++ b/api/tests/unit/projects/test_unit_projects_views.py @@ -2,6 +2,12 @@ from datetime import timedelta import pytest +from common.projects.permissions import ( + CREATE_ENVIRONMENT, + CREATE_FEATURE, + TAG_SUPPORTED_PERMISSIONS, + VIEW_PROJECT, +) from django.urls import reverse from django.utils import timezone from pytest_django.fixtures import SettingsWrapper @@ -26,12 +32,6 @@ UserPermissionGroupProjectPermission, UserProjectPermission, ) -from projects.permissions import ( - CREATE_ENVIRONMENT, - CREATE_FEATURE, - TAG_SUPPORTED_PERMISSIONS, - VIEW_PROJECT, -) from segments.models import Segment from tests.types import WithProjectPermissionsCallable from users.models import FFAdminUser, UserPermissionGroup @@ -176,9 +176,8 @@ def test_can_list_project_permission(client: APIClient, project: Project) -> Non # Then assert response.status_code == status.HTTP_200_OK - assert ( - len(response.json()) == 7 - ) # hard code how many permissions we expect there to be + # Hard code how many permissions we expect there to be. + assert len(response.json()) == 9 returned_supported_permissions = [ permission["key"] diff --git a/api/tests/unit/segments/test_unit_segments_models.py b/api/tests/unit/segments/test_unit_segments_models.py index 351a2fc7cf90..60ae2750e110 100644 --- a/api/tests/unit/segments/test_unit_segments_models.py +++ b/api/tests/unit/segments/test_unit_segments_models.py @@ -336,10 +336,10 @@ def test_manager_returns_only_highest_version_of_segments( assert segment.version == 3 # When - queryset1 = Segment.objects.filter(id=cloned_segment.id) - queryset2 = Segment.all_objects.filter(id=cloned_segment.id) - queryset3 = Segment.objects.filter(id=segment.id) - queryset4 = Segment.all_objects.filter(id=segment.id) + queryset1 = Segment.live_objects.filter(id=cloned_segment.id) + queryset2 = Segment.objects.filter(id=cloned_segment.id) + queryset3 = Segment.live_objects.filter(id=segment.id) + queryset4 = Segment.objects.filter(id=segment.id) # Then assert not queryset1.exists() diff --git a/api/tests/unit/segments/test_unit_segments_permissions.py b/api/tests/unit/segments/test_unit_segments_permissions.py index b338c96b1d9f..5b6f92c8a1cc 100644 --- a/api/tests/unit/segments/test_unit_segments_permissions.py +++ b/api/tests/unit/segments/test_unit_segments_permissions.py @@ -1,11 +1,12 @@ import uuid from unittest import mock +from common.projects.permissions import VIEW_PROJECT + from environments.identities.models import Identity from environments.models import Environment from permissions.models import PermissionModel from projects.models import Project, UserProjectPermission -from projects.permissions import VIEW_PROJECT from segments.models import Segment from segments.permissions import SegmentPermissions from tests.types import ( diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 0f8f3f58e2db..4e29c6d3f1e5 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -2,6 +2,7 @@ import random import pytest +from common.projects.permissions import MANAGE_SEGMENTS, VIEW_PROJECT from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType @@ -22,7 +23,6 @@ from features.versioning.models import EnvironmentFeatureVersion from metadata.models import Metadata, MetadataModelField from projects.models import Project -from projects.permissions import MANAGE_SEGMENTS, VIEW_PROJECT from segments.models import Condition, Segment, SegmentRule, WhitelistedSegment from tests.types import WithProjectPermissionsCallable from util.mappers import map_identity_to_identity_document @@ -689,7 +689,7 @@ def test_update_segment_versioned_segment( # Before updating the segment confirm pre-existing version count which is # automatically set by the fixture. - assert Segment.all_objects.filter(version_of=segment).count() == 2 + assert Segment.objects.filter(version_of=segment).count() == 2 new_condition_property = "foo2" new_condition_value = "bar" @@ -736,13 +736,11 @@ def test_update_segment_versioned_segment( assert response.status_code == status.HTTP_200_OK # Now verify that a new versioned segment has been set. - assert Segment.all_objects.filter(version_of=segment).count() == 3 + assert Segment.objects.filter(version_of=segment).count() == 3 # Now check the previously versioned segment to match former count of conditions. - versioned_segment = Segment.all_objects.filter( - version_of=segment, version=2 - ).first() + versioned_segment = Segment.objects.filter(version_of=segment, version=2).first() assert versioned_segment != segment assert versioned_segment.rules.count() == 1 versioned_rule = versioned_segment.rules.first() @@ -773,9 +771,7 @@ def test_update_segment_versioned_segment_with_thrown_exception( rule=nested_rule, property="foo", operator=EQUAL, value="bar" ) - assert ( - segment.version == 2 == Segment.all_objects.filter(version_of=segment).count() - ) + assert segment.version == 2 == Segment.objects.filter(version_of=segment).count() new_condition_property = "foo2" new_condition_value = "bar" @@ -826,9 +822,7 @@ def test_update_segment_versioned_segment_with_thrown_exception( segment.refresh_from_db() # Now verify that the version of the segment has not been changed. - assert ( - segment.version == 2 == Segment.all_objects.filter(version_of=segment).count() - ) + assert segment.version == 2 == Segment.objects.filter(version_of=segment).count() @pytest.mark.parametrize( diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index fcc17d4a8157..8d4dcf32889d 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -1,11 +1,11 @@ import pytest +from common.projects.permissions import VIEW_PROJECT from django.db.utils import IntegrityError from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission from organisations.permissions.permissions import ORGANISATION_PERMISSIONS from projects.models import Project -from projects.permissions import VIEW_PROJECT from tests.types import WithProjectPermissionsCallable from users.models import FFAdminUser diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 9e111d5748d0..411d4f0ffa3f 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -27,6 +27,7 @@ from environments.constants import IDENTITY_INTEGRATIONS_RELATION_NAMES from features.versioning.models import EnvironmentFeatureVersion +from segments.models import Segment if TYPE_CHECKING: # pragma: no cover from environments.identities.models import Identity, Trait @@ -40,7 +41,7 @@ from integrations.webhook.models import WebhookConfiguration from organisations.models import Organisation from projects.models import Project - from segments.models import Segment, SegmentRule + from segments.models import SegmentRule __all__ = ( @@ -194,7 +195,11 @@ def map_environment_to_engine( organisation: "Organisation" = project.organisation # Read relationships - grab all the data needed from the ORM here. - project_segments: List["Segment"] = project.segments.all() + + project_segments = [ + ps for ps in project.segments.all() if ps.id == ps.version_of_id + ] + project_segment_rules_by_segment_id: Dict[ int, Iterable["SegmentRule"], From c0e3b3f30ee607620592930c3b2c490fc6ef772c Mon Sep 17 00:00:00 2001 From: Gagan Date: Fri, 22 Nov 2024 14:10:40 +0530 Subject: [PATCH 27/77] deps: bump rbac to support tags on permission level (#4860) --- api/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/Makefile b/api/Makefile index 1edac404dedf..eca334728e26 100644 --- a/api/Makefile +++ b/api/Makefile @@ -12,7 +12,7 @@ POETRY_VERSION ?= 1.8.3 GUNICORN_LOGGER_CLASS ?= util.logging.GunicornJsonCapableLogger SAML_REVISION ?= v1.6.4 -RBAC_REVISION ?= v0.10.0 +RBAC_REVISION ?= v0.11.0 -include .env-local -include $(DOTENV_OVERRIDE_FILE) From 94a7f49b9cd229ac6814a28c52c16f6bf40e5a6f Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 26 Nov 2024 09:09:37 +0000 Subject: [PATCH 28/77] chore(admin): minor improvements (#4862) --- api/organisations/admin.py | 70 +++++++++++++++++++++++++++++++++++++- api/poetry.lock | 30 +++------------- api/pyproject.toml | 2 +- 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/api/organisations/admin.py b/api/organisations/admin.py index 4fe6260ef5b2..f8de3f3563be 100644 --- a/api/organisations/admin.py +++ b/api/organisations/admin.py @@ -4,7 +4,12 @@ from django.contrib import admin from django.db.models import Count, Q -from organisations.models import Organisation, Subscription, UserOrganisation +from organisations.models import ( + Organisation, + OrganisationSubscriptionInformationCache, + Subscription, + UserOrganisation, +) from projects.models import Project @@ -13,6 +18,8 @@ class ProjectInline(admin.StackedInline): extra = 0 show_change_link = True + classes = ("collapse",) + class SubscriptionInline(admin.StackedInline): model = Subscription @@ -29,12 +36,73 @@ class UserOrganisationInline(admin.TabularInline): verbose_name_plural = "Users" +class OrganisationSubscriptionInformationCacheInline(admin.StackedInline): + model = OrganisationSubscriptionInformationCache + extra = 0 + show_change_link = False + classes = ("collapse",) + + fieldsets = ( + ( + None, + { + "fields": [], + "description": "This data is relevant in SaaS only. It should all be managed automatically via " + "webhooks from Chargebee and recurring tasks but may need to be edited in certain " + "situtations.", + }, + ), + ( + "Usage Information", + { + "classes": ["collapse"], + "fields": ["api_calls_24h", "api_calls_7d", "api_calls_30d"], + }, + ), + ( + "Billing Information", + { + "classes": ["collapse"], + "fields": [ + "current_billing_term_starts_at", + "current_billing_term_ends_at", + "chargebee_email", + ], + }, + ), + ( + "Allowances", + { + "description": "These fields shouldn't need to be edited, as it should be managed automatically, " + "but sometimes things get out of sync - in which case, we can edit them here.", + "fields": [ + "allowed_seats", + "allowed_30d_api_calls", + "allowed_projects", + "audit_log_visibility_days", + "feature_history_visibility_days", + ], + }, + ), + ) + + readonly_fields = ( + "api_calls_24h", + "api_calls_7d", + "api_calls_30d", + "current_billing_term_starts_at", + "current_billing_term_ends_at", + "chargebee_email", + ) + + @admin.register(Organisation) class OrganisationAdmin(admin.ModelAdmin): inlines = [ ProjectInline, SubscriptionInline, UserOrganisationInline, + OrganisationSubscriptionInformationCacheInline, ] list_display = ( "id", diff --git a/api/poetry.lock b/api/poetry.lock index ba834dbae554..e9192b6f7ef1 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1327,7 +1327,7 @@ sseclient-py = ">=1.8.0,<2.0.0" [[package]] name = "flagsmith-auth-controller" -version = "0.1.1" +version = "0.1.3" description = "Flagsmith auth controller." optional = false python-versions = ">=3.11,<4.0" @@ -1340,8 +1340,8 @@ django-multiselectfield = "0.1.12" [package.source] type = "git" url = "https://github.com/flagsmith/flagsmith-auth-controller" -reference = "v0.1.2" -resolved_reference = "d0f73840b4d5a078077c2bb108458356476d0ee5" +reference = "v0.1.3" +resolved_reference = "303954ba54fd2f402c75806b3b9ba7f2d42aa426" [[package]] name = "flagsmith-common" @@ -2147,16 +2147,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -3327,7 +3317,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -3335,15 +3324,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -3360,7 +3342,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -3368,7 +3349,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -4186,4 +4166,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "df2e02787204f56111c560faa7f7800a09e87e9bdc0b9802660af5bd08aa03ee" +content-hash = "259c8cdd1b9facb619ee08d1c46926df7fab9bf01bee4c768bbd79499e290a79" diff --git a/api/pyproject.toml b/api/pyproject.toml index 90a75efd26d8..dc97dc14d731 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -178,7 +178,7 @@ djangorestframework-simplejwt = "^5.3.1" optional = true [tool.poetry.group.auth-controller.dependencies] -flagsmith-auth-controller = { git = "https://github.com/flagsmith/flagsmith-auth-controller", tag = "v0.1.2" } +flagsmith-auth-controller = { git = "https://github.com/flagsmith/flagsmith-auth-controller", tag = "v0.1.3" } [tool.poetry.group.saml] optional = true From 23922223d4367792b196d798cb2571dc8f589ee1 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 26 Nov 2024 14:00:21 +0000 Subject: [PATCH 29/77] fix: Environment Ready Checker (#4865) --- .../web/components/EnvironmentReadyChecker.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/EnvironmentReadyChecker.tsx b/frontend/web/components/EnvironmentReadyChecker.tsx index 0967f5366ff5..cb82fa5abf49 100644 --- a/frontend/web/components/EnvironmentReadyChecker.tsx +++ b/frontend/web/components/EnvironmentReadyChecker.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import { FC, useEffect, useState } from 'react' import { useGetEnvironmentQuery } from 'common/services/useEnvironment' type EnvironmentReadyCheckerType = { @@ -13,12 +13,22 @@ const EnvironmentReadyChecker: FC = ({ children, match, }) => { + const [environmentCreated, setEnvironmentCreated] = useState(false) + const { data, isLoading } = useGetEnvironmentQuery( { id: match.params.environmentId, }, - { pollingInterval: 1000, skip: !match.params.environmentId }, + { + pollingInterval: 1000, + skip: !match.params.environmentId || environmentCreated, + }, ) + useEffect(() => { + if (!!data && !data?.is_creating) { + setEnvironmentCreated(true) + } + }, [data]) if (!match?.params?.environmentId) { return children } From 6ad5e76fb78035b51b30e71e86a81455bfc30a49 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 26 Nov 2024 14:28:57 +0000 Subject: [PATCH 30/77] deps(workflows): flagsmith-workflows 2.6.0 -> 2.6.1 (#4866) --- api/poetry.lock | 6 +++--- api/pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index e9192b6f7ef1..3ca034e57912 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -4046,8 +4046,8 @@ flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/flagsmith/flagsmith-workflows" -reference = "v2.6.0" -resolved_reference = "06b03d428484f5aed2f400cef431ab5aae0d0df0" +reference = "v2.6.1" +resolved_reference = "c3c05dec3aa4040f43bd40467736981c8804a129" [[package]] name = "wrapt" @@ -4166,4 +4166,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "259c8cdd1b9facb619ee08d1c46926df7fab9bf01bee4c768bbd79499e290a79" +content-hash = "57d603e5e64c9688d51cdf536e95b13a51578002f2584fce94d5eecbd5286be9" diff --git a/api/pyproject.toml b/api/pyproject.toml index dc97dc14d731..777f19033742 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -196,7 +196,7 @@ flagsmith-ldap = { git = "https://github.com/flagsmith/flagsmith-ldap", tag = "v optional = true [tool.poetry.group.workflows.dependencies] -workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.6.0" } +workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.6.1" } [tool.poetry.group.dev.dependencies] django-test-migrations = "~1.2.0" From 04a160599a780662f83e3b5a74f066b6ead17a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20L=C3=B3pez=20Dato?= Date: Tue, 26 Nov 2024 14:37:16 -0300 Subject: [PATCH 31/77] docs: Improve edge proxy docs (#4861) --- .../deployment/hosting/locally-edge-proxy.md | 260 +++++++----------- 1 file changed, 92 insertions(+), 168 deletions(-) diff --git a/docs/docs/deployment/hosting/locally-edge-proxy.md b/docs/docs/deployment/hosting/locally-edge-proxy.md index 45849e62d841..b0d5bf69d905 100644 --- a/docs/docs/deployment/hosting/locally-edge-proxy.md +++ b/docs/docs/deployment/hosting/locally-edge-proxy.md @@ -4,102 +4,141 @@ title: Edge Proxy sidebar_position: 25 --- -## Configuration +## Running -The Edge Proxy can be configured using a json configuration file (named `config.json` here). +The Edge Proxy runs as a [Docker container](https://hub.docker.com/repository/docker/flagsmith/edge-proxy) with no +external dependencies. It connects to the Flagsmith API to download environment documents, and your Flagsmith client +applications connect to it using [remote flag evaluation](/clients/#remote-evaluation). -You can set the following configuration in `config.json` to control the behaviour of the Edge Proxy: +The examples below assume you have a configuration file located at `./config.json`. Your Flagsmith client applications +can then consume the Edge Proxy by setting their API URL to `http://localhost:8000/api/v1/`. -### Basic Settings +
+Docker CLI +``` +docker run \ + -v ./config.json:/app/config.json \ + -p 8000:8000 \ + flagsmith/edge-proxy:latest +``` +
-#### `environment_key_pairs` +
+Docker Compose +```yaml +services: + edge_proxy: + image: flagsmith/edge-proxy:latest + volumes: + - type: bind + source: ./config.json + target: /app/config.json + ports: + - '8000:8000' +``` +
-An array of environment key pair objects: +## Configuration -```json -"environment_key_pairs": [{ - "server_side_key": "your_server_side_key", - "client_side_key": "your_client_side_environment_key" -}] -``` +The Edge Proxy can be configured with any combination of: -#### `api_poll_frequency` +- Environment variables. +- A JSON configuration file, by default located at `/app/config.json` in the Edge Proxy container. + +Environment variables take priority over their corresponding options defined in the configuration file. -:::note +Environment variables are case-insensitive, and are processed using +[Pydantic](https://docs.pydantic.dev/2.7/concepts/pydantic_settings/#environment-variable-names). -This setting is optional. +### Example configuration -::: +
-Control how often the Edge Proxy is going to ping the server for changes, in seconds: +Configuration file ```json -"api_poll_frequency": 30 +{ + "environment_key_pairs": [ + { + "server_side_key": "ser.your_server_side_key_1", + "client_side_key": "your_client_side_key_1" + } + ], + "api_poll_frequency": 5, + "logging": { + "log_level": "DEBUG", + "log_format": "json" + } +} ``` -Defaults to `10`. +
-#### `api_poll_timeout` +
-:::note +Environment variables -This setting is optional. +```ruby +ENVIRONMENT_KEY_PAIRS='[{"server_side_key":"ser.your_server_side_key_1","client_side_key":"your_client_side_key_1"}]' +API_POLL_FREQUENCY=5 +LOGGING='{"log_level":"DEBUG","log_format":"json"}' +``` + +
-::: +### Basic Settings -Specify the request timeout when trying to retrieve new changes, in seconds: +#### `environment_key_pairs` + +Specifies which environments to poll environment documents for. Each environment requires a server-side key and its +corresponding client-side key. ```json -"api_poll_timeout": 1 +"environment_key_pairs": [{ + "server_side_key": "ser.your_server_side_key", + "client_side_key": "your_client_side_environment_key" +}] ``` -Defaults to `5`. - #### `api_url` -:::note +If you are self-hosting Flagsmith, set this to your API URL: -This setting is optional. +```json +"api_url": "https://flagsmith.example.com/api/v1" +``` -::: +#### `api_poll_frequency` -Set if you are running a self hosted version of Flagsmith: +How often to poll the Flagsmith API for changes, in seconds. Defaults to 10. ```json -"api_url": "https://my-flagsmith.domain.com/api/v1" +"api_poll_frequency": 30 ``` -If not set, defaults to Flagsmith's Edge API. - -#### `allow_origins` +#### `api_poll_timeout` -:::note +The request timeout when trying to retrieve new changes, in seconds. Defaults to 5. -This setting is optional. +```json +"api_poll_timeout": 1 +``` -::: +#### `allow_origins` -Set a value for the `Access-Control-Allow-Origin` header. +Set a value for the `Access-Control-Allow-Origin` header. Defaults to `*`. ```json "allow_origins": "https://my-flagsmith.domain.com" ``` -If not set, defaults to `*`. - ### Endpoint Caches #### `endpoint_caches` -:::note - -This setting is optional. - -::: +Enables a LRU cache per endpoint. -Enable a LRU cache per endpoint. - -Optionally, specify the LRU cache size with `cache_max_size` (defaults to 128): +Optionally, specify the LRU cache size with `cache_max_size`. Defaults to 128. ```json "endpoint_caches": { @@ -117,12 +156,6 @@ Optionally, specify the LRU cache size with `cache_max_size` (defaults to 128): #### `logging.log_level` -:::note - -This setting is optional. - -::: - Choose a logging level from `"CRITICAL"`, `"ERROR"`, `"WARNING"`, `"INFO"`, `"DEBUG"`. Defaults to `"INFO"`. ```json @@ -131,13 +164,7 @@ Choose a logging level from `"CRITICAL"`, `"ERROR"`, `"WARNING"`, `"INFO"`, `"DE #### `logging.log_format` -:::note - -This setting is optional. - -::: - -Choose a logging forman between `"generic"` and `"json"`. Defaults to `"generic"`. +Choose a logging format between `"generic"` and `"json"`. Defaults to `"generic"`. ```json "logging": {"log_format": "json"} @@ -145,12 +172,6 @@ Choose a logging forman between `"generic"` and `"json"`. Defaults to `"generic" #### `logging.log_event_field_name` -:::note - -This setting is optional. - -::: - Set a name used for human-readable log entry field when logging events in JSON. Defaults to `"message"`. ```json @@ -159,23 +180,13 @@ Set a name used for human-readable log entry field when logging events in JSON. #### `logging.colour` -:::note - -- Added in [2.13.0](https://github.com/Flagsmith/edge-proxy/releases/tag/v2.13.0). -- This setting is optional. - -::: +Added in [2.13.0](https://github.com/Flagsmith/edge-proxy/releases/tag/v2.13.0). Set to `false` to disable coloured output. Useful when outputting the log to a file. #### `logging.override` -:::note - -- Added in [2.13.0](https://github.com/Flagsmith/edge-proxy/releases/tag/v2.13.0). -- This setting is optional. - -::: +Added in [2.13.0](https://github.com/Flagsmith/edge-proxy/releases/tag/v2.13.0). Accepts [Python-compatible logging settings](https://docs.python.org/3/library/logging.config.html#configuration-dictionary-schema) @@ -204,7 +215,7 @@ everything a file, one can set up own file handler and assign it to the root log } ``` -Or, log access logs to file in generic format while logging everything else to stdout in json: +Or, log access logs to file in generic format while logging everything else to stdout in JSON: ```json "logging": { @@ -264,23 +275,6 @@ if last_updated_all_environments_at < datetime.now() - timedelta(seconds=total_g return 200 ``` -### Example - -Here's an example of a minimal working Edge Proxy configuration: - -```json -{ - "environment_key_pairs": [ - { - "server_side_key": "your_server_side_environment_key", - "client_side_key": "your_client_side_environment_key" - } - ], - "api_poll_frequency": 10, - "api_url": "https://api.flagsmith.com/api/v1" -} -``` - ### Environment Variables You can configure the Edge Proxy with the following Environment Variables: @@ -288,71 +282,6 @@ You can configure the Edge Proxy with the following Environment Variables: - `WEB_CONCURRENCY` The number of [Uvicorn](https://www.uvicorn.org/) workers. Defaults to `1`. Set to the number of available CPU cores. -## Running the Edge Proxy - -The Edge Proxy runs as a docker container. It is currently available at the -[Docker Hub](https://hub.docker.com/repository/docker/flagsmith/edge-proxy). - -### With docker run - -```bash -# Download the Docker Image -docker pull flagsmith/edge-proxy - -# Run it -docker run \ - -v //config.json:/app/config.json \ - -p 8000:8000 \ - flagsmith/edge-proxy:latest -``` - -### With docker compose - -```yml -services: - edge_proxy: - image: flagsmith/edge-proxy:latest - volumes: - - type: bind - source: ./config.json - target: /app/config.json - ports: - - '8000:8000' -``` - -The Proxy is now running and available on port 8000. - -## Consuming the Edge Proxy - -The Edge Proxy provides an identical set of API methods as our Core API. You need to point your SDK to the Edge Proxy -domain name and you're good to go. For example, lets say you had your proxy running locally as per the instructions -above: - -```bash -curl "http://localhost:8000/api/v1/flags/" -H "x-environment-key: 95DybY5oJoRNhxPZYLrxk4" | jq - -[ - { - "enabled": true, - "feature_state_value": 5454, - "feature": { - "name": "feature_1", - "id": 2, - "type": "MULTIVARIATE" - } - }, - { - "enabled": true, - "feature_state_value": "some_value", - "feature": { - "name": "feature_2", - "id": 9, - "type": "STANDARD" - } - }, -] -``` - ## Monitoring There are 2 health check endpoints for the Edge Proxy. @@ -363,11 +292,6 @@ When making a request to `/proxy/health` the proxy will respond with a HTTP `200 your orchestration health checks to this endpoint. This endpoint checks that the [Environment Document](/clients#the-environment-document) is not stale, and that the proxy is serving SDK requests. -### Realtime Flags/Server Sent Events Health Check - -If you are using the Proxy to power Server Sent Events for realtime flag updates. When making a request to `/sse/health` -the proxy will respond with a HTTP `200` and `{"status": "ok"}`. - ## Architecture The standard Flagsmith architecture: From 7ffa79448c1ffbb07c288c2c528db80ad4aca56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20L=C3=B3pez=20Dato?= Date: Tue, 26 Nov 2024 15:02:30 -0300 Subject: [PATCH 32/77] docs: fix separate API/frontend ingress (#4868) --- docs/docs/deployment/hosting/kubernetes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/deployment/hosting/kubernetes.md b/docs/docs/deployment/hosting/kubernetes.md index 34fe81119617..6a07f0396a52 100644 --- a/docs/docs/deployment/hosting/kubernetes.md +++ b/docs/docs/deployment/hosting/kubernetes.md @@ -93,7 +93,7 @@ ingress: - /api/ - /health/ - /admin/ - - /static/ + - /static/admin/ frontend: extraEnv: From 15cf98f9d0cd227f6f0934edecc2cb8ee5b5525c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 27 Nov 2024 18:23:49 +0000 Subject: [PATCH 33/77] test: add pgpool test environment (#4747) --- .github/workflows/manual-e2e-tests.yml | 38 ++++++++++++++++ docker-compose.pgpool.yml | 62 ++++++++++++++++++++++++++ docker/common/db/.pgpool.yml | 62 ++++++++++++++++++++++++++ docker/common/e2e/.e2e-tests.yml | 42 +++++++++++++++++ docker/db.yml | 17 ------- frontend/docker-compose.yml | 19 -------- 6 files changed, 204 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/manual-e2e-tests.yml create mode 100644 docker-compose.pgpool.yml create mode 100644 docker/common/db/.pgpool.yml create mode 100644 docker/common/e2e/.e2e-tests.yml delete mode 100644 docker/db.yml delete mode 100644 frontend/docker-compose.yml diff --git a/.github/workflows/manual-e2e-tests.yml b/.github/workflows/manual-e2e-tests.yml new file mode 100644 index 000000000000..7bf9fec3799e --- /dev/null +++ b/.github/workflows/manual-e2e-tests.yml @@ -0,0 +1,38 @@ +name: 'Manual E2E tests' + +on: + workflow_dispatch: + inputs: + e2e-token: + description: 'The authentication token used by the E2E process' + required: true + e2e-concurrency: + description: 'The concurrency value to use when running the E2E process' + default: 3 + type: number + api-url: + description: 'Which database service to use to run the API against' + default: 'https://api.flagsmith.com/api/v1/' + +jobs: + run-e2e-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + cache: npm + node-version-file: frontend/.nvmrc + cache-dependency-path: frontend/package-lock.json + + - name: Run tests + working-directory: frontend + env: + E2E_TEST_AUTH_TOKEN: ${{ inputs.e2e-token }} + FLAGSMITH_API_URL: ${{ inputs.api-url }} + E2E_CONCURRENCY: ${{ inputs.e2e-concurrency }} + run: | + npm ci + npm run env + npm run test diff --git a/docker-compose.pgpool.yml b/docker-compose.pgpool.yml new file mode 100644 index 000000000000..454f812196f4 --- /dev/null +++ b/docker-compose.pgpool.yml @@ -0,0 +1,62 @@ +services: + pg-0: + extends: + file: ./docker/common/db/.pgpool.yml + service: pg-0 + volumes: + - pg_0_data:/bitnami/postgresql + + pg-1: + extends: + file: ./docker/common/db/.pgpool.yml + service: pg-1 + volumes: + - pg_1_data:/bitnami/postgresql + + pgpool: + extends: + file: ./docker/common/db/.pgpool.yml + service: pgpool + + flagsmith: + image: flagsmith/flagsmith:latest + platform: linux/arm64 + environment: + DATABASE_URL: postgresql://flagsmith:password@pgpool:5432/flagsmith + USE_POSTGRES_FOR_ANALYTICS: 'true' # Store API and Flag Analytics data in Postgres + + ENVIRONMENT: production # set to 'production' in production. + DJANGO_ALLOWED_HOSTS: '*' # Change this in production + ALLOW_ADMIN_INITIATION_VIA_CLI: 'true' # Change this in production + FLAGSMITH_DOMAIN: localhost:8000 # Change this in production + DJANGO_SECRET_KEY: secret # Change this in production + ENABLE_ADMIN_ACCESS_USER_PASS: 'true' + TASK_RUN_METHOD: TASK_PROCESSOR # other options are: SYNCHRONOUSLY, SEPARATE_THREAD (default) + ports: + - 8000:8000 + healthcheck: + test: ['CMD-SHELL', 'python /app/scripts/healthcheck.py'] + interval: 2s + timeout: 2s + retries: 20 + start_period: 20s + depends_on: + pgpool: + condition: service_healthy + + flagsmith_processor: + image: flagsmith/flagsmith:latest + platform: linux/arm64 + environment: + DATABASE_URL: postgresql://flagsmith:password@pgpool:5432/flagsmith + USE_POSTGRES_FOR_ANALYTICS: 'true' + depends_on: + flagsmith: + condition: service_healthy + command: run-task-processor + +volumes: + pg_0_data: + driver: local + pg_1_data: + driver: local diff --git a/docker/common/db/.pgpool.yml b/docker/common/db/.pgpool.yml new file mode 100644 index 000000000000..404c286d2d8c --- /dev/null +++ b/docker/common/db/.pgpool.yml @@ -0,0 +1,62 @@ +# Copyright Broadcom, Inc. All Rights Reserved. +# SPDX-License-Identifier: APACHE-2.0 + +services: + pg-0: + image: docker.io/bitnami/postgresql-repmgr:15 + ports: + - 5432 + environment: + - POSTGRESQL_POSTGRES_PASSWORD=password + - POSTGRESQL_USERNAME=flagsmith + - POSTGRESQL_PASSWORD=password + - POSTGRESQL_DATABASE=flagsmith + - POSTGRESQL_NUM_SYNCHRONOUS_REPLICAS=1 + - REPMGR_PRIMARY_HOST=pg-0 + - REPMGR_PARTNER_NODES=pg-1,pg-0 + - REPMGR_NODE_NAME=pg-0 + - REPMGR_NODE_NETWORK_NAME=pg-0 + - REPMGR_USERNAME=repmgr + - REPMGR_PASSWORD=repmgrpassword + + pg-1: + image: docker.io/bitnami/postgresql-repmgr:15 + ports: + - 5432 + environment: + - POSTGRESQL_POSTGRES_PASSWORD=password + - POSTGRESQL_USERNAME=flagsmith + - POSTGRESQL_PASSWORD=password + - POSTGRESQL_DATABASE=flagsmith + - POSTGRESQL_NUM_SYNCHRONOUS_REPLICAS=1 + - REPMGR_PRIMARY_HOST=pg-0 + - REPMGR_PARTNER_NODES=pg-0,pg-1 + - REPMGR_NODE_NAME=pg-1 + - REPMGR_NODE_NETWORK_NAME=pg-1 + - REPMGR_USERNAME=repmgr + - REPMGR_PASSWORD=repmgrpassword + + pgpool: + image: docker.io/bitnami/pgpool:4 + ports: + - 5432:5432 + environment: + - PGPOOL_BACKEND_NODES=0:pg-0:5432,1:pg-1:5432 + - PGPOOL_SR_CHECK_USER=repmgr + - PGPOOL_SR_CHECK_PASSWORD=repmgrpassword + - PGPOOL_ENABLE_LDAP=no + - PGPOOL_POSTGRES_USERNAME=postgres + - PGPOOL_POSTGRES_PASSWORD=password + - PGPOOL_ADMIN_USERNAME=admin + - PGPOOL_ADMIN_PASSWORD=adminpassword + - PGPOOL_ENABLE_LOAD_BALANCING=yes + - PGPOOL_POSTGRES_CUSTOM_USERS=flagsmith + - PGPOOL_POSTGRES_CUSTOM_PASSWORDS=password + healthcheck: + test: ['CMD', '/opt/bitnami/scripts/pgpool/healthcheck.sh'] + interval: 10s + timeout: 5s + retries: 5 + depends_on: + - pg-0 + - pg-1 diff --git a/docker/common/e2e/.e2e-tests.yml b/docker/common/e2e/.e2e-tests.yml new file mode 100644 index 000000000000..dfe9592189a4 --- /dev/null +++ b/docker/common/e2e/.e2e-tests.yml @@ -0,0 +1,42 @@ +services: + flagsmith-api: + image: ${API_IMAGE:-ghcr.io/flagsmith/flagsmith-api:dev} + build: + context: ../../../ + target: oss-api + environment: + E2E_TEST_AUTH_TOKEN: some-token + ENABLE_FE_E2E: 'True' + DJANGO_ALLOWED_HOSTS: '*' + DISABLE_ANALYTICS_FEATURES: 'true' + EMAIL_BACKEND: django.core.mail.backends.smtp.EmailBackend + ACCESS_LOG_LOCATION: /dev/shm/log.txt + ports: + - 8000:8000 + healthcheck: + test: '[ -e /dev/shm/log.txt ] && exit 0 || exit 1' + start_period: 60s + interval: 10s + timeout: 3s + retries: 30 + + frontend: + image: ${E2E_IMAGE:-ghcr.io/flagsmith/flagsmith-e2e:dev} + build: + context: ../../../ + dockerfile: frontend/Dockerfile.e2e + environment: + E2E_TEST_TOKEN_DEV: some-token + DISABLE_ANALYTICS_FEATURES: 'true' + FLAGSMITH_API: flagsmith-api:8000/api/v1/ + SLACK_TOKEN: ${SLACK_TOKEN} + GITHUB_ACTION_URL: ${GITHUB_ACTION_URL} + ports: + - 8080:8080 + depends_on: + flagsmith-api: + condition: service_healthy + + links: + - flagsmith-api:flagsmith-api + command: [npm, run, test] diff --git a/docker/db.yml b/docker/db.yml deleted file mode 100644 index a98f1f82053c..000000000000 --- a/docker/db.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: flagsmith - -volumes: - pg_11_data: - -services: - db: - image: postgres:15.5-alpine - pull_policy: always - restart: unless-stopped - volumes: - - pg_11_data:/var/lib/postgresql/data - ports: - - 5432:5432 - environment: - POSTGRES_DB: flagsmith - POSTGRES_PASSWORD: password diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml deleted file mode 100644 index 5eee759e9994..000000000000 --- a/frontend/docker-compose.yml +++ /dev/null @@ -1,19 +0,0 @@ -version: '3.7' - -services: - bullettrain: - build: - context: . - target: development - dockerfile: Dockerfile - command: npm run dev - environment: - - FLAGSMITH_API_URL=http://localhost:8000/api/v1/ - ports: - - 8080:8080 - volumes: - - .:/srv/bt - - frontend_node_modules:/srv/bt/node_modules - -volumes: - frontend_node_modules: From 39556c09d03dba5c779053ec28eea140bd9772ab Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 28 Nov 2024 10:53:50 +0000 Subject: [PATCH 34/77] chore: fix prettier issues and replace prettier pre-commit hook (#4867) --- .pre-commit-config.yaml | 8 +-- docs/docs/clients/client-side/ios.mdx | 4 +- docs/docs/clients/server-side.mdx | 88 +++++++++++++-------------- docs/docs/pricing/index.mdx | 22 +++---- 4 files changed, 61 insertions(+), 61 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1a562398753..3a5461705c5d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,13 +26,13 @@ repos: - id: check-json - id: check-toml - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 + - repo: local hooks: - id: prettier + name: prettier exclude: ^(frontend/|CHANGELOG.md|.github/docker_build_comment_template.md) - additional_dependencies: - - prettier@3.3.3 # SEE: https://github.com/pre-commit/pre-commit/issues/3133 + language: system + entry: npx prettier --check - repo: https://github.com/python-poetry/poetry rev: 1.8.0 diff --git a/docs/docs/clients/client-side/ios.mdx b/docs/docs/clients/client-side/ios.mdx index 499a0b0d2516..a08dfe773189 100644 --- a/docs/docs/clients/client-side/ios.mdx +++ b/docs/docs/clients/client-side/ios.mdx @@ -90,8 +90,8 @@ Flagsmith.shared.getFeatureFlags() { (result) in Note that you can use: -- `flag.value?.stringValue` -- `flag.value?.intValue` +- `flag.value?.stringValue` +- `flag.value?.intValue` Based on your desired type. diff --git a/docs/docs/clients/server-side.mdx b/docs/docs/clients/server-side.mdx index 242595ffa63f..925f8e46b8a4 100644 --- a/docs/docs/clients/server-side.mdx +++ b/docs/docs/clients/server-side.mdx @@ -24,57 +24,57 @@ Server Side SDKs can run in 2 different modes: Local Evaluation and Remote Evalu -- Version Compatibility: **Python 3.8+** -- Source Code: https://github.com/Flagsmith/flagsmith-python-client +- Version Compatibility: **Python 3.8+** +- Source Code: https://github.com/Flagsmith/flagsmith-python-client -- Version Compatibility: **JDK 11+** -- Source Code: https://github.com/Flagsmith/flagsmith-java-client +- Version Compatibility: **JDK 11+** +- Source Code: https://github.com/Flagsmith/flagsmith-java-client -- Version Compatibility: **.NET core 6.0+** -- Source Code: https://github.com/Flagsmith/flagsmith-dotnet-client +- Version Compatibility: **.NET core 6.0+** +- Source Code: https://github.com/Flagsmith/flagsmith-dotnet-client -- Version Compatibility: **Node 14+** -- Source Code: - - https://github.com/Flagsmith/flagsmith-nodejs-client +- Version Compatibility: **Node 14+** +- Source Code: + - https://github.com/Flagsmith/flagsmith-nodejs-client -- Version Compatibility: **Ruby 2.4+** -- Source Code: https://github.com/Flagsmith/flagsmith-ruby-client +- Version Compatibility: **Ruby 2.4+** +- Source Code: https://github.com/Flagsmith/flagsmith-ruby-client -- Version Compatibility: **php 7.4+** -- Source Code: https://github.com/Flagsmith/flagsmith-php-client +- Version Compatibility: **php 7.4+** +- Source Code: https://github.com/Flagsmith/flagsmith-php-client -- Version Compatibility: **Go 1.18+** -- Source Code: https://github.com/Flagsmith/flagsmith-go-client +- Version Compatibility: **Go 1.18+** +- Source Code: https://github.com/Flagsmith/flagsmith-go-client -- Version Compatibility: **2021 edition (1.56.0)+** -- Source Code: https://github.com/Flagsmith/flagsmith-rust-client +- Version Compatibility: **2021 edition (1.56.0)+** +- Source Code: https://github.com/Flagsmith/flagsmith-rust-client -- Version Compatibility: **Elixir 1.12+** -- Source Code: https://github.com/Flagsmith/flagsmith-elixir-client +- Version Compatibility: **Elixir 1.12+** +- Source Code: https://github.com/Flagsmith/flagsmith-elixir-client @@ -572,19 +572,19 @@ secret_button_feature_value = Flagsmith.Client.get_feature_value(flags, "secret_ ### When running in [Remote Evaluation mode](/clients#remote-evaluation) -- When requesting Flags for an Identity, all the Traits defined in the SDK will automatically be persisted against the - Identity within the Flagsmith API. -- Traits passed to the SDK will be added to all the other previously persisted Traits associated with that Identity. -- This full set of Traits are then used to evaluate the Flag values for the Identity. -- This all happens in a single request/response. +- When requesting Flags for an Identity, all the Traits defined in the SDK will automatically be persisted against the + Identity within the Flagsmith API. +- Traits passed to the SDK will be added to all the other previously persisted Traits associated with that Identity. +- This full set of Traits are then used to evaluate the Flag values for the Identity. +- This all happens in a single request/response. ### When running in [Local Evaluation mode](/clients#local-evaluation) -- _Only_ the Traits provided to the SDK at runtime will be used. Local Evaluation mode, by design, does not make any - network requests to the Flagsmith API when evaluating Flags for an Identity. - - When running in Local Evaluation Mode, the SDK requests the - [Environment Document](/clients#the-environment-document) from the Flagsmith API. This contains all the - information required to make Flag Evaluations, but it does _not_ contain any Trait data. +- _Only_ the Traits provided to the SDK at runtime will be used. Local Evaluation mode, by design, does not make any + network requests to the Flagsmith API when evaluating Flags for an Identity. + - When running in Local Evaluation Mode, the SDK requests the + [Environment Document](/clients#the-environment-document) from the Flagsmith API. This contains all the + information required to make Flag Evaluations, but it does _not_ contain any Trait data. ## Managing Default Flags @@ -983,10 +983,10 @@ The Server Side SDKS share the same network behaviour across the different langu ### Remote Evaluation Mode Network Behaviour -- A blocking network request is made every time you make a call to get an Environment Flags. In Python, for example, - `flagsmith.get_environment_flags()` will trigger this request. -- A blocking network request is made every time you make a call to get an Identities Flags. In Python, for example, - `flagsmith.get_identity_flags(identifier=identifier, traits=traits)` will trigger this request. +- A blocking network request is made every time you make a call to get an Environment Flags. In Python, for example, + `flagsmith.get_environment_flags()` will trigger this request. +- A blocking network request is made every time you make a call to get an Identities Flags. In Python, for example, + `flagsmith.get_identity_flags(identifier=identifier, traits=traits)` will trigger this request. ### Local Evaluation Mode Network Behaviour @@ -998,9 +998,9 @@ To use Local Evaluation mode, you must use a Server Side key. ::: -- When the SDK is initialised, it will make an asynchronous network request to retrieve details about the Environment. -- Every 60 seconds (by default), it will repeat this aysnchronous request to ensure that the Environment information - it has is up to date. +- When the SDK is initialised, it will make an asynchronous network request to retrieve details about the Environment. +- Every 60 seconds (by default), it will repeat this aysnchronous request to ensure that the Environment information it + has is up to date. To achieve Local Evaluation, in most languages, the SDK spawns a separate thread (or equivalent) to poll the API for changes to the Environment. In certain languages, you may be required to terminate this thread before cleaning up the @@ -1793,9 +1793,9 @@ Flagsmith uses [Caffeine](https://github.com/ben-manes/caffeine), a high perform If you enable caching on the Flagsmith client without setting any values (as shown below), the following default values will be set for you: -- `maxSize(10)` -- `expireAfterWrite(5, TimeUnit.MINUTES)` -- project level caching will be disabled by default (i.e. only enabled if you configure a caching key) +- `maxSize(10)` +- `expireAfterWrite(5, TimeUnit.MINUTES)` +- project level caching will be disabled by default (i.e. only enabled if you configure a caching key) ```java // use in-memory caching with Flagsmith defaults as described above @@ -1978,11 +1978,11 @@ $flagsmith->updateEnvironment(); Note: -- For the environment cache, please use the server key generated from the Flagsmith Settings menu. The key's prefix is - `ser.`. -- The cache is important for concurrent requests. Without the cache, each request in PHP is a different process with - its own memory objects. The cache (filesystem or other) would enforce that the network call is reduced to a file - system one. +- For the environment cache, please use the server key generated from the Flagsmith Settings menu. The key's prefix is + `ser.`. +- The cache is important for concurrent requests. Without the cache, each request in PHP is a different process with its + own memory objects. The cache (filesystem or other) would enforce that the network call is reduced to a file system + one. diff --git a/docs/docs/pricing/index.mdx b/docs/docs/pricing/index.mdx index f9aa2e8f8573..e4f2c4cbec90 100644 --- a/docs/docs/pricing/index.mdx +++ b/docs/docs/pricing/index.mdx @@ -35,17 +35,17 @@ API request. The billable API requests your application makes depends mainly on which [flag evaluation mode](/clients) it uses: -- Remote Evaluation is the default for all applications. Applications using Remote Evaluation call the Flagsmith API - when they need to fetch the flags for the current environment or user. -- Local Evaluation is optional, and only for server-side applications. Each individual application or - [Edge Proxy](/advanced-use/edge-proxy) instance polls the Flagsmith API at a configurable interval (60 seconds by - default) to fetch the flags for the current environment and all users in one API request. +- Remote Evaluation is the default for all applications. Applications using Remote Evaluation call the Flagsmith API + when they need to fetch the flags for the current environment or user. +- Local Evaluation is optional, and only for server-side applications. Each individual application or + [Edge Proxy](/advanced-use/edge-proxy) instance polls the Flagsmith API at a configurable interval (60 seconds by + default) to fetch the flags for the current environment and all users in one API request. The following requests are not billable: -- [Admin API](/clients/rest#private-admin-api-endpoints) requests. -- Requests made by Flagsmith SDKs to track [Flag Analytics](/advanced-use/flag-analytics). -- Connecting to a [real-time flag updates](/advanced-use/real-time-flags) stream. +- [Admin API](/clients/rest#private-admin-api-endpoints) requests. +- Requests made by Flagsmith SDKs to track [Flag Analytics](/advanced-use/flag-analytics). +- Connecting to a [real-time flag updates](/advanced-use/real-time-flags) stream. ### Example: client-side application @@ -85,9 +85,9 @@ its lifecycle could look like this: "Service" in this context refers to any of the following: -- An instance of your application, running in a container or server, using the Flagsmith SDK with Local Evaluation - enabled. -- An [Edge Proxy](/advanced-use/edge-proxy) container instance. +- An instance of your application, running in a container or server, using the Flagsmith SDK with Local Evaluation + enabled. +- An [Edge Proxy](/advanced-use/edge-proxy) container instance. Following the example above, if you used the default polling frequency of 60 seconds, you could estimate your monthly API usage as: From 6013b849cbaabdb60c233f8a9e0694d6fa6656c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:03:08 +0000 Subject: [PATCH 35/77] chore(deps): bump http-proxy-middleware from 2.0.6 to 2.0.7 in /docs (#4864) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index ab3658e63ce4..d0359ca11466 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8196,9 +8196,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", @@ -22553,9 +22553,9 @@ } }, "http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "requires": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", From c7aa30bb217deca7ea6a0b0657d1a08076f2ab44 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 28 Nov 2024 14:00:24 +0000 Subject: [PATCH 36/77] fix: prevent enabling versioning from affecting scheduled change requests (#4872) --- api/features/versioning/tasks.py | 2 + .../versioning/test_unit_versioning_tasks.py | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/api/features/versioning/tasks.py b/api/features/versioning/tasks.py index 5850c369d0c0..c5d8e1748ce4 100644 --- a/api/features/versioning/tasks.py +++ b/api/features/versioning/tasks.py @@ -109,7 +109,9 @@ def _create_initial_feature_versions(environment: "Environment"): change_request__isnull=False, change_request__committed_at__isnull=False, change_request__deleted_at__isnull=True, + environment=environment, ).select_related("change_request") + for feature_state in scheduled_feature_states: ef_version = EnvironmentFeatureVersion.objects.create( feature=feature, diff --git a/api/tests/unit/features/versioning/test_unit_versioning_tasks.py b/api/tests/unit/features/versioning/test_unit_versioning_tasks.py index bc701c2156b9..2ae8ce082a56 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_tasks.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_tasks.py @@ -32,6 +32,7 @@ get_environment_flags_queryset, ) from features.workflows.core.models import ChangeRequest +from organisations.models import Organisation from projects.models import Project from segments.models import Segment from users.models import FFAdminUser @@ -233,6 +234,36 @@ def test_enable_v2_versioning_for_scheduled_changes( version=None, ) + # and a published, scheduled feature state associated with a different project + # Note: this additional test clause is to verify an issue found in testing + # where enabling feature versioning 'stole' scheduled feature states from other + # projects. + another_organisation = Organisation.objects.create(name="another organisation") + staff_user.add_organisation(another_organisation) + another_project = Project.objects.create( + name="another project", organisation=another_organisation + ) + another_environment = Environment.objects.create( + name="another environment", project=another_project + ) + another_feature = Feature.objects.create( + name="another_feature", project=another_project + ) + published_scheduled_cr_another_environment = ChangeRequest.objects.create( + environment=another_environment, + title="Published, scheduled change in another environment", + user=staff_user, + ) + another_environment_fs = FeatureState.objects.create( + feature=another_feature, + enabled=True, + environment=another_environment, + live_from=two_hours_from_now, + change_request=published_scheduled_cr_another_environment, + version=None, + ) + published_scheduled_cr_another_environment.commit(staff_user) + # When enable_v2_versioning(environment.id) @@ -260,6 +291,13 @@ def test_enable_v2_versioning_for_scheduled_changes( == scheduled_feature_state ) + another_environment_fs.refresh_from_db() + assert another_environment_fs.environment_feature_version is None + assert ( + another_environment_fs.change_request + == published_scheduled_cr_another_environment + ) + def test_publish_version_change_set_sends_email_to_change_request_owner_if_conflicts_when_scheduled( feature: Feature, From ada424ded7d6a71880afaeb4cc57c0b0f5390f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20L=C3=B3pez=20Dato?= Date: Thu, 28 Nov 2024 15:15:41 -0300 Subject: [PATCH 37/77] docs: Node.js v5 docs (#4821) --- docs/docs/clients/server-side.mdx | 161 +++++++++++------------ docs/plugins/flagsmith-versions/index.js | 4 +- docs/src/components/SdkVersions.js | 1 + 3 files changed, 78 insertions(+), 88 deletions(-) diff --git a/docs/docs/clients/server-side.mdx b/docs/docs/clients/server-side.mdx index 925f8e46b8a4..dbfa6500606f 100644 --- a/docs/docs/clients/server-side.mdx +++ b/docs/docs/clients/server-side.mdx @@ -8,7 +8,13 @@ import CodeBlock from '@theme/CodeBlock'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -import { JavaVersion, RustVersion, DotnetVersion, ElixirVersion } from '@site/src/components/SdkVersions.js'; +import { + JavaVersion, + RustVersion, + DotnetVersion, + ElixirVersion, + NodejsVersion, +} from '@site/src/components/SdkVersions.js'; # Server Side SDKs @@ -40,11 +46,10 @@ Server Side SDKs can run in 2 different modes: Local Evaluation and Remote Evalu - Source Code: https://github.com/Flagsmith/flagsmith-dotnet-client - + -- Version Compatibility: **Node 14+** -- Source Code: - - https://github.com/Flagsmith/flagsmith-nodejs-client +- Version Compatibility: **Node 18+** +- Source Code: https://github.com/Flagsmith/flagsmith-nodejs-client @@ -140,11 +145,10 @@ pip install flagsmith - + ```bash -# Via NPM -npm i flagsmith-nodejs --save +npm install flagsmith-nodejs ``` @@ -245,10 +249,10 @@ _flagsmithClient = new("FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY"); ``` - + ```javascript -const Flagsmith = require('flagsmith-nodejs'); +import { Flagsmith } from 'flagsmith-nodejs'; const flagsmith = new Flagsmith({ environmentKey: 'FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY', @@ -367,12 +371,12 @@ var buttonData = await flags.GetFeatureValue("secret_button"); ``` - + ```javascript const flags = await flagsmith.getEnvironmentFlags(); -var showButton = flags.isFeatureEnabled('secret_button'); -var buttonData = flags.getFeatureValue('secret_button'); +const showButton = flags.isFeatureEnabled('secret_button'); +const buttonData = flags.getFeatureValue('secret_button'); ``` @@ -479,7 +483,7 @@ var showButton = await flags.IsFeatureEnabled("secret_button"); ``` - + ```javascript const identifier = 'delboy@trotterstraders.co.uk'; @@ -662,7 +666,7 @@ static Flag defaultFlagHandler(string featureName) ``` - + ```javascript const flagsmith = new Flagsmith({ @@ -853,18 +857,32 @@ public class MyCustomOfflineHandler: BaseOfflineHandler ``` - + -```javascript -// Using the built-in local file handler -const localFileHandler = new LocalFileHandler('path_to_environment_file/environment_file.json'); -const flagsmith = new Flagsmith({ offlineMode: true, offlineHandler: localFileHandler }); +Use
LocalFileHandler +to read an environment file generated by the [Flagsmith CLI](/clients/CLI): -// Defining a custom offline handler -class CustomOfflineHandler extends BaseOfflineHandler { - getEnvironment(): EnvironmentModel { - return someMethodToGetTheEnvironment(); - } +```typescript +import { Flagsmith, LocalFileHandler } from 'flagsmith-nodejs'; + +const flagsmith = new Flagsmith({ + offlineMode: true, + offlineHandler: new LocalFileHandler('./flagsmith.json'), +}); +``` + +To create your own offline handler, implement the `BaseOfflineHandler` interface. It must return an +{/* prettier-ignore */}EnvironmentModel +object: + +```typescript +import type { BaseOfflineHandler, EnvironmentModel } from 'flagsmith-nodejs'; + +class CustomOfflineHandler implements BaseOfflineHandler { + getEnvironment(): EnvironmentModel { + // ... + } } ``` @@ -1015,10 +1033,9 @@ flagsmith.close(); ``` - + ```javascript -// available from v2.2.1 flagsmith.close(); ``` @@ -1413,10 +1430,11 @@ $flagsmith = Flagsmith::Client.new( ``` - + ```typescript -import { bool, number } from 'prop-types'; +import { Flagsmith } from 'flagsmith-nodejs'; +import type { EnvironmentModel } from 'flagsmith-nodejs'; const flagsmith = new Flagsmith({ /* @@ -1440,9 +1458,8 @@ const flagsmith = new Flagsmith({ See https://docs.flagsmith.com/clients/server-side#caching */ cache: { - has: (key: string) => bool, - get: (key: string) => string | number | null, - set: (k: string, v: Flags) => (cache[k] = v), + get: (key: string) => Promise.resolve(), + set: (k: string, v: Flags) => Promise.resolve(), }, /* @@ -1780,7 +1797,8 @@ client_configuration = Flagsmith.Client.new( ## Caching -The following SDKs have code and functionality related to caching flags. +Some SDKs support caching flags retrieved from the Flagsmith API, or calculated from your environment definition if +using Local Evaluation. @@ -1874,68 +1892,37 @@ final FlagsAndTraits flags = cache.getIfPresent(projectLevelCacheKey); ``` - + -You can initialise the SDK with something like this: +The `cache` option in the `Flagsmith` constructor accepts a cache implementation. This cache must implement the +{/* prettier-ignore */}FlagsmithCache +interface. -```javascript -flagsmith.init({ - cache: { - has:(key)=> return Promise.resolve(!!cache[key]) , // true | false - get: (k)=> cache[k] // return flags or flags for user - set: (k,v)=> cache[k] = v // gets called if has returns false with response from API for Identify or getFlags - } -}) -``` +For example, this cache implementation uses Redis as a backing store: -The core concept is that if `has` returns false, the SDK will make the required API calls under the hood. The keys are -either `flags` or `flags_traits-${identity}`. - -An example of a concrete implemention is below. - -```javascript -const flagsmith = require('flagsmith-nodejs'); -const redis = require('redis'); +```typescript +import { Flagsmith } from 'flagsmith-nodejs'; +import type { BaseOfflineHandler, EnvironmentModel, Flags, FlagsmithCache } from 'flagsmith-nodejs'; +import * as redis from 'redis'; const redisClient = redis.createClient({ - host: 'localhost', - port: 6379, + url: 'localhost:6379', }); -flagsmith.init({ - environmentID: 'FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY', - cache: { - has: (key) => - new Promise((resolve, reject) => { - redisClient.exists(key, (err, reply) => { - console.log('check ' + key + ' from cache', err, reply); - resolve(reply === 1); - }); - }), - get: (key) => - new Promise((resolve) => { - redisClient.get(key, (err, cacheValue) => { - console.log('get ' + key + ' from cache'); - resolve(cacheValue && JSON.parse(cacheValue)); - }); - }), - set: (key, value) => - new Promise((resolve) => { - // Expire the key after 60 seconds - redisClient.set(key, JSON.stringify(value), 'EX', 60, (err, reply) => { - console.log('set ' + key + ' to cache', err); - resolve(); - }); - }), +const redisFlagsmithCache = { + async get(key: string): Promise { + const cachedValue = await redisClient.get(key); + return Promise.resolve(cachedValue && JSON.parse(cachedValue)); }, -}); + async set(key: string, value: Flags): Promise { + await redisClient.set(key, JSON.stringify(value), { EX: 60 }); + return Promise.resolve(); + }, +} satisfies FlagsmithCache; -router.get('/', function (req, res, next) { - flagsmith.getValue('background_colour').then((value) => { - res.render('index', { - title: value, - }); - }); +const flagsmith = new Flagsmith({ + environmentKey: 'ser...', + cache: redisFlagsmithCache, }); ``` @@ -2037,7 +2024,7 @@ https://github.com/Flagsmith/flagsmith-java-client https://github.com/Flagsmith/flagsmith-dotnet-client - + https://github.com/Flagsmith/flagsmith-nodejs-client diff --git a/docs/plugins/flagsmith-versions/index.js b/docs/plugins/flagsmith-versions/index.js index b68835d55499..2c43dcb16d07 100644 --- a/docs/plugins/flagsmith-versions/index.js +++ b/docs/plugins/flagsmith-versions/index.js @@ -69,9 +69,10 @@ export default async function fetchFlagsmithVersions(context, options) { return { name: 'flagsmith-versions', async loadContent() { - const [js, java, android, swiftpm, cocoapods, dotnet, rust, elixir] = await Promise.all( + const [js, nodejs, java, android, swiftpm, cocoapods, dotnet, rust, elixir] = await Promise.all( [ fetchNpmVersions('flagsmith'), + fetchNpmVersions('flagsmith-nodejs'), fetchJavaVersions(), fetchAndroidVersions(), fetchSwiftPMVersions(), @@ -83,6 +84,7 @@ export default async function fetchFlagsmithVersions(context, options) { ); return { js, + nodejs, java, android, swiftpm, diff --git a/docs/src/components/SdkVersions.js b/docs/src/components/SdkVersions.js index 47c96bf871d7..e0426a1d0695 100644 --- a/docs/src/components/SdkVersions.js +++ b/docs/src/components/SdkVersions.js @@ -27,3 +27,4 @@ export const DotnetVersion = ({ spec = '~5' }) => Version({ sdk: 'dotnet', spec export const ElixirVersion = ({ spec = '~2' }) => Version({ sdk: 'elixir', spec }); export const RustVersion = ({ spec = '~2' }) => Version({ sdk: 'rust', spec }); export const JsVersion = ({ spec = '~7' }) => Version({ sdk: 'js', spec }); +export const NodejsVersion = ({ spec } = { spec: '~5' }) => Version({ sdk: 'nodejs', spec }); From 95d4e6c3e11c14a2058c37a1b60d607862b86bc6 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 28 Nov 2024 18:16:16 +0000 Subject: [PATCH 38/77] docs: remove duplicate info message (#4874) --- docs/docs/clients/server-side.mdx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/docs/clients/server-side.mdx b/docs/docs/clients/server-side.mdx index dbfa6500606f..61379725c70d 100644 --- a/docs/docs/clients/server-side.mdx +++ b/docs/docs/clients/server-side.mdx @@ -1050,15 +1050,6 @@ Evaluation mode. Please see [caching](#caching) below. ### Offline Mode -:::info - -Offline mode is still in active development for some SDKs. We are building it for all our SDKs; those that are -production ready are listed below. - -Progress on the remaining SDKs can be seen [here](https://github.com/Flagsmith/flagsmith/issues/2024). - -::: - To run the SDK in a fully offline mode, you can set the client to offline mode. This will prevent the SDK from making any calls to the Flagsmith API. To use offline mode, you must also provide an [offline handler](server-side#using-an-offline-handler). See [Configuring the SDK](server-side#configuring-the-sdk) for From db8433ba0a307b3e27f078aadf94d6ef4e717391 Mon Sep 17 00:00:00 2001 From: Gagan Date: Fri, 29 Nov 2024 16:54:03 +0530 Subject: [PATCH 39/77] feat(environment/views): Add get-by-uuid action (#4875) --- .../migrations/0037_add_uuid_field.py | 29 +++++++++++++++ api/environments/models.py | 6 ++- api/environments/serializers.py | 1 + api/environments/views.py | 22 ++++++++++- .../test_unit_environments_views.py | 37 +++++++++++++++++++ 5 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 api/environments/migrations/0037_add_uuid_field.py diff --git a/api/environments/migrations/0037_add_uuid_field.py b/api/environments/migrations/0037_add_uuid_field.py new file mode 100644 index 000000000000..eaf32b1c642b --- /dev/null +++ b/api/environments/migrations/0037_add_uuid_field.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.23 on 2024-11-27 08:46 + +from django.db import migrations, models +import uuid + +from core.migration_helpers import AddDefaultUUIDs + + +class Migration(migrations.Migration): + dependencies = [ + ("environments", "0036_add_is_creating_field"), + ] + + operations = [ + migrations.AddField( + model_name="environment", + name="uuid", + field=models.UUIDField(editable=False, null=True), + ), + migrations.RunPython( + AddDefaultUUIDs("environments", "environment"), + reverse_code=migrations.RunPython.noop, + ), + migrations.AlterField( + model_name="environment", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/api/environments/models.py b/api/environments/models.py index f1cf5adc5132..b01b15c89453 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -1,5 +1,6 @@ import logging import typing +import uuid from copy import deepcopy from core.models import abstract_base_auditable_model_factory @@ -63,7 +64,8 @@ class Environment( LifecycleModel, abstract_base_auditable_model_factory( - change_details_excluded_fields=["updated_at"] + change_details_excluded_fields=["updated_at"], + historical_records_excluded_fields=["uuid"], ), SoftDeleteObject, ): @@ -71,6 +73,7 @@ class Environment( related_object_type = RelatedObjectType.ENVIRONMENT name = models.CharField(max_length=2000) + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) created_date = models.DateTimeField("DateCreated", auto_now_add=True) description = models.TextField(null=True, blank=True, max_length=20000) project = models.ForeignKey( @@ -178,6 +181,7 @@ def clone( """ clone = deepcopy(self) clone.id = None + clone.uuid = uuid.uuid4() clone.name = name clone.api_key = api_key if api_key else create_hash() clone.is_creating = True diff --git a/api/environments/serializers.py b/api/environments/serializers.py index 4d2836732859..70ac6f9be8cc 100644 --- a/api/environments/serializers.py +++ b/api/environments/serializers.py @@ -44,6 +44,7 @@ class Meta: model = Environment fields = ( "id", + "uuid", "name", "api_key", "description", diff --git a/api/environments/views.py b/api/environments/views.py index b60b8a1ab929..c035e8bc6218 100644 --- a/api/environments/views.py +++ b/api/environments/views.py @@ -1,13 +1,17 @@ import logging -from common.environments.permissions import TAG_SUPPORTED_PERMISSIONS +from common.environments.permissions import ( + TAG_SUPPORTED_PERMISSIONS, + VIEW_ENVIRONMENT, +) from django.db.models import Count, Q from django.utils.decorators import method_decorator from drf_yasg import openapi from drf_yasg.utils import no_body, swagger_auto_schema from rest_framework import mixins, status, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -142,6 +146,20 @@ def perform_create(self, serializer): user=self.request.user, environment=environment, admin=True ) + @action( + detail=False, + url_path=r"get-by-uuid/(?P[0-9a-f-]+)", + methods=["get"], + ) + def get_by_uuid(self, request, uuid): + qs = self.get_queryset() + environment = get_object_or_404(qs, uuid=uuid) + if not request.user.has_environment_permission(VIEW_ENVIRONMENT, environment): + raise PermissionDenied() + + serializer = self.get_serializer(environment) + return Response(serializer.data) + @action(detail=True, methods=["GET"], url_path="trait-keys") def trait_keys(self, request, *args, **kwargs): keys = [ diff --git a/api/tests/unit/environments/test_unit_environments_views.py b/api/tests/unit/environments/test_unit_environments_views.py index bbe4102330f7..41c39911b4e6 100644 --- a/api/tests/unit/environments/test_unit_environments_views.py +++ b/api/tests/unit/environments/test_unit_environments_views.py @@ -76,6 +76,43 @@ def test_retrieve_environment( ) +def test_get_by_uuid_returns_environment( + staff_client: APIClient, + environment: Environment, + with_environment_permissions: WithEnvironmentPermissionsCallable, +) -> None: + # Given + with_environment_permissions([VIEW_ENVIRONMENT]) + + url = reverse( + "api-v1:environments:environment-get-by-uuid", + args=[environment.uuid], + ) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["uuid"] == str(environment.uuid) + + +def test_get_by_uuid_returns_403_for_user_without_permission( + staff_client: APIClient, environment: Environment +) -> None: + # Given + url = reverse( + "api-v1:environments:environment-get-by-uuid", + args=[environment.uuid], + ) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_user_with_view_environment_permission_can_retrieve_environment( staff_client: APIClient, environment: Environment, From 6c4090d73e0c47dc8f66ad70693055645c8fd68f Mon Sep 17 00:00:00 2001 From: Gagan Date: Fri, 29 Nov 2024 17:23:47 +0530 Subject: [PATCH 40/77] deps: bump rbac to fix role permission (#4877) --- api/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/Makefile b/api/Makefile index eca334728e26..5c5fd7ea3e12 100644 --- a/api/Makefile +++ b/api/Makefile @@ -12,7 +12,7 @@ POETRY_VERSION ?= 1.8.3 GUNICORN_LOGGER_CLASS ?= util.logging.GunicornJsonCapableLogger SAML_REVISION ?= v1.6.4 -RBAC_REVISION ?= v0.11.0 +RBAC_REVISION ?= v0.11.1 -include .env-local -include $(DOTENV_OVERRIDE_FILE) From 90469307ee534807ee8029b5d7613710567b1737 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Dec 2024 09:18:12 +0000 Subject: [PATCH 41/77] chore: update Flagsmith environment document (#4881) Co-authored-by: matthewelwell --- api/integrations/flagsmith/data/environment.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/integrations/flagsmith/data/environment.json b/api/integrations/flagsmith/data/environment.json index f843b8ee43e1..044a1d59c92c 100644 --- a/api/integrations/flagsmith/data/environment.json +++ b/api/integrations/flagsmith/data/environment.json @@ -15,7 +15,7 @@ "multivariate_feature_state_values": [] }, { - "django_id": 627339, + "django_id": 638869, "enabled": true, "feature": { "id": 96656, @@ -24,11 +24,11 @@ }, "feature_segment": null, "feature_state_value": null, - "featurestate_uuid": "566228a6-78b0-4696-a023-feb149b61fd2", + "featurestate_uuid": "768e6102-7a0c-49f6-9d9f-097e376ed76c", "multivariate_feature_state_values": [] }, { - "django_id": 627341, + "django_id": 638871, "enabled": true, "feature": { "id": 96657, @@ -37,7 +37,7 @@ }, "feature_segment": null, "feature_state_value": null, - "featurestate_uuid": "4f7cae64-afed-416c-ada8-e6b034dff436", + "featurestate_uuid": "346cc272-dabe-4993-9cfa-76afd0d848f1", "multivariate_feature_state_values": [] }, { From 06ed466864d723ac43bad9ec8fa738a8e9d0812d Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 3 Dec 2024 14:49:55 +0530 Subject: [PATCH 42/77] feat(org/view): Add api to get organisation by uuid (#4878) --- api/organisations/serializers.py | 1 + api/organisations/views.py | 13 ++++++- .../test_unit_organisations_views.py | 36 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index fbb09b1eb23c..8a14bd924817 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -36,6 +36,7 @@ class Meta: model = Organisation fields = ( "id", + "uuid", "name", "created_date", "webhook_notification_email", diff --git a/api/organisations/views.py b/api/organisations/views.py index 33bd581441a9..7a7be7d61255 100644 --- a/api/organisations/views.py +++ b/api/organisations/views.py @@ -16,7 +16,7 @@ from rest_framework.authentication import BasicAuthentication from rest_framework.decorators import action, api_view, authentication_classes from rest_framework.exceptions import ValidationError -from rest_framework.generics import ListAPIView +from rest_framework.generics import ListAPIView, get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -124,6 +124,17 @@ def create(self, request, **kwargs): else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @action( + detail=False, + url_path=r"get-by-uuid/(?P[0-9a-f-]+)", + methods=["get"], + ) + def get_by_uuid(self, request, uuid): + qs = self.get_queryset() + organisation = get_object_or_404(qs, uuid=uuid) + serializer = self.get_serializer(organisation) + return Response(serializer.data) + @action(detail=True, permission_classes=[IsAuthenticated]) def projects(self, request, pk): organisation = self.get_object() diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index d55b28e4fb20..16b72e671001 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -71,6 +71,42 @@ def test_should_return_organisation_list_when_requested( assert response.data["results"][0]["name"] == organisation.name +def test_get_by_uuid_returns_organisation( + admin_client: APIClient, + organisation: Organisation, +) -> None: + # Given + url = reverse( + "api-v1:organisations:organisation-get-by-uuid", + args=[organisation.uuid], + ) + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["uuid"] == str(organisation.uuid) + + +def test_get_by_uuid_returns_404_for_organisation_that_does_not_belong_to_the_user( + admin_client: APIClient, + organisation: Organisation, +) -> None: + # Given + different_org = Organisation.objects.create(name="Different org") + url = reverse( + "api-v1:organisations:organisation-get-by-uuid", + args=[different_org.uuid], + ) + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_non_superuser_can_create_new_organisation_by_default( staff_client: APIClient, staff_user: FFAdminUser, From 81c96f4a947ef608443d2d4ecac8ccee667e7bec Mon Sep 17 00:00:00 2001 From: Flagsmith Bot <65724737+flagsmithdev@users.noreply.github.com> Date: Tue, 3 Dec 2024 10:38:34 +0000 Subject: [PATCH 43/77] chore(main): release 2.155.0 (#4858) --- .release-please-manifest.json | 2 +- CHANGELOG.md | 15 +++++++++++++++ version.txt | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1670b75b9e3a..7beabe708fd1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.154.0" + ".": "2.155.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b999b0a7f54..2cc89e66f633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2.155.0](https://github.com/Flagsmith/flagsmith/compare/v2.154.0...v2.155.0) (2024-12-03) + + +### Features + +* Create change requests for segments ([#4265](https://github.com/Flagsmith/flagsmith/issues/4265)) ([355f4e2](https://github.com/Flagsmith/flagsmith/commit/355f4e2827dc0a0cba169a320c7a0db8b522089e)) +* **environment/views:** Add get-by-uuid action ([#4875](https://github.com/Flagsmith/flagsmith/issues/4875)) ([db8433b](https://github.com/Flagsmith/flagsmith/commit/db8433ba0a307b3e27f078aadf94d6ef4e717391)) +* **org/view:** Add api to get organisation by uuid ([#4878](https://github.com/Flagsmith/flagsmith/issues/4878)) ([06ed466](https://github.com/Flagsmith/flagsmith/commit/06ed466864d723ac43bad9ec8fa738a8e9d0812d)) + + +### Bug Fixes + +* Environment Ready Checker ([#4865](https://github.com/Flagsmith/flagsmith/issues/4865)) ([2392222](https://github.com/Flagsmith/flagsmith/commit/23922223d4367792b196d798cb2571dc8f589ee1)) +* prevent enabling versioning from affecting scheduled change requests ([#4872](https://github.com/Flagsmith/flagsmith/issues/4872)) ([c7aa30b](https://github.com/Flagsmith/flagsmith/commit/c7aa30bb217deca7ea6a0b0657d1a08076f2ab44)) + ## [2.154.0](https://github.com/Flagsmith/flagsmith/compare/v2.153.0...v2.154.0) (2024-11-21) diff --git a/version.txt b/version.txt index ebe9185df092..9170c650ade7 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.154.0 +2.155.0 From 7d3b2531c9abc02aceeee5193472ddc92bc772ac Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Tue, 3 Dec 2024 05:52:59 -0500 Subject: [PATCH 44/77] fix: Set Hubspot cookie name from request body (#4880) --- .../lead_tracking/hubspot/services.py | 32 +++++++++---------- .../invites/test_unit_invites_views.py | 8 +++-- .../test_unit_organisations_views.py | 3 +- api/tests/unit/users/test_unit_users_views.py | 8 ++--- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/api/integrations/lead_tracking/hubspot/services.py b/api/integrations/lead_tracking/hubspot/services.py index 285fbe5a74d6..324af4f8bfcd 100644 --- a/api/integrations/lead_tracking/hubspot/services.py +++ b/api/integrations/lead_tracking/hubspot/services.py @@ -9,23 +9,21 @@ def register_hubspot_tracker(request: Request) -> None: - hubspot_cookie = request.COOKIES.get(HUBSPOT_COOKIE_NAME) + hubspot_cookie = request.data.get(HUBSPOT_COOKIE_NAME) - # TODO: Remove this temporary debugging logger statement. - logger.info(f"Request cookies for user {request.user.email}: {request.COOKIES}") - - if hubspot_cookie: - logger.info( - f"Creating HubspotTracker instance for user {request.user.email} with cookie {hubspot_cookie}" - ) - - HubspotTracker.objects.update_or_create( - user=request.user, - defaults={ - "hubspot_cookie": hubspot_cookie, - }, - ) - else: + if not hubspot_cookie: logger.info( - f"Could not create HubspotTracker instance for user {request.user.email} since no cookie" + f"Request did not included Hubspot data for user {request.user.email}" ) + return + + logger.info( + f"Creating HubspotTracker instance for user {request.user.email} with cookie {hubspot_cookie}" + ) + + HubspotTracker.objects.update_or_create( + user=request.user, + defaults={ + "hubspot_cookie": hubspot_cookie, + }, + ) diff --git a/api/tests/unit/organisations/invites/test_unit_invites_views.py b/api/tests/unit/organisations/invites/test_unit_invites_views.py index bdaecdb0f5e0..b9082f0d9dfb 100644 --- a/api/tests/unit/organisations/invites/test_unit_invites_views.py +++ b/api/tests/unit/organisations/invites/test_unit_invites_views.py @@ -14,7 +14,7 @@ from organisations.invites.models import Invite, InviteLink from organisations.models import Organisation, OrganisationRole, Subscription -from users.models import FFAdminUser +from users.models import FFAdminUser, HubspotTracker def test_create_invite_link( @@ -166,13 +166,17 @@ def test_join_organisation_with_permission_groups( subscription.save() url = reverse("api-v1:users:user-join-organisation", args=[invite.hash]) + data = {"hubspotutk": "somehubspotdata"} # When - response = test_user_client.post(url) + response = test_user_client.post(url, data) test_user.refresh_from_db() # Then assert response.status_code == status.HTTP_200_OK + hubspot_tracker = HubspotTracker.objects.first() + assert hubspot_tracker.user == test_user + assert organisation in test_user.organisations.all() assert user_permission_group in test_user.permission_groups.all() # and invite is deleted diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 16b72e671001..2f0fd2cdd123 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -118,8 +118,9 @@ def test_non_superuser_can_create_new_organisation_by_default( data = { "name": org_name, "webhook_notification_email": webhook_notification_email, + HUBSPOT_COOKIE_NAME: "test_cookie_tracker", } - staff_client.cookies[HUBSPOT_COOKIE_NAME] = "test_cookie_tracker" + assert not HubspotTracker.objects.filter(user=staff_user).exists() # When diff --git a/api/tests/unit/users/test_unit_users_views.py b/api/tests/unit/users/test_unit_users_views.py index f66f8cf01c71..2856d3afbfa3 100644 --- a/api/tests/unit/users/test_unit_users_views.py +++ b/api/tests/unit/users/test_unit_users_views.py @@ -35,11 +35,11 @@ def test_join_organisation( organisation = Organisation.objects.create(name="test org") invite = Invite.objects.create(email=staff_user.email, organisation=organisation) url = reverse("api-v1:users:user-join-organisation", args=[invite.hash]) - staff_client.cookies[HUBSPOT_COOKIE_NAME] = "test_cookie_tracker" + data = {HUBSPOT_COOKIE_NAME: "test_cookie_tracker"} assert not HubspotTracker.objects.filter(user=staff_user).exists() # When - response = staff_client.post(url) + response = staff_client.post(url, data) staff_user.refresh_from_db() # Then @@ -56,11 +56,11 @@ def test_join_organisation_via_link( organisation = Organisation.objects.create(name="test org") invite = InviteLink.objects.create(organisation=organisation) url = reverse("api-v1:users:user-join-organisation-link", args=[invite.hash]) - staff_client.cookies[HUBSPOT_COOKIE_NAME] = "test_cookie_tracker" + data = {HUBSPOT_COOKIE_NAME: "test_cookie_tracker"} assert not HubspotTracker.objects.filter(user=staff_user).exists() # When - response = staff_client.post(url) + response = staff_client.post(url, data) staff_user.refresh_from_db() # Then From d62d29fa955a8e436c81945050b4dc35865722a6 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 3 Dec 2024 16:23:23 +0530 Subject: [PATCH 45/77] deps: bump workflow and common (#4879) --- api/poetry.lock | 35 ++++++++++++++++++++++++++++------- api/pyproject.toml | 4 ++-- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index 3ca034e57912..baaa050c3818 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1362,8 +1362,8 @@ flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-common" -reference = "v1.1.0" -resolved_reference = "27fbd8b7d889dc1529df08972a8c1bfaba5a7e03" +reference = "v1.2.0" +resolved_reference = "ccb6c0603168c91cfb8f9f24bdfa21741eb5916e" [[package]] name = "flagsmith-flag-engine" @@ -2147,6 +2147,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -3317,6 +3327,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -3324,8 +3335,16 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -3342,6 +3361,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -3349,6 +3369,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -4040,14 +4061,14 @@ develop = false [package.dependencies] djangorestframework = "*" djangorestframework-recursive = "*" -flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.1.0"} +flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.2.0"} flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/flagsmith/flagsmith-workflows" -reference = "v2.6.1" -resolved_reference = "c3c05dec3aa4040f43bd40467736981c8804a129" +reference = "v2.7.0" +resolved_reference = "3bd546a451e7f2359aa9787ec8ffa5e4315d12cf" [[package]] name = "wrapt" @@ -4166,4 +4187,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "57d603e5e64c9688d51cdf536e95b13a51578002f2584fce94d5eecbd5286be9" +content-hash = "487ea7b46f083a1be71662f027a0b170c60e5aa4e35d083224ad4cd4697ec7d1" diff --git a/api/pyproject.toml b/api/pyproject.toml index 777f19033742..28cff6880c0b 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -170,7 +170,7 @@ hubspot-api-client = "^8.2.1" djangorestframework-dataclasses = "^1.3.1" pyotp = "^2.9.0" flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.0.2" } -flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.1.0" } +flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.2.0" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" @@ -196,7 +196,7 @@ flagsmith-ldap = { git = "https://github.com/flagsmith/flagsmith-ldap", tag = "v optional = true [tool.poetry.group.workflows.dependencies] -workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.6.1" } +workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.0" } [tool.poetry.group.dev.dependencies] django-test-migrations = "~1.2.0" From e17f92df02c5b08a8f6ba498ce49185d37beb994 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 3 Dec 2024 10:53:40 +0000 Subject: [PATCH 46/77] fix: add migration to clean up corrupt data caused by feature versioning (#4873) --- ...ata_issue_caused_by_enabling_versioning.py | 71 ++++++++ .../test_unit_versioning_migrations.py | 154 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 api/features/versioning/migrations/0005_fix_scheduled_fs_data_issue_caused_by_enabling_versioning.py create mode 100644 api/tests/unit/features/versioning/test_unit_versioning_migrations.py diff --git a/api/features/versioning/migrations/0005_fix_scheduled_fs_data_issue_caused_by_enabling_versioning.py b/api/features/versioning/migrations/0005_fix_scheduled_fs_data_issue_caused_by_enabling_versioning.py new file mode 100644 index 000000000000..88bb9efa66b4 --- /dev/null +++ b/api/features/versioning/migrations/0005_fix_scheduled_fs_data_issue_caused_by_enabling_versioning.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.16 on 2024-11-28 13:45 +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.models import F + + +def fix_corrupted_feature_states(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + feature_state_model_class = apps.get_model("features", "FeatureState") + environment_feature_version_model_class = apps.get_model( + "feature_versioning", "EnvironmentFeatureVersion" + ) + + feature_states_to_update = [] + environment_feature_versions_to_update = [] + + for feature_state in feature_state_model_class.objects.filter( + # We're looking for feature states that live in environments + # that don't have versioning enabled, but have an environment + # feature version associated to them. Moreover, those environment + # feature versions should have a change request associated with + # them. See this PR for further details of the bug we're looking + # to clean up after: https://github.com/Flagsmith/flagsmith/pull/4872 + environment__use_v2_feature_versioning=False, + environment_feature_version__isnull=False, + environment_feature_version__change_request__isnull=False, + ).exclude( + # Just to be safe, we exclude feature states for which the version + # belongs to the same feature and environment. This will prevent us + # from accidentally modifying any feature states that belong to + # legitimate environment feature versions but perhaps versioning + # has been switched off for the environment. + environment_feature_version__feature=F("feature"), + environment_feature_version__environment=F("environment") + ).select_related("environment_feature_version"): + environment_feature_version = feature_state.environment_feature_version + + # 1. move the change request back to the feature state + feature_state.change_request = environment_feature_version.change_request + + # 2. remove the change request from the EnvironmentFeatureVersion + environment_feature_version.change_request = None + + # 3. remove the environment feature version from the feature state + feature_state.environment_feature_version = None + + feature_states_to_update.append(feature_state) + environment_feature_versions_to_update.append(environment_feature_version) + + feature_state_model_class.objects.bulk_update( + feature_states_to_update, + ["environment_feature_version", "change_request"], + ) + environment_feature_version_model_class.objects.bulk_update( + environment_feature_versions_to_update, + ["change_request"], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("feature_versioning", "0004_add_version_change_set"), + ] + + operations = [ + migrations.RunPython( + fix_corrupted_feature_states, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/api/tests/unit/features/versioning/test_unit_versioning_migrations.py b/api/tests/unit/features/versioning/test_unit_versioning_migrations.py new file mode 100644 index 000000000000..88fc94b4d246 --- /dev/null +++ b/api/tests/unit/features/versioning/test_unit_versioning_migrations.py @@ -0,0 +1,154 @@ +from datetime import timedelta + +from django.utils import timezone +from django_test_migrations.migrator import Migrator + + +def test_fix_scheduled_fs_data_issue_caused_by_enabling_versioning( + migrator: Migrator +) -> None: + # Given + now = timezone.now() + one_hour_from_now = now + timedelta(hours=1) + + initial_state = migrator.apply_initial_migration(("feature_versioning", "0004_add_version_change_set")) + + organisation_model_class = initial_state.apps.get_model("organisations", "Organisation") + project_model_class = initial_state.apps.get_model("projects", "Project") + environment_model_class = initial_state.apps.get_model("environments", "Environment") + change_request_model_class = initial_state.apps.get_model("workflows_core", "ChangeRequest") + environment_feature_version_model_class = initial_state.apps.get_model( + "feature_versioning", "EnvironmentFeatureVersion" + ) + feature_state_model_class = initial_state.apps.get_model("features", "FeatureState") + feature_model_class = initial_state.apps.get_model("features", "Feature") + + organisation = organisation_model_class.objects.create(name="Test Organisation") + project = project_model_class.objects.create(name="Test Project", organisation=organisation) + environment_1 = environment_model_class.objects.create(name="Environment 1", project=project) + feature = feature_model_class.objects.create(name="test_feature", project=project) + + # First, let's create some regular data that should be left untouched for a + # non-versioned environment + # Note: because migrations don't trigger the signals, we need to manually create + # the default state + environment_1_default_feature_state = feature_state_model_class.objects.create( + feature=feature, + environment=environment_1, + version=1, + ) + environment_1_live_cr = change_request_model_class.objects.create( + title="Live CR for Environment 1", + environment=environment_1, + ) + environment_1_live_feature_state = feature_state_model_class.objects.create( + feature=feature, + environment=environment_1, + enabled=True, + live_from=one_hour_from_now, + version=2, + change_request=environment_1_live_cr, + ) + + # ... and a versioned environment + versioned_environment = environment_model_class.objects.create( + name="Versioned Environment", + project=project, + use_v2_feature_versioning=True, + ) + versioned_environment_version_1 = environment_feature_version_model_class.objects.create( + feature=feature, + environment=versioned_environment, + ) + versioned_environment_default_feature_state = feature_state_model_class.objects.create( + feature=feature, + environment=versioned_environment, + environment_feature_version=versioned_environment_version_1, + ) + versioned_environment_cr = change_request_model_class.objects.create( + environment=versioned_environment, + title="Versioned Environment CR", + ) + versioned_environment_cr_version = environment_feature_version_model_class.objects.create( + feature=feature, + environment=versioned_environment, + change_request=versioned_environment_cr, + ) + versioned_environment_change_request_feature_state = feature_state_model_class.objects.create( + feature=feature, + environment=versioned_environment, + environment_feature_version=versioned_environment_cr_version, + ) + + # Now, let's create the corrupt data that we want to correct + environment_2 = environment_model_class.objects.create(name="Environment 2", project=project) + # Note: because migrations don't trigger the signals, we need to manually create + # the default state + feature_state_model_class.objects.create( + feature=feature, + environment=environment_2, + version=1, + ) + environment_2_scheduled_cr = change_request_model_class.objects.create( + title="Environment 2 Scheduled CR", + environment=environment_2, + ) + environment_2_scheduled_feature_state = feature_state_model_class.objects.create( + feature=feature, + environment=environment_2, + live_from=one_hour_from_now, + version=2, + change_request=environment_2_scheduled_cr, + ) + + # and let's explicitly write out the corruption steps as they would have occurred + # when any environment has feature versioning enabled. + # 1. The scheduled feature state for environment 2 would be associated with a new + # environment feature version in that environment. + new_version = environment_feature_version_model_class.objects.create( + environment=versioned_environment, + feature=feature, + ) + environment_2_scheduled_feature_state.environment_feature_version = new_version + + # 2. The change request would be removed from the feature state + environment_2_scheduled_feature_state.change_request = None + + # 3. The change request would be added to the new version instead + new_version.change_request = environment_2_scheduled_cr + + # 4. Let's save the models with the modifications + new_version.save() + environment_2_scheduled_feature_state.save() + + # When + new_state = migrator.apply_tested_migration( + ( + "feature_versioning", + "0005_fix_scheduled_fs_data_issue_caused_by_enabling_versioning", + ) + ) + + # Then + feature_state_model_class = new_state.apps.get_model("features", "FeatureState") + + # Let's check that the regular data is untouched + assert feature_state_model_class.objects.get( + pk=environment_1_default_feature_state.pk + ).environment_feature_version is None + assert feature_state_model_class.objects.get( + pk=environment_1_live_feature_state.pk + ).environment_feature_version is None + assert feature_state_model_class.objects.get( + pk=versioned_environment_default_feature_state.pk + ).environment_feature_version_id == versioned_environment_version_1.pk + assert feature_state_model_class.objects.get( + pk=versioned_environment_change_request_feature_state.pk + ).environment_feature_version_id == versioned_environment_cr_version.pk + + # And let's check the corrupted data issue has been solved + new_environment_2_scheduled_feature_state = feature_state_model_class.objects.get( + pk=environment_2_scheduled_feature_state.pk + ) + assert new_environment_2_scheduled_feature_state.environment_feature_version is None + assert new_environment_2_scheduled_feature_state.change_request_id == environment_2_scheduled_cr.pk From bb7849c799fff98c1750adf1d5a057479994d41b Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 3 Dec 2024 10:59:54 +0000 Subject: [PATCH 47/77] feat: Scheduled segment overrides (#4805) --- frontend/web/components/base/forms/Button.tsx | 11 +- frontend/web/components/modals/CreateFlag.js | 211 +++++++++++------- 2 files changed, 133 insertions(+), 89 deletions(-) diff --git a/frontend/web/components/base/forms/Button.tsx b/frontend/web/components/base/forms/Button.tsx index 306070be37be..282bb21c87bb 100644 --- a/frontend/web/components/base/forms/Button.tsx +++ b/frontend/web/components/base/forms/Button.tsx @@ -56,13 +56,13 @@ export const Button: FC = ({ const hasPlan = feature ? Utils.getPlansPermission(feature) : true return href || !hasPlan ? ( - {!!iconLeft && ( + {!!iconLeft && !!hasPlan && ( = ({ width={iconSize} /> )} - {children} +
+ {children} + {!hasPlan && } +
{!!iconRight && ( { - this.setState({ segmentsChanged: false }) + const saveFeatureSegments = saveFeatureWithValidation( + (schedule) => { + this.setState({ segmentsChanged: false }) - if (is4Eyes && isVersioned && !identity) { - openModal2( - this.props.changeRequest - ? 'Update Change Request' - : 'New Change Request', - { - closeModal2() - this.save( - ( - projectId, - environmentId, - flag, - projectFlag, - environmentFlag, - segmentOverrides, - ) => { - createChangeRequest( + if ((is4Eyes || schedule) && isVersioned && !identity) { + openModal2( + this.props.changeRequest + ? 'Update Change Request' + : schedule + ? 'Schedule Segment Overrides Update' + : 'New Change Request', + { + closeModal2() + this.save( + ( projectId, environmentId, flag, projectFlag, environmentFlag, segmentOverrides, - { - approvals, - description, - featureStateId: - this.props.changeRequest && - this.props.changeRequest.feature_states[0].id, - id: - this.props.changeRequest && - this.props.changeRequest.id, - live_from, - multivariate_options: this.props - .multivariate_options - ? this.props.multivariate_options.map((v) => { - const matching = - this.state.multivariate_options.find( - (m) => - m.id === - v.multivariate_feature_option, - ) - return { - ...v, - percentage_allocation: - matching.default_percentage_allocation, - } - }) - : this.state.multivariate_options, - title, - }, - !is4Eyes, - 'SEGMENT', - ) - }, - ) - }} - />, - ) - } else { - this.save(editFeatureSegments, isSaving) - } - }) + ) => { + createChangeRequest( + projectId, + environmentId, + flag, + projectFlag, + environmentFlag, + segmentOverrides, + { + approvals, + description, + featureStateId: + this.props.changeRequest && + this.props.changeRequest.feature_states[0] + .id, + id: + this.props.changeRequest && + this.props.changeRequest.id, + live_from, + multivariate_options: this.props + .multivariate_options + ? this.props.multivariate_options.map( + (v) => { + const matching = + this.state.multivariate_options.find( + (m) => + m.id === + v.multivariate_feature_option, + ) + return { + ...v, + percentage_allocation: + matching.default_percentage_allocation, + } + }, + ) + : this.state.multivariate_options, + title, + }, + !is4Eyes, + 'SEGMENT', + ) + }, + ) + }} + />, + ) + } else { + this.save(editFeatureSegments, isSaving) + } + }, + ) const onCreateFeature = saveFeatureWithValidation(() => { this.save(createFlag, isSaving) @@ -1485,24 +1492,58 @@ const CreateFlag = class extends Component { Constants.environmentPermissions( 'Manage segment overrides', ), - , + <> + {!is4Eyes && + isVersioned && ( + <> + + + )} + + , ) }} From e6b0e2f799d0c6bc513e622ca19b9698f6f76db2 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 3 Dec 2024 13:18:10 +0000 Subject: [PATCH 48/77] feat: combine value + segment change requests for versioned environments (#4738) Co-authored-by: Matthew Elwell --- frontend/common/dispatcher/app-actions.js | 2 - .../common/providers/FeatureListProvider.js | 2 - frontend/common/services/useFeatureVersion.ts | 56 +++++++----- frontend/common/stores/feature-list-store.ts | 47 ++++------ frontend/web/components/modals/CreateFlag.js | 89 +++---------------- 5 files changed, 65 insertions(+), 131 deletions(-) diff --git a/frontend/common/dispatcher/app-actions.js b/frontend/common/dispatcher/app-actions.js index f284e64ec028..0eab0e1d1245 100644 --- a/frontend/common/dispatcher/app-actions.js +++ b/frontend/common/dispatcher/app-actions.js @@ -143,7 +143,6 @@ const AppActions = Object.assign({}, require('./base/_app-actions'), { segmentOverrides, changeRequest, commit, - mode, ) { Dispatcher.handleViewAction({ actionType: Actions.EDIT_ENVIRONMENT_FLAG_CHANGE_REQUEST, @@ -152,7 +151,6 @@ const AppActions = Object.assign({}, require('./base/_app-actions'), { environmentFlag, environmentId, flag, - mode, projectFlag, projectId, segmentOverrides, diff --git a/frontend/common/providers/FeatureListProvider.js b/frontend/common/providers/FeatureListProvider.js index 9d0edcce9782..3b8738bcf141 100644 --- a/frontend/common/providers/FeatureListProvider.js +++ b/frontend/common/providers/FeatureListProvider.js @@ -192,7 +192,6 @@ const FeatureListProvider = class extends React.Component { segmentOverrides, changeRequest, commit, - mode, ) => { AppActions.editFeatureMv( projectId, @@ -224,7 +223,6 @@ const FeatureListProvider = class extends React.Component { segmentOverrides, changeRequest, commit, - mode, ) }, ) diff --git a/frontend/common/services/useFeatureVersion.ts b/frontend/common/services/useFeatureVersion.ts index 9e81a84bf60b..8321873bf90c 100644 --- a/frontend/common/services/useFeatureVersion.ts +++ b/frontend/common/services/useFeatureVersion.ts @@ -43,35 +43,43 @@ export const getFeatureStateCrud = ( if (!oldFeatureStates) { return featureStates } - if (segments?.length) { - // filter out feature states that have no changes - const segmentDiffs = getSegmentDiff( - featureStates, - oldFeatureStates, - segments, - ) - return featureStates.filter((v) => { - const diff = segmentDiffs?.diffs?.find( - (diff) => v.feature_segment?.segment === diff.segment.id, + const segmentDiffs = segments?.length + ? getSegmentDiff( + featureStates.filter((v) => !!v.feature_segment), + oldFeatureStates.filter((v) => !!v.feature_segment), + segments, ) - return !!diff?.totalChanges - }) - } else { - // return nothing if feature state isn't different - const valueDiff = getFeatureStateDiff( - featureStates[0], - oldFeatureStates[0], + : null + const featureStateDiffs = featureStates.filter((v) => { + if (!v.feature_segment) return + const diff = segmentDiffs?.diffs?.find( + (diff) => v.feature_segment?.segment === diff.segment.id, ) - if (!valueDiff.totalChanges) { - const variationDiff = getVariationDiff( - featureStates[0], - oldFeatureStates[0], - ) - if (!variationDiff.totalChanges) return [] + return !!diff?.totalChanges + }) + const newValueFeatureState = featureStates.find((v) => !v.feature_segment)! + const oldValueFeatureState = oldFeatureStates.find( + (v) => !v.feature_segment, + )! + // return nothing if feature state isn't different + const valueDiff = getFeatureStateDiff( + oldValueFeatureState, + newValueFeatureState, + ) + if (!valueDiff.totalChanges) { + const variationDiff = getVariationDiff( + oldValueFeatureState, + newValueFeatureState, + ) + if (variationDiff.totalChanges) { + featureStateDiffs.push(newValueFeatureState) } - return featureStates + } else { + featureStateDiffs.push(newValueFeatureState) } + return featureStateDiffs } + const featureStatesToCreate = featureStates.filter( (v) => !v.id && !v.toRemove, ) diff --git a/frontend/common/stores/feature-list-store.ts b/frontend/common/stores/feature-list-store.ts index 2843884ef7e8..ebc4fa9ccc92 100644 --- a/frontend/common/stores/feature-list-store.ts +++ b/frontend/common/stores/feature-list-store.ts @@ -12,12 +12,12 @@ import { } from 'common/services/useProjectFlag' import OrganisationStore from './organisation-store' import { - ChangeRequest, - Environment, - FeatureState, - PagedResponse, - ProjectFlag, -} from 'common/types/responses' + ChangeRequest, + Environment, + FeatureState, + PagedResponse, + ProjectFlag, TypedFeatureState, +} from 'common/types/responses'; import Utils from 'common/utils/utils' import Actions from 'common/dispatcher/action-constants' import Project from 'common/project' @@ -466,14 +466,13 @@ const controller = { segmentOverrides: any, changeRequest: Req['createChangeRequest'], commit: boolean, - mode: 'VALUE' | 'SEGMENT', ) => { store.saving() try { API.trackEvent(Constants.events.EDIT_FEATURE) const env: Environment = ProjectStore.getEnvironment(environmentId) as any // Detect differences between change request and existing feature states - const res: { data: PagedResponse } = await getFeatureStates( + const res: { data: PagedResponse } = await getFeatureStates( getStore(), { environment: environmentFlag.environment, @@ -483,19 +482,15 @@ const controller = { forceRefetch: true, }, ) - let segments = null - if (mode === 'SEGMENT') { - const res = await getSegments(getStore(), { - include_feature_specific: true, - page_size: 1000, - projectId, - }) - segments = res.data.results - } - const oldFeatureStates = res.data.results.filter((v) => { - return mode === 'VALUE' ? !v.feature_segment : !!v.feature_segment + const segmentResult = await getSegments(getStore(), { + include_feature_specific: true, + page_size: 1000, + projectId, }) + const segments = segmentResult.data.results + const oldFeatureStates = res.data.results + let feature_states_to_create: | Req['createFeatureVersion']['feature_states_to_create'] | undefined = undefined, @@ -506,23 +501,20 @@ const controller = { | Req['createFeatureVersion']['segment_ids_to_delete_overrides'] | undefined = undefined if (env.use_v2_feature_versioning) { - let featureStates - if (mode === 'SEGMENT') { - featureStates = segmentOverrides?.map((override: any, i: number) => + const featureStates = (segmentOverrides || []) + .map((override: any, i: number) => convertSegmentOverrideToFeatureState(override, i, changeRequest), ) - } else { - featureStates = [ + .concat([ Object.assign({}, environmentFlag, { enabled: flag.default_enabled, feature_state_value: flag.initial_value, live_from: flag.live_from, }), - ] - } + ]) const version = getFeatureStateCrud( - featureStates.map((v) => ({ + featureStates.map((v: FeatureState) => ({ ...v, // endpoint returns object for feature_state_value rather than the value feature_state_value: Utils.valueToFeatureState( @@ -1082,7 +1074,6 @@ store.dispatcherIndex = Dispatcher.register(store, (payload) => { action.segmentOverrides, action.changeRequest, action.commit, - action.mode, ) break case Actions.EDIT_FEATURE: diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index 982f569d8a2c..b3749d5083f7 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -877,8 +877,9 @@ const CreateFlag = class extends Component { }, ) => { const saveFeatureValue = saveFeatureWithValidation((schedule) => { - this.setState({ valueChanged: false }) if ((is4Eyes || schedule) && !identity) { + this.setState({ segmentsChanged: false, valueChanged: false }) + openModal2( schedule ? 'New Scheduled Flag Update' @@ -940,7 +941,6 @@ const CreateFlag = class extends Component { title, }, !is4Eyes, - 'VALUE', ) }, ) @@ -948,6 +948,7 @@ const CreateFlag = class extends Component { />, ) } else { + this.setState({ valueChanged: false }) this.save(editFeatureValue, isSaving) } }) @@ -962,77 +963,7 @@ const CreateFlag = class extends Component { this.setState({ segmentsChanged: false }) if ((is4Eyes || schedule) && isVersioned && !identity) { - openModal2( - this.props.changeRequest - ? 'Update Change Request' - : schedule - ? 'Schedule Segment Overrides Update' - : 'New Change Request', - { - closeModal2() - this.save( - ( - projectId, - environmentId, - flag, - projectFlag, - environmentFlag, - segmentOverrides, - ) => { - createChangeRequest( - projectId, - environmentId, - flag, - projectFlag, - environmentFlag, - segmentOverrides, - { - approvals, - description, - featureStateId: - this.props.changeRequest && - this.props.changeRequest.feature_states[0] - .id, - id: - this.props.changeRequest && - this.props.changeRequest.id, - live_from, - multivariate_options: this.props - .multivariate_options - ? this.props.multivariate_options.map( - (v) => { - const matching = - this.state.multivariate_options.find( - (m) => - m.id === - v.multivariate_feature_option, - ) - return { - ...v, - percentage_allocation: - matching.default_percentage_allocation, - } - }, - ) - : this.state.multivariate_options, - title, - }, - !is4Eyes, - 'SEGMENT', - ) - }, - ) - }} - />, - ) + return saveFeatureValue() } else { this.save(editFeatureSegments, isSaving) } @@ -1129,7 +1060,11 @@ const CreateFlag = class extends Component {
{is4Eyes - ? 'This will create a change request for the environment' + ? `This will create a change request ${ + isVersioned + ? 'with any value and segment override changes ' + : '' + }for the environment` : 'This will update the feature value for the environment'}{' '} { @@ -1412,7 +1347,11 @@ const CreateFlag = class extends Component {

{is4Eyes && isVersioned - ? 'This will create a change request for the environment' + ? `This will create a change request ${ + isVersioned + ? 'with any value and segment override changes ' + : '' + }for the environment` : 'This will update the segment overrides for the environment'}{' '} { From fbd1a13497ef805e3b23e1877e7ccfe087cf197b Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 3 Dec 2024 14:05:39 +0000 Subject: [PATCH 49/77] feat: Enterprise licensing (#3624) Co-authored-by: Zach Aysan --- api/app/settings/common.py | 24 ++++++++ api/app/settings/test.py | 43 ++++++++++++++ api/organisations/models.py | 31 +++++++--- api/organisations/subscriptions/metadata.py | 8 +-- api/organisations/urls.py | 14 +++++ api/poetry.lock | 59 +++++++++---------- .../versioning/test_unit_versioning_views.py | 14 ++--- .../test_unit_organisations_models.py | 44 -------------- 8 files changed, 142 insertions(+), 95 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index ee76139d98d8..ad14f2b78cef 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1272,3 +1272,27 @@ # subscriptions created before this date full audit log and versioning # history. VERSIONING_RELEASE_DATE = env.date("VERSIONING_RELEASE_DATE", default=None) + +SUBSCRIPTION_LICENCE_PUBLIC_KEY = env.str( + "SUBSCRIPTION_LICENCE_PUBLIC_KEY", + """ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs1H23Xv1IlhyTbUP9Z4e +zN3t6oa97ybLufhqSRCoPxHWAY/pqqjdwiC00AnRbL/guDi1FLPEkLza2gAKfU+f +04SsNTfYL5MTPnaFtf+B+hlYmlrT1C6n05t+uQW2OQm6mWoqBssmoyR8T5FXfBls +FrT8dsZg5XG7JaWAyGbbVscHrXHXqVcLbFGO8CcO2BG2whl+7hzm4edNCsxLJqmN +uASR9KtntdulkRar0A9x+hAQUlrDKv77nMMdljNIqkcCcWrbhiDoTVCDbE99mhMq +LeC/+C54/ZiCb3r9woq/kpsbRj0Ys2b4czfjWioXooSxA0w3BE6/lV0+hVltjRO6 +5QIDAQAB +-----END PUBLIC KEY----- +""", +) + +# For the matching private key to the public key added above +# search for "Flagsmith licence private key" in Bitwarden. +SUBSCRIPTION_LICENCE_PRIVATE_KEY = env.str("SUBSCRIPTION_LICENCE_PRIVATE_KEY", None) + +LICENSING_INSTALLED = importlib.util.find_spec("licensing") is not None + +if LICENSING_INSTALLED: # pragma: no cover + INSTALLED_APPS.append("licensing") diff --git a/api/app/settings/test.py b/api/app/settings/test.py index 1fb33fe5c0a4..3faad310c58f 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -18,3 +18,46 @@ RETRY_WEBHOOKS = True INFLUXDB_BUCKET = "test_bucket" + +SUBSCRIPTION_LICENCE_PRIVATE_KEY = """ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0o6Q+J6ArJZ2x +RyZQ5e9ue6dB4bgH7I7DYYb9t9eIb55z0vZZWVLLmIr+ngCfCxIePqCclrAen9gr +rCRhyAXD+XZYjRP0w2wlqA367HJXbti1adXnQnM4QXITNJhRnGoqiRVx7vQ/Klup ++yMBJOU4IkkSsQaAgp0eTdPlGlA+KAfCH39rsqIHNXuS1qfspI2RyaR6130NvR6D +4p07XJls1AYOs8xphdWl8b4hzbJTvC0IqRhvX+z4kEyQjprdcfwOG4qrqtIb4asm +21imOtE8CGRvHUl/cV+1l/hgv1fdbeCFzM89q16Z/KXIAWJMYfkWuOWGVEmf7yjB +9aMrfM3fAgMBAAECggEAKqGwQocBkw1GoS8kiNUrY8zFFZRa5Wvb6ZqbzEdWE7oc +EEPKph2hn7E5pIvPo7luJjsrlqktmZyp3Oy8jWMykSTP3Gg3PH3eiSiXXA/vkFj1 +xiLbO8AAB1fSv1ubUy9yEuXVbNUzSbEKfxxpD30Qp+XXjxS+bxfkUuGVT62dIH3V +j251CEsCIZzwOriGP52OKK5HR24Y9/c+uGLu1CLY6qdrMgWAXTYqEoUw7ku8Sm8B +o6fuu9i0mAEJUl6qcVz3yH0QYe9pM6jDQ9oZeSkVYyspCwysTVs2jsCTMYUpK/kD +WU9sniHRgly3C9Ge3PrE4qUeMRTNk0Vd4RETtznwqQKBgQD3UiJ11FUd3g1FXL9N +iLOIayACrX37cBuc8M5iAzasNDjSVNoCrQun1091xzYK6/F7As4PtH1YUaDgXdBp +efHHl3DPTFkeztPMOKOd8tCpLai/23sbCBNc51x0LCuWWnNaAuidId1rpvFq7AMJ +jE4HPJkoL6udOzlKUHebp02ILQKBgQC6+nT2A+AZeheUiw2wBl0BRitQCxA+TN+L +vkAwLa0u/OqeNc8W50lybzHCS9nEVZ0Lp3Qk9Cl++X/k5o6k2byqJxtmMvLGqjjw +UNuZWHSoUzfdzs8yBjroLM4HsBgbEaG9E2e2zuqKBvwLqZ3fv/fXvmJDIu+aCWXC +ADtlrAvJuwKBgQDq+CW1PJ4BWk3RcGRwDUhEe0JWSO5ATCpv2Hi7tcHjqVmyutrF +YBKKy4y6oSE/DxrFe8y6LwhHOIZXo8m17B1BOyf6StcA5g9jHwyTq3WCxdZlMOis +red3hHfaB30Bw72D7u+BGgN7m4gRxVi9YYdgaLo569Bn+TRc3kZEo5aNoQKBgH7z +aJBU50ZFCFeZ5iw61dD0pJnPOTMjnLBT917+1FRP8riCzl29obep2b4TJANTIbL0 ++j3Q7Y/BtV1kUTuKfreEn+zO8NmEX+6C5+cBEQvsnMTkEvfjFQHo0eaUYHmYihlH +YKbVbJdU0LLWclOmEpAQOsVcphQPB2EmKS4KF2LbAoGAOqVsQg61S1u7s4NF4JCN +EiJvBDjjwTycNCmhY7bV1R7LX+Qk/Mq9fgK3yccKV/Bl69C9Fmeopivbu20urNhn +q/sgOPDK0zJUSVh76gFon1gx7OfaHV31TrvIl0T7WnyfDvAv20F+dmmXkjnPBNNm +dXzo4kXwDOlWCJI8VhYfH/0= +-----END PRIVATE KEY----- +""" + +SUBSCRIPTION_LICENCE_PUBLIC_KEY = """ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtKOkPiegKyWdsUcmUOXv +bnunQeG4B+yOw2GG/bfXiG+ec9L2WVlSy5iK/p4AnwsSHj6gnJawHp/YK6wkYcgF +w/l2WI0T9MNsJagN+uxyV27YtWnV50JzOEFyEzSYUZxqKokVce70PypbqfsjASTl +OCJJErEGgIKdHk3T5RpQPigHwh9/a7KiBzV7ktan7KSNkcmketd9Db0eg+KdO1yZ +bNQGDrPMaYXVpfG+Ic2yU7wtCKkYb1/s+JBMkI6a3XH8DhuKq6rSG+GrJttYpjrR +PAhkbx1Jf3FftZf4YL9X3W3ghczPPatemfylyAFiTGH5FrjlhlRJn+8owfWjK3zN +3wIDAQAB +-----END PUBLIC KEY----- +""" diff --git a/api/organisations/models.py b/api/organisations/models.py index 3c81188e63bf..1e5637df3e8e 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -415,16 +415,29 @@ def _get_subscription_metadata_for_chargebee(self) -> ChargebeeObjMetadata: return cb_metadata def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata: - if not is_enterprise(): - return FREE_PLAN_SUBSCRIPTION_METADATA + if is_enterprise() and hasattr( + self.organisation, "licence" + ): # pragma: no cover + licence_information = self.organisation.licence.get_licence_information() + return BaseSubscriptionMetadata( + seats=licence_information.num_seats, + projects=licence_information.num_projects, + audit_log_visibility_days=None, + feature_history_visibility_days=None, + ) + # TODO: Once we've successfully rolled out licences to enterprises + # remove this branch to force them into the free plan + # if they don't have a licence. + elif is_enterprise(): # pragma: no cover + return BaseSubscriptionMetadata( + seats=self.max_seats, + api_calls=self.max_api_calls, + projects=None, + audit_log_visibility_days=None, + feature_history_visibility_days=None, + ) - return BaseSubscriptionMetadata( - seats=self.max_seats, - api_calls=self.max_api_calls, - projects=None, - audit_log_visibility_days=None, - feature_history_visibility_days=None, - ) + return FREE_PLAN_SUBSCRIPTION_METADATA def add_single_seat(self): if not self.can_auto_upgrade_seats: diff --git a/api/organisations/subscriptions/metadata.py b/api/organisations/subscriptions/metadata.py index 893cb729bc60..fdb3033ab55d 100644 --- a/api/organisations/subscriptions/metadata.py +++ b/api/organisations/subscriptions/metadata.py @@ -9,13 +9,13 @@ class BaseSubscriptionMetadata: def __init__( self, seats: int = 0, - api_calls: int = 0, - projects: typing.Optional[int] = None, - chargebee_email: str = None, + api_calls: None | int = None, + projects: None | int = None, + chargebee_email: None | str = None, audit_log_visibility_days: int | None = 0, feature_history_visibility_days: int | None = DEFAULT_VERSION_LIMIT_DAYS, **kwargs, # allows for extra unknown attrs from CB json metadata - ): + ) -> None: self.seats = seats self.api_calls = api_calls self.projects = projects diff --git a/api/organisations/urls.py b/api/organisations/urls.py index 56faeedab455..600beededdf0 100644 --- a/api/organisations/urls.py +++ b/api/organisations/urls.py @@ -154,6 +154,20 @@ ), ] +if settings.LICENSING_INSTALLED: # pragma: no cover + from licensing.views import create_or_update_licence + + urlpatterns.extend( + [ + path( + "/licence", + create_or_update_licence, + name="create-or-update-licence", + ), + ] + ) + + if settings.IS_RBAC_INSTALLED: from rbac.views import ( GroupRoleViewSet, diff --git a/api/poetry.lock b/api/poetry.lock index baaa050c3818..f531559897a8 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -565,38 +565,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.1" +version = "43.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, - {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, - {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, - {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, - {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, - {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, - {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] [package.dependencies] @@ -609,7 +609,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -3340,7 +3340,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index 7eba620a1b06..074b0665d63f 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -1639,8 +1639,7 @@ def test_list_versions_always_returns_current_version_even_if_outside_limit( @pytest.mark.freeze_time(now - timedelta(days=DEFAULT_VERSION_LIMIT_DAYS + 1)) -@pytest.mark.parametrize("is_saas", (True, False)) -def test_list_versions_returns_all_versions_for_enterprise_plan( +def test_list_versions_returns_all_versions_for_enterprise_plan_when_saas( feature: Feature, environment_v2_versioning: Environment, staff_user: FFAdminUser, @@ -1649,10 +1648,10 @@ def test_list_versions_returns_all_versions_for_enterprise_plan( with_project_permissions: WithProjectPermissionsCallable, subscription: Subscription, freezer: FrozenDateTimeFactory, - is_saas: bool, mocker: MockerFixture, ) -> None: # Given + is_saas = True with_environment_permissions([VIEW_ENVIRONMENT]) with_project_permissions([VIEW_PROJECT]) @@ -1668,11 +1667,10 @@ def test_list_versions_returns_all_versions_for_enterprise_plan( subscription.plan = "enterprise" subscription.save() - if is_saas: - OrganisationSubscriptionInformationCache.objects.update_or_create( - organisation=subscription.organisation, - defaults={"feature_history_visibility_days": None}, - ) + OrganisationSubscriptionInformationCache.objects.update_or_create( + organisation=subscription.organisation, + defaults={"feature_history_visibility_days": None}, + ) initial_version = EnvironmentFeatureVersion.objects.get( feature=feature, environment=environment_v2_versioning diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index aa76002cd5cd..a3ace82a8e31 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -336,50 +336,6 @@ def test_organisation_subscription_get_subscription_metadata_returns_free_plan_m assert subscription_metadata == FREE_PLAN_SUBSCRIPTION_METADATA -@pytest.mark.parametrize( - "subscription_id, plan, max_seats, expected_seats, expected_projects", - ( - ( - None, - "free", - 10, - MAX_SEATS_IN_FREE_PLAN, - settings.MAX_PROJECTS_IN_FREE_PLAN, - ), - ("anything", "enterprise", 20, 20, None), - (TRIAL_SUBSCRIPTION_ID, "enterprise", 20, 20, None), - ), -) -def test_organisation_get_subscription_metadata_for_enterprise_self_hosted_licenses( - organisation: Organisation, - subscription_id: str | None, - plan: str, - max_seats: int, - expected_seats: int, - expected_projects: int | None, - mocker: MockerFixture, -) -> None: - """ - Specific test to make sure that we can manually add subscriptions to - enterprise self-hosted deployments and the values stored in the django - database will be correctly used. - """ - # Given - Subscription.objects.filter(organisation=organisation).update( - subscription_id=subscription_id, plan=plan, max_seats=max_seats - ) - organisation.subscription.refresh_from_db() - mocker.patch("organisations.models.is_saas", return_value=False) - mocker.patch("organisations.models.is_enterprise", return_value=True) - - # When - subscription_metadata = organisation.subscription.get_subscription_metadata() - - # Then - assert subscription_metadata.projects == expected_projects - assert subscription_metadata.seats == expected_seats - - @pytest.mark.parametrize( "subscription_id, plan, max_seats, max_api_calls, expected_seats, " "expected_api_calls, expected_projects", From 679acdd26cb18f16626a4809caa0aea65060e466 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 3 Dec 2024 19:20:56 +0000 Subject: [PATCH 50/77] fix: button style (#4884) --- frontend/web/components/base/forms/Button.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/web/components/base/forms/Button.tsx b/frontend/web/components/base/forms/Button.tsx index 282bb21c87bb..8ca31c338d5a 100644 --- a/frontend/web/components/base/forms/Button.tsx +++ b/frontend/web/components/base/forms/Button.tsx @@ -62,15 +62,16 @@ export const Button: FC = ({ href={hasPlan ? href : Constants.getUpgradeUrl()} rel='noreferrer' > - {!!iconLeft && !!hasPlan && ( - - )} -

+
+ {!!iconLeft && !!hasPlan && ( + + )} {children} {!hasPlan && }
From 27f50cac93c885f1fdb0719add326c6f061f2eaf Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Wed, 4 Dec 2024 03:48:47 -0500 Subject: [PATCH 51/77] test: Segment matching for sub rules and conditions (#4776) --- api/poetry.lock | 12 +- api/pyproject.toml | 4 +- .../unit/segments/test_unit_segments_views.py | 142 ++++++++++++++++++ 3 files changed, 150 insertions(+), 8 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index f531559897a8..1e742a5750b7 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1362,8 +1362,8 @@ flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-common" -reference = "v1.2.0" -resolved_reference = "ccb6c0603168c91cfb8f9f24bdfa21741eb5916e" +reference = "v1.3.0" +resolved_reference = "d36be973b6dd38c4b0d752c8c023478bbc57756c" [[package]] name = "flagsmith-flag-engine" @@ -4060,14 +4060,14 @@ develop = false [package.dependencies] djangorestframework = "*" djangorestframework-recursive = "*" -flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.2.0"} +flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.3.0"} flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/flagsmith/flagsmith-workflows" -reference = "v2.7.0" -resolved_reference = "3bd546a451e7f2359aa9787ec8ffa5e4315d12cf" +reference = "v2.7.1" +resolved_reference = "83fec3e21752a1dadc2fde87bf9e11ddcda33ed9" [[package]] name = "wrapt" @@ -4186,4 +4186,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "487ea7b46f083a1be71662f027a0b170c60e5aa4e35d083224ad4cd4697ec7d1" +content-hash = "4c7b0065fcd2a98b5091c6b5546d8355b693b0b4a766bfe1beae4364bf223e85" diff --git a/api/pyproject.toml b/api/pyproject.toml index 28cff6880c0b..4a9cc6aa0850 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -170,7 +170,7 @@ hubspot-api-client = "^8.2.1" djangorestframework-dataclasses = "^1.3.1" pyotp = "^2.9.0" flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.0.2" } -flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.2.0" } +flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.3.0" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" @@ -196,7 +196,7 @@ flagsmith-ldap = { git = "https://github.com/flagsmith/flagsmith-ldap", tag = "v optional = true [tool.poetry.group.workflows.dependencies] -workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.0" } +workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.1" } [tool.poetry.group.dev.dependencies] django-test-migrations = "~1.2.0" diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 4e29c6d3f1e5..f84b4e706f61 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -670,6 +670,148 @@ def test_update_segment_add_new_condition( assert nested_rule.conditions.order_by("-id").first().value == new_condition_value +def test_update_mismatched_rule_and_segment( + project: Project, + admin_client_new: APIClient, + segment: Segment, + segment_rule: SegmentRule, +) -> None: + # Given + url = reverse( + "api-v1:projects:project-segments-detail", args=[project.id, segment.id] + ) + false_segment = Segment.objects.create(name="False segment", project=project) + segment_rule.segment = false_segment + segment_rule.save() + + nested_rule = SegmentRule.objects.create( + rule=segment_rule, type=SegmentRule.ANY_RULE + ) + existing_condition = Condition.objects.create( + rule=nested_rule, property="foo", operator=EQUAL, value="bar" + ) + + new_condition_property = "foo2" + new_condition_value = "bar" + data = { + "name": segment.name, + "project": project.id, + "rules": [ + { + "id": segment_rule.id, + "type": segment_rule.type, + "rules": [ + { + "id": nested_rule.id, + "type": nested_rule.type, + "rules": [], + "conditions": [ + # existing condition + { + "id": existing_condition.id, + "property": existing_condition.property, + "operator": existing_condition.operator, + "value": existing_condition.value, + }, + # new condition + { + "property": new_condition_property, + "operator": EQUAL, + "value": new_condition_value, + }, + ], + } + ], + "conditions": [], + } + ], + } + + # When + response = admin_client_new.put( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"segment": "Mismatched segment is not allowed"} + segment_rule.refresh_from_db() + assert segment_rule.segment == false_segment + + +def test_update_mismatched_condition_and_segment( + project: Project, + admin_client_new: APIClient, + segment: Segment, + segment_rule: SegmentRule, +) -> None: + # Given + url = reverse( + "api-v1:projects:project-segments-detail", args=[project.id, segment.id] + ) + false_segment = Segment.objects.create(name="False segment", project=project) + false_segment_rule = SegmentRule.objects.create( + segment=false_segment, type=SegmentRule.ALL_RULE + ) + false_nested_rule = SegmentRule.objects.create( + rule=false_segment_rule, type=SegmentRule.ANY_RULE + ) + nested_rule = SegmentRule.objects.create( + rule=segment_rule, type=SegmentRule.ANY_RULE + ) + + existing_condition = Condition.objects.create( + rule=false_nested_rule, property="foo", operator=EQUAL, value="bar" + ) + + new_condition_property = "foo2" + new_condition_value = "bar" + data = { + "name": segment.name, + "project": project.id, + "rules": [ + { + "id": segment_rule.id, + "type": segment_rule.type, + "rules": [ + { + "id": nested_rule.id, + "type": nested_rule.type, + "rules": [], + "conditions": [ + # existing condition + { + "id": existing_condition.id, + "property": existing_condition.property, + "operator": existing_condition.operator, + "value": existing_condition.value, + }, + # new condition + { + "property": new_condition_property, + "operator": EQUAL, + "value": new_condition_value, + }, + ], + } + ], + "conditions": [], + } + ], + } + + # When + response = admin_client_new.put( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"segment": "Mismatched segment is not allowed"} + existing_condition.refresh_from_db() + assert existing_condition._get_segment() != segment + + def test_update_segment_versioned_segment( project: Project, admin_client_new: APIClient, From 4e1abdaacad6e8c39d1898c8a7b22dd1ee5f6550 Mon Sep 17 00:00:00 2001 From: Gagan Date: Wed, 4 Dec 2024 15:25:35 +0530 Subject: [PATCH 52/77] deps: bump common, rbac and workflow (#4885) --- api/Makefile | 2 +- api/poetry.lock | 13 +++++++------ api/pyproject.toml | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/api/Makefile b/api/Makefile index 5c5fd7ea3e12..f9308c59d9a9 100644 --- a/api/Makefile +++ b/api/Makefile @@ -12,7 +12,7 @@ POETRY_VERSION ?= 1.8.3 GUNICORN_LOGGER_CLASS ?= util.logging.GunicornJsonCapableLogger SAML_REVISION ?= v1.6.4 -RBAC_REVISION ?= v0.11.1 +RBAC_REVISION ?= v0.11.2 -include .env-local -include $(DOTENV_OVERRIDE_FILE) diff --git a/api/poetry.lock b/api/poetry.lock index 1e742a5750b7..aecf908c7c56 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1362,8 +1362,8 @@ flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-common" -reference = "v1.3.0" -resolved_reference = "d36be973b6dd38c4b0d752c8c023478bbc57756c" +reference = "v1.4.0" +resolved_reference = "8b82fab5cbc0ff75c161fbf755e4e707e8d90434" [[package]] name = "flagsmith-flag-engine" @@ -3340,6 +3340,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -4060,14 +4061,14 @@ develop = false [package.dependencies] djangorestframework = "*" djangorestframework-recursive = "*" -flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.3.0"} +flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.0"} flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/flagsmith/flagsmith-workflows" -reference = "v2.7.1" -resolved_reference = "83fec3e21752a1dadc2fde87bf9e11ddcda33ed9" +reference = "v2.7.2" +resolved_reference = "53680ff0f5fb742936db673adf824901374decbf" [[package]] name = "wrapt" @@ -4186,4 +4187,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "4c7b0065fcd2a98b5091c6b5546d8355b693b0b4a766bfe1beae4364bf223e85" +content-hash = "2f98e69917864d300bb14088fcf7a3f37a7e4f73b3c10597b42ce6429b6cc505" diff --git a/api/pyproject.toml b/api/pyproject.toml index 4a9cc6aa0850..6e6f42b3a43f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -170,7 +170,7 @@ hubspot-api-client = "^8.2.1" djangorestframework-dataclasses = "^1.3.1" pyotp = "^2.9.0" flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.0.2" } -flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.3.0" } +flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.0" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" @@ -196,7 +196,7 @@ flagsmith-ldap = { git = "https://github.com/flagsmith/flagsmith-ldap", tag = "v optional = true [tool.poetry.group.workflows.dependencies] -workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.1" } +workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.2" } [tool.poetry.group.dev.dependencies] django-test-migrations = "~1.2.0" From 05ab7cf876611de613aececc0f70f5987955e446 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Dec 2024 11:07:53 +0000 Subject: [PATCH 53/77] fix: cannot create new segments due to segment validation (#4886) --- api/poetry.lock | 39 +++++-------------- api/pyproject.toml | 4 +- .../unit/segments/test_unit_segments_views.py | 38 ++++++++++++++++++ 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index aecf908c7c56..254b197a8f1a 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1345,7 +1345,7 @@ resolved_reference = "303954ba54fd2f402c75806b3b9ba7f2d42aa426" [[package]] name = "flagsmith-common" -version = "0.1.0" +version = "1.4.2" description = "A repository for including code that is required in multiple flagsmith repositories" optional = false python-versions = "^3.10" @@ -1362,8 +1362,8 @@ flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-common" -reference = "v1.4.0" -resolved_reference = "8b82fab5cbc0ff75c161fbf755e4e707e8d90434" +reference = "v1.4.2" +resolved_reference = "5ac44b7c8dce96c043c9a0c38c7cbde04886fcf9" [[package]] name = "flagsmith-flag-engine" @@ -2147,16 +2147,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -3327,7 +3317,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -3335,16 +3324,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -3361,7 +3342,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -3369,7 +3349,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -4051,7 +4030,7 @@ test = ["pytest"] [[package]] name = "workflows-logic" -version = "2.0.0" +version = "2.7.4" description = "Workflows logic plugin for Flagsmith application." optional = false python-versions = ">=3.10,<4.0" @@ -4061,14 +4040,14 @@ develop = false [package.dependencies] djangorestframework = "*" djangorestframework-recursive = "*" -flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.0"} +flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.2"} flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/flagsmith/flagsmith-workflows" -reference = "v2.7.2" -resolved_reference = "53680ff0f5fb742936db673adf824901374decbf" +reference = "v2.7.4" +resolved_reference = "aba7b848a13ed589c8f46fea5fa7c63a2c047d82" [[package]] name = "wrapt" @@ -4187,4 +4166,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "2f98e69917864d300bb14088fcf7a3f37a7e4f73b3c10597b42ce6429b6cc505" +content-hash = "2bb9c372733d458c5f6908dff9c668e5b7dd9b6d24d37080a00b5bb45d2f532d" diff --git a/api/pyproject.toml b/api/pyproject.toml index 6e6f42b3a43f..2bcb45f00298 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -170,7 +170,7 @@ hubspot-api-client = "^8.2.1" djangorestframework-dataclasses = "^1.3.1" pyotp = "^2.9.0" flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.0.2" } -flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.0" } +flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.2" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" @@ -196,7 +196,7 @@ flagsmith-ldap = { git = "https://github.com/flagsmith/flagsmith-ldap", tag = "v optional = true [tool.poetry.group.workflows.dependencies] -workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.2" } +workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.4" } [tool.poetry.group.dev.dependencies] django-test-migrations = "~1.2.0" diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index f84b4e706f61..779e229aab7a 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -156,6 +156,44 @@ def test_create_segments_reaching_max_limit(project, client, settings): assert project.segments.count() == 1 +def test_segments_limit_ignores_old_segment_versions( + project: Project, + segment: Segment, + staff_client: APIClient, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([MANAGE_SEGMENTS]) + + # let's reduce the max segments allowed to 2 + project.max_segments_allowed = 2 + project.save() + + # and create some older versions for the segment fixture + segment.deep_clone() + assert Segment.objects.filter(version_of_id=segment.id).count() == 3 + assert Segment.live_objects.count() == 1 + + url = reverse("api-v1:projects:project-segments-list", args=[project.id]) + data = { + "name": "New segment name", + "project": project.id, + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [{"operator": EQUAL, "property": "test-property"}], + } + ], + } + + # When + res = staff_client.post(url, data=json.dumps(data), content_type="application/json") + + # Then + assert res.status_code == status.HTTP_201_CREATED + + @pytest.mark.parametrize( "client", [lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")], From 7c4e2ff23ea66bf8e3dfb57b3492454a831b1bed Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Wed, 4 Dec 2024 13:20:08 +0000 Subject: [PATCH 54/77] feat: tag based permissions (#4853) --- docs/docs/system-administration/rbac.md | 45 +- frontend/api/index.js | 4 + frontend/common/providers/Permission.tsx | 33 +- frontend/common/services/usePermission.ts | 17 +- frontend/common/services/useProject.ts | 2 +- frontend/common/services/useRole.ts | 6 +- frontend/common/services/useRolePermission.ts | 10 +- frontend/common/stores/account-store.js | 3 + frontend/common/stores/organisation-store.js | 6 +- frontend/common/types/requests.ts | 6 +- frontend/common/types/responses.ts | 14 +- frontend/common/utils/utils.tsx | 30 - frontend/web/components/App.js | 3 + .../web/components/CompareEnvironments.js | 2 + frontend/web/components/CompareFeatures.js | 1 + frontend/web/components/EditPermissions.tsx | 404 +++-- frontend/web/components/FeatureAction.tsx | 3 + frontend/web/components/FeatureRow.js | 1 + frontend/web/components/Icon.tsx | 137 +- .../web/components/PermissionsSummaryList.tsx | 78 +- frontend/web/components/PermissionsTabs.tsx | 46 +- frontend/web/components/ProjectFilter.tsx | 1 + .../web/components/RolePermissionsList.tsx | 14 +- frontend/web/components/RolesTable.tsx | 9 +- frontend/web/components/SettingsButton.tsx | 1 + frontend/web/components/UserGroupList.tsx | 18 +- frontend/web/components/UserSelect.js | 20 +- frontend/web/components/modals/CreateFlag.js | 830 +++++---- .../web/components/modals/CreateGroup.tsx | 7 +- frontend/web/components/modals/CreateRole.tsx | 3 + .../web/components/pages/ChangeRequestPage.js | 1 + .../pages/EnvironmentSettingsPage.js | 36 +- frontend/web/components/pages/FeaturesPage.js | 429 +++-- .../components/pages/ProjectSettingsPage.js | 4 +- frontend/web/components/pages/UserPage.tsx | 1589 ++++++++--------- .../pages/UsersAndPermissionsPage.tsx | 35 +- frontend/web/components/pages/WidgetPage.tsx | 359 ++-- frontend/web/components/tags/AddEditTags.tsx | 6 +- frontend/web/components/tags/TagValues.tsx | 34 +- frontend/web/project/project-components.js | 2 +- frontend/web/styles/components/_panel.scss | 4 +- frontend/web/styles/project/_modals.scss | 2 +- 42 files changed, 2316 insertions(+), 1939 deletions(-) diff --git a/docs/docs/system-administration/rbac.md b/docs/docs/system-administration/rbac.md index b57fcc3d6e85..aa779af4980d 100644 --- a/docs/docs/system-administration/rbac.md +++ b/docs/docs/system-administration/rbac.md @@ -134,6 +134,11 @@ Assigning roles to groups has several benefits over assigning permissions direct Permissions can be assigned at four levels: user group, organisation, project, and environment. +## Tagged Permissions + +When creating a role, some permissions allow you to grant access when features have specific tags. For example, you can +configure a role to create change requests only for features tagged with "marketing". + ### User group | Permission | Ability | @@ -149,25 +154,27 @@ Permissions can be assigned at four levels: user group, organisation, project, a ### Project -| Permission | Ability | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| Administrator | Grants full read and write access to all environments, features and segments. | -| View Project | Allows viewing this project. The project is hidden from users without this permission. | -| Create Environment | Allows creating new environments in this project. Users are automatically granted Administrator permissions on any environments they create. | -| Create Feature | Allows creating new features in all environments. | -| Delete Feature | Allows deleting features from all environments. | -| Manage Segments | Grants write access to segments in this project. | -| View audit log | Allows viewing all audit log entries for this project. | +### Project + +| Permission | Ability | Supports Tags | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| Administrator | Grants full read and write access to all environments, features, and segments. | | +| View Project | Allows viewing this project. The project is hidden from users without this permission. | | +| Create Environment | Allows creating new environments in this project. Users are automatically granted Administrator permissions on any environments they create. | | +| Create Feature | Allows creating new features in all environments. | | +| Delete Feature | Allows deleting features from all environments. | Yes | +| Manage Segments | Grants write access to segments in this project. | | +| View audit log | Allows viewing all audit log entries for this project. | | ### Environment -| Permission | Ability | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -| Administrator | Grants full read and write access to all feature states, overrides, identities and change requests in this environment. | -| View Environment | Allows viewing this environment. The environment is hidden from users without this permission. | -| Update Feature State | Allows updating updating any feature state or values in this environment. | -| Manage Identities | Grants read and write access to identities in this environment. | -| Manage Segment Overrides | Grants write access to segment overrides in this environment. | -| Create Change Request | Allows creating change requests for features in this environment. | -| Approve Change Request | Allows approving or denying change requests in this environment. | -| View Identities | Grants read-only access to identities in this environment. | +| Permission | Ability | Supports Tags | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------- | +| Administrator | Grants full read and write access to all feature states, overrides, identities, and change requests in this environment. | | +| View Environment | Allows viewing this environment. The environment is hidden from users without this permission. | | +| Update Feature State | Allows updating any feature state or values in this environment. | Yes | +| Manage Identities | Grants read and write access to identities in this environment. | | +| Manage Segment Overrides | Grants write access to segment overrides in this environment. | | +| Create Change Request | Allows creating change requests for features in this environment. | Yes | +| Approve Change Request | Allows approving or denying change requests in this environment. | Yes | +| View Identities | Grants read-only access to identities in this environment. | | diff --git a/frontend/api/index.js b/frontend/api/index.js index 0f5aceb97470..863e937e9201 100755 --- a/frontend/api/index.js +++ b/frontend/api/index.js @@ -124,6 +124,10 @@ app.get('/config/project-overrides', (req, res) => { name: 'githubAppURL', value: process.env.GITHUB_APP_URL, }, + { + name: 'e2eToken', + value: process.env.E2E_TEST_TOKEN || '', + }, ] let output = values.map(getVariable).join('') let dynatrace = '' diff --git a/frontend/common/providers/Permission.tsx b/frontend/common/providers/Permission.tsx index 212f41bee1ee..cd04e8459902 100644 --- a/frontend/common/providers/Permission.tsx +++ b/frontend/common/providers/Permission.tsx @@ -1,11 +1,15 @@ -import React, { FC, ReactNode } from 'react' +import React, { FC, ReactNode, useMemo } from 'react' import { useGetPermissionQuery } from 'common/services/usePermission' import { PermissionLevel } from 'common/types/requests' -import AccountStore from 'common/stores/account-store' // we need this to make JSX compile +import AccountStore from 'common/stores/account-store' +import intersection from 'lodash/intersection' +import { add } from 'ionicons/icons'; +import { cloneDeep } from 'lodash'; // we need this to make JSX compile type PermissionType = { id: any permission: string + tags?: number[] level: PermissionLevel children: (data: { permission: boolean; isLoading: boolean }) => ReactNode } @@ -14,11 +18,26 @@ export const useHasPermission = ({ id, level, permission, + tags, }: Omit) => { - const { data, isLoading, isSuccess } = useGetPermissionQuery( - { id: `${id}`, level }, - { skip: !id || !level }, - ) + const { + data: permissionsData, + isLoading, + isSuccess, + } = useGetPermissionQuery({ id: `${id}`, level }, { skip: !id || !level }) + const data = useMemo(() => { + if (!tags?.length || !permissionsData?.tag_based_permissions) + return permissionsData + const addedPermissions = cloneDeep(permissionsData) + permissionsData.tag_based_permissions.forEach((tagBasedPermission) => { + if (intersection(tagBasedPermission.tags, tags).length) { + tagBasedPermission.permissions.forEach((key) => { + addedPermissions[key] = true + }) + } + }) + return addedPermissions + }, [permissionsData, tags]) const hasPermission = !!data?.[permission] || !!data?.ADMIN return { isLoading, @@ -32,11 +51,13 @@ const Permission: FC = ({ id, level, permission, + tags, }) => { const { isLoading, permission: hasPermission } = useHasPermission({ id, level, permission, + tags, }) return ( <> diff --git a/frontend/common/services/usePermission.ts b/frontend/common/services/usePermission.ts index cbaf91f05a52..b91586aa9ee7 100644 --- a/frontend/common/services/usePermission.ts +++ b/frontend/common/services/usePermission.ts @@ -13,16 +13,25 @@ export const permissionService = service query: ({ id, level }: Req['getPermission']) => ({ url: `${level}s/${id}/my-permissions/`, }), - transformResponse(baseQueryReturnValue: { - admin: boolean - permissions: string[] - }) { + transformResponse( + baseQueryReturnValue: { + admin: boolean + permissions: string[] + tag_based_permissions?: Res['permission']['tag_based_permissions'] + }, + _, + ) { const res: Res['permission'] = { ADMIN: baseQueryReturnValue.admin, } + if (baseQueryReturnValue.tag_based_permissions) { + res.tag_based_permissions = + baseQueryReturnValue.tag_based_permissions + } baseQueryReturnValue.permissions.forEach((v) => { res[v] = true }) + return res }, }), diff --git a/frontend/common/services/useProject.ts b/frontend/common/services/useProject.ts index b929c6c47eec..205cc545ef86 100644 --- a/frontend/common/services/useProject.ts +++ b/frontend/common/services/useProject.ts @@ -18,7 +18,7 @@ export const projectService = service query: (data) => ({ url: `projects/?organisation=${data.organisationId}`, }), - transformResponse: (res) => sortBy(res, 'name'), + transformResponse: (res) => sortBy(res, (v) => v.name.toLowerCase()), }), // END OF ENDPOINTS }), diff --git a/frontend/common/services/useRole.ts b/frontend/common/services/useRole.ts index 2bfdb8476e1f..8356d2ef215b 100644 --- a/frontend/common/services/useRole.ts +++ b/frontend/common/services/useRole.ts @@ -22,6 +22,7 @@ export const roleService = service }), }), getRole: builder.query({ + providesTags: (res) => [{ id: res?.id, type: 'Role' }], query: (query: Req['getRole']) => ({ url: `organisations/${query.organisation_id}/roles/${query.role_id}/`, }), @@ -33,7 +34,10 @@ export const roleService = service }), }), updateRole: builder.mutation({ - invalidatesTags: (res) => [{ id: 'LIST', type: 'Role' }], + invalidatesTags: (res, _, req) => [ + { id: 'LIST', type: 'Role' }, + { id: req.role_id, type: 'Role' }, + ], query: (query: Req['updateRole']) => ({ body: query.body, method: 'PUT', diff --git a/frontend/common/services/useRolePermission.ts b/frontend/common/services/useRolePermission.ts index 9bf4b26391dc..2dfbfe80461d 100644 --- a/frontend/common/services/useRolePermission.ts +++ b/frontend/common/services/useRolePermission.ts @@ -121,15 +121,12 @@ export async function getRoleProjectPermissions( typeof rolePermissionService.endpoints.getRoleProjectPermissions.initiate >[1], ) { - store.dispatch( + return store.dispatch( rolePermissionService.endpoints.getRoleProjectPermissions.initiate( data, options, ), ) - return Promise.all( - store.dispatch(rolePermissionService.util.getRunningQueriesThunk()), - ) } export async function getRoleEnvironmentPermissions( @@ -139,15 +136,12 @@ export async function getRoleEnvironmentPermissions( typeof rolePermissionService.endpoints.getRoleEnvironmentPermissions.initiate >[1], ) { - store.dispatch( + return store.dispatch( rolePermissionService.endpoints.getRoleEnvironmentPermissions.initiate( data, options, ), ) - return Promise.all( - store.dispatch(rolePermissionService.util.getRunningQueriesThunk()), - ) } export async function createRolePermissions( diff --git a/frontend/common/stores/account-store.js b/frontend/common/stores/account-store.js index 3174c2aff953..6c33760ce886 100644 --- a/frontend/common/stores/account-store.js +++ b/frontend/common/stores/account-store.js @@ -7,6 +7,8 @@ import Constants from 'common/constants' import dataRelay from 'data-relay' import { sortBy } from 'lodash' import Project from 'common/project' +import { getStore } from 'common/store' +import { service } from "common/service"; const controller = { acceptInvite: (id) => { @@ -341,6 +343,7 @@ const controller = { API.reset().finally(() => { store.model = user store.organisation = null + getStore().dispatch(service.util.resetApiState()) store.trigger('logout') }) }) diff --git a/frontend/common/stores/organisation-store.js b/frontend/common/stores/organisation-store.js index af4cd739af0c..5aad2f9d0707 100644 --- a/frontend/common/stores/organisation-store.js +++ b/frontend/common/stores/organisation-store.js @@ -39,7 +39,11 @@ const controller = { ) : ['Development', 'Production'] data - .post(`${Project.api}projects/`, { name, organisation: store.id }) + .post( + `${Project.api}projects/`, + { name, organisation: store.id }, + E2E ? { 'X-E2E-Test-Auth-Token': Project.e2eToken } : {}, + ) .then((project) => { Promise.all( defaultEnvironmentNames.map((envName) => { diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 55bca8eedc18..dfa7d68ac381 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -14,6 +14,8 @@ import { UserGroup, AttributeName, Identity, + Role, + RolePermission, } from './responses' export type PagedRequest = T & { @@ -158,7 +160,7 @@ export type Req = { updateRole: { organisation_id: number role_id: number - body: { description: string | null; name: string } + body: Role } deleteRole: { organisation_id: number; role_id: number } getRolePermissionEnvironment: { @@ -179,7 +181,7 @@ export type Req = { level: PermissionLevel body: { admin?: boolean - permissions: string[] + permissions: RolePermission['permissions'] project: number environment: number } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 9dd860a61d32..0679b3795ca4 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -261,8 +261,12 @@ export type UserPermission = { id: number role?: number } + +export type RolePermission = Omit & { + permissions: { permission_key: string; tags: number[] }[] +} export type GroupPermission = Omit & { - group: UserGroup + group: UserGroupSummary } export type AuditLogItem = { @@ -328,6 +332,7 @@ export type Identity = { export type AvailablePermission = { key: string description: string + supports_tag: boolean } export type APIKey = { @@ -657,7 +662,10 @@ export type Res = { } identity: { id: string } //todo: we don't consider this until we migrate identity-store identities: EdgePagedResponse - permission: Record + permission: Record & { + ADMIN: boolean + tag_based_permissions?: { permissions: string[]; tags: number[] }[] + } availablePermissions: AvailablePermission[] tag: Tag tags: Tag[] @@ -695,7 +703,7 @@ export type Res = { versionFeatureState: FeatureState[] role: Role roles: PagedResponse - rolePermission: PagedResponse + rolePermission: PagedResponse projectFlags: PagedResponse projectFlag: ProjectFlag identityFeatureStatesAll: IdentityFeatureState[] diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 4b98764d802c..83ad6329db5c 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -341,36 +341,6 @@ const Utils = Object.assign({}, require('./base/_utils'), { return `/organisation/${orgId}/projects` }, - getPermissionList( - isAdmin: boolean, - permissions: string[] | undefined | null, - numberToTruncate = 3, - ): { - items: string[] - truncatedItems: string[] - } { - if (isAdmin) { - return { - items: ['Administrator'], - truncatedItems: [], - } - } - if (!permissions) return { items: [], truncatedItems: [] } - - const items = - permissions && permissions.length - ? permissions - .slice(0, numberToTruncate) - .map((item) => `${Format.enumeration.get(item)}`) - : [] - - return { - items, - truncatedItems: (permissions || []) - .slice(numberToTruncate) - .map((item) => `${Format.enumeration.get(item)}`), - } - }, getPlanName: (plan: string) => { if (plan && plan.includes('free')) { return planNames.free diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 9d0d2b35c16c..2fc0afc1fbcf 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -439,6 +439,7 @@ const App = class extends Component { > } id='audit-log-link' to={`/project/${projectId}/audit-log`} + data-test='audit-log-link' > Audit Log @@ -661,6 +663,7 @@ const App = class extends Component { } id='org-settings-link' + data-test='org-settings-link' to={`/organisation/${ AccountStore.getOrganisation().id }/settings`} diff --git a/frontend/web/components/CompareEnvironments.js b/frontend/web/components/CompareEnvironments.js index 780418e16a9a..cf11c7ca3590 100644 --- a/frontend/web/components/CompareEnvironments.js +++ b/frontend/web/components/CompareEnvironments.js @@ -242,6 +242,7 @@ class CompareEnvironments extends Component {
) => { + return ( + +
+ {props.data.value === 'GRANTED' && ( + + )} + {props.data.value === 'GRANTED_FOR_TAGS' && ( + + )} + {props.children} +
+
+ ) +} + type EditPermissionModalType = { group?: UserGroupSummary id: number @@ -84,7 +104,7 @@ type EditPermissionModalType = { user?: User role?: Role roles?: Role[] - permissionChanged: () => void + permissionChanged?: () => void isEditUserPermission?: boolean isEditGroupPermission?: boolean } @@ -96,12 +116,19 @@ type EditPermissionsType = Omit & { tabClassName?: string } type EntityPermissions = Omit< - UserPermission, + RolePermission, 'user' | 'id' | 'group' | 'isGroup' > & { id?: number + group?: number user?: number + tags?: number[] } +const permissionOptions = [ + { label: 'Granted', value: 'GRANTED' }, + { label: 'Granted for tags', value: 'GRANTED_FOR_TAGS' }, + { label: 'None', value: 'NONE' }, +] const withAdminPermissions = (InnerComponent: any) => { const WrappedComponent: FC = (props) => { const { id, level } = props @@ -121,7 +148,10 @@ const withAdminPermissions = (InnerComponent: any) => { } if (!permission) { return ( -
+
To manage permissions you need to be admin of this {level}.
) @@ -132,24 +162,7 @@ const withAdminPermissions = (InnerComponent: any) => { return WrappedComponent } const _EditPermissionsModal: FC = withAdminPermissions( - forwardRef((props) => { - const [entityPermissions, setEntityPermissions] = - useState({ admin: false, permissions: [] }) - const [parentError, setParentError] = useState(false) - const [saving, setSaving] = useState(false) - const [showRoles, setShowRoles] = useState(false) - const [valueChanged, setValueChanged] = useState(false) - - const [permissionWasCreated, setPermissionWasCreated] = - useState(false) - const [rolesSelected, setRolesSelected] = useState< - { - role: number - user_role_id?: number - group_role_id?: number - }[] - >([]) - + forwardRef((props: EditPermissionModalType) => { const { className, envId, @@ -171,6 +184,38 @@ const _EditPermissionsModal: FC = withAdminPermissions( user, } = props + const [entityPermissions, setEntityPermissions] = + useState({ + admin: false, + permissions: [], + }) + const [parentError, setParentError] = useState(false) + const [saving, setSaving] = useState(false) + const [showRoles, setShowRoles] = useState(false) + const [valueChanged, setValueChanged] = useState(false) + + const projectId = + props.level === 'project' + ? props.id + : props.level === 'environment' + ? props.parentId + : undefined + + const { data: tags, isLoading: tagsLoading } = useGetTagsQuery( + { projectId: `${projectId}` }, + { skip: !projectId }, + ) + + const [permissionWasCreated, setPermissionWasCreated] = + useState(false) + const [rolesSelected, setRolesSelected] = useState< + { + role: number + user_role_id: number | undefined + group_role_id: number | undefined + }[] + >([]) + const { data: permissions } = useGetAvailablePermissionsQuery({ level }) const { data: userWithRolesData, isSuccess: userWithRolesDataSuccesfull } = useGetUserWithRolesQuery( @@ -195,6 +240,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( useEffect(() => { if (user && userWithRolesDataSuccesfull) { const resultArray = userWithRolesData?.results?.map((userRole) => ({ + group_role_id: undefined, role: userRole.id, user_role_id: user?.id, })) @@ -208,6 +254,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( const resultArray = groupWithRolesData?.results?.map((groupRole) => ({ group_role_id: group?.id, role: groupRole.id, + user_role_id: undefined, })) setRolesSelected(resultArray) } @@ -215,13 +262,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( }, [groupWithRolesDataSuccesfull]) const processResults = (results: (UserPermission | GroupPermission)[]) => { - let entityPermissions: - | (Omit & { - user?: any - group?: any - role?: any - }) - | undefined = isGroup + const foundPermission = isGroup ? find( results || [], (r) => (r as GroupPermission).group.id === group?.id, @@ -232,18 +273,22 @@ const _EditPermissionsModal: FC = withAdminPermissions( results || [], (r) => (r as UserPermission).user?.id === user?.id, ) - - if (!entityPermissions) { - entityPermissions = { admin: false, permissions: [] } - } - if (user) { - entityPermissions.user = user.id - } - if (group) { - entityPermissions.group = group.id - } - return entityPermissions + const permissions = + (role && (level === 'project' || level === 'environment') + ? foundPermission?.permissions + : (foundPermission?.permissions || []).map((v) => ({ + permission_key: v, + tags: [], + }))) || [] + return { + ...(foundPermission || {}), + group: group?.id, + //Since role permissions and other permissions are different in data structure, adjust permissions to match + permissions, + user: user?.id, + } as EntityPermissions } + const [ createRolePermissionUser, { data: usersData, isSuccess: userAdded }, @@ -278,6 +323,8 @@ const _EditPermissionsModal: FC = withAdminPermissions( }, ] = useCreateRolePermissionsMutation() + const tagBasedPermissions = + Utils.getFlagsmithHasFeature('tag_based_permissions') && !!role useEffect(() => { const isSaving = isRolePermCreating || isRolePermUpdating if (isSaving) { @@ -387,7 +434,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( if ( !entityPermissions.admin && !entityPermissions.permissions.find( - (v) => v === `VIEW_${parentLevel.toUpperCase()}`, + (v) => v.permission_key === `VIEW_${parentLevel.toUpperCase()}`, ) ) { // e.g. trying to set an environment permission but don't have view_project @@ -426,7 +473,24 @@ const _EditPermissionsModal: FC = withAdminPermissions( const hasPermission = (key: string) => { if (admin()) return true - return entityPermissions.permissions.includes(key) + return entityPermissions.permissions.find( + (permission) => permission.permission_key === key, + ) + } + + const getPermissionType = (key: string) => { + if (admin()) return 'GRANTED' + const permission = entityPermissions.permissions.find( + (v) => v.permission_key === key, + ) + + if (!permission) return 'NONE' + + if (permission.tags?.length || limitedPermissions.includes(key)) { + return 'GRANTED_FOR_TAGS' + } + + return 'GRANTED' } const save = useCallback(() => { @@ -439,19 +503,33 @@ const _EditPermissionsModal: FC = withAdminPermissions( : `${level}s/${id}/user-permissions/${entityId}` setSaving(true) const action = entityId ? 'put' : 'post' - _data[action]( - `${Project.api}${url}${entityId && '/'}`, - entityPermissions, - ) - .then((res: EntityPermissions) => { - setEntityPermissions(res) - toast( - `${ - level.charAt(0).toUpperCase() + level.slice(1) - } Permissions Saved`, - ) - onSave && onSave() - }) + _data[action](`${Project.api}${url}${entityId && '/'}`, { + ...entityPermissions, + permissions: entityPermissions.permissions.map( + (v) => v.permission_key, + ), + }) + .then( + ( + res: Omit & { + permissions: string[] + }, + ) => { + setEntityPermissions({ + ...res, + permissions: (res.permissions || []).map((v) => ({ + permission_key: v, + tags: [], + })), + }) + toast( + `${ + level.charAt(0).toUpperCase() + level.slice(1) + } Permissions Saved`, + ) + onSave && onSave() + }, + ) .catch(() => { toast(`Error Saving Permissions`, 'danger') }) @@ -460,7 +538,10 @@ const _EditPermissionsModal: FC = withAdminPermissions( }) } else { const body = { - permissions: entityPermissions.permissions, + permissions: + level === 'organisation' + ? entityPermissions.permissions.map((v) => v.permission_key) + : entityPermissions.permissions, } as Partial if (level === 'project') { body.admin = entityPermissions.admin @@ -468,7 +549,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( } if (level === 'environment') { body.admin = entityPermissions.admin - body.environment = envId || id + body.environment = (envId || id) as number } if (entityId || permissionWasCreated) { updateRolePermissions({ @@ -505,6 +586,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( role, updateRolePermissions, ]) + const [limitedPermissions, setLimitedPermissions] = useState([]) useEffect(() => { if (valueChanged) { @@ -512,13 +594,51 @@ const _EditPermissionsModal: FC = withAdminPermissions( } //eslint-disable-next-line }, [valueChanged]) + + const selectPermissions = ( + key: string, + value: 'GRANTED' | 'GRANTED_FOR_TAGS' | 'NONE', + tags: number[] = [], + ) => { + const updatedPermissions = [ + ...entityPermissions.permissions.filter( + (v) => v.permission_key !== key, + ), + ] + const updatedLimitedPermissions = limitedPermissions.filter( + (v) => v !== key, + ) + if (value === 'NONE') { + setEntityPermissions({ + ...entityPermissions, + permissions: updatedPermissions, + }) + } else { + setEntityPermissions({ + ...entityPermissions, + permissions: updatedPermissions.concat([ + { + permission_key: key, + tags, + }, + ]), + }) + } + if (value === 'GRANTED_FOR_TAGS') { + setLimitedPermissions(updatedLimitedPermissions.concat([key])) + } else { + setLimitedPermissions(updatedLimitedPermissions) + } + } const togglePermission = (key: string) => { if (role) { permissionChanged?.() const updatedPermissions = [...entityPermissions.permissions] - const index = updatedPermissions.indexOf(key) + const index = updatedPermissions.findIndex( + (v) => v.permission_key === key, + ) if (index === -1) { - updatedPermissions.push(key) + updatedPermissions.push({ permission_key: key, tags: [] }) } else { updatedPermissions.splice(index, 1) } @@ -530,10 +650,14 @@ const _EditPermissionsModal: FC = withAdminPermissions( } else { const newEntityPermissions = { ...entityPermissions } - const index = newEntityPermissions.permissions.indexOf(key) - + const index = newEntityPermissions.permissions.findIndex( + (v) => v.permission_key === key, + ) if (index === -1) { - newEntityPermissions.permissions.push(key) + newEntityPermissions.permissions.push({ + permission_key: key, + tags: [], + }) } else { newEntityPermissions.permissions.splice(index, 1) } @@ -600,7 +724,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( deleteRolePermissionUser({ organisation_id: id, role_id: roleId, - user_id: roleSelected?.user_role_id, + user_id: roleSelected?.user_role_id!, }).then(onRoleRemoved as any) } } @@ -613,7 +737,7 @@ const _EditPermissionsModal: FC = withAdminPermissions( }).then(onRoleRemoved as any) } else if (roleSelected) { deleteRolePermissionGroup({ - group_id: roleSelected.group_role_id, + group_id: roleSelected.group_role_id!, organisation_id: id, role_id: roleId, }).then(onRoleRemoved as any) @@ -628,7 +752,8 @@ const _EditPermissionsModal: FC = withAdminPermissions( if (user) { setRolesSelected( (rolesSelected || []).concat({ - role: usersData?.role, + group_role_id: undefined, + role: usersData?.role!, user_role_id: usersData?.id, }), ) @@ -637,7 +762,8 @@ const _EditPermissionsModal: FC = withAdminPermissions( setRolesSelected( (rolesSelected || []).concat({ group_role_id: groupsData?.id, - role: groupsData?.role, + role: groupsData?.role!, + user_role_id: undefined, }), ) } @@ -657,27 +783,30 @@ const _EditPermissionsModal: FC = withAdminPermissions( if (matchedRole) { if (user) { return { + group_role_id: undefined, ...role, user_role_id: matchedRole.user_role_id, } } if (group) { return { + user_role_id: undefined, ...role, group_role_id: matchedRole.group_role_id, } } } - return role + return { + group_role_id: undefined, + user_role_id: undefined, + ...role, + } }) } const rolesAdded = getRoles(roles, rolesSelected || []) - const isAdmin = admin() - const [search, setSearch] = useState() - return !permissions || !entityPermissions ? (
@@ -696,50 +825,90 @@ const _EditPermissionsModal: FC = withAdminPermissions( { - toggleAdmin() - setValueChanged(true) - }} - checked={isAdmin} - /> - -
- )} + data-test={`admin-switch-${level}`} + onChange={() => { + toggleAdmin() + setValueChanged(true) + }} + checked={isAdmin} + /> + +
+ )} { + filterRow={(item: AvailablePermission, search: string) => { const name = Format.enumeration.get(item.key).toLowerCase() return name.includes(search?.toLowerCase() || '') }} title='Permissions' - className='no-pad mb-2' + className='no-pad mb-2 overflow-visible' items={permissions} - renderRow={(p: AvailablePermission) => { - const levelUpperCase = level.toUpperCase() - const disabled = - level !== 'organisation' && - p.key !== `VIEW_${levelUpperCase}` && - !hasPermission(`VIEW_${levelUpperCase}`) - return ( - - - - {Format.enumeration.get(p.key)} + renderRow={(p: AvailablePermission, index: number) => { + const levelUpperCase = level.toUpperCase() + const disabled = + level !== 'organisation' && + p.key !== `VIEW_${levelUpperCase}` && + !hasPermission(`VIEW_${levelUpperCase}`) + const permission = entityPermissions.permissions.find( + (v) => v.permission_key === p.key, + ) + const permissionType = getPermissionType(p.key) + return ( + + + + {Format.enumeration.get(p.key)}
{p.description}
-
- { - setValueChanged(true) - togglePermission(p.key) - }} - disabled={disabled || admin() || saving} - checked={!disabled && hasPermission(p.key)} - /> + {permissionType === 'GRANTED_FOR_TAGS' && ( + { + setValueChanged(true) + selectPermissions(p.key, 'GRANTED_FOR_TAGS', v) + }} + /> + )} +
+ {tagBasedPermissions ? ( +
+ { const { email, first_name, id, last_name } = @@ -415,7 +416,11 @@ const CreateGroup: FC = ({ group, orgId, roles }) => {
{group ? ( <> - diff --git a/frontend/web/components/modals/CreateRole.tsx b/frontend/web/components/modals/CreateRole.tsx index a310afee599e..579437cfab30 100644 --- a/frontend/web/components/modals/CreateRole.tsx +++ b/frontend/web/components/modals/CreateRole.tsx @@ -349,6 +349,7 @@ const CreateRole: FC = ({ className: 'full-width', name: 'roleName', }} + data-test='role-name' value={roleName} unsaved={isEdit && roleNameChanged} onChange={(event: InputEvent) => { @@ -445,6 +446,7 @@ const CreateRole: FC = ({ Members} + data-test='members-tab' >
@@ -513,6 +515,7 @@ const CreateRole: FC = ({ Permissions} + data-test='permissions-tab' >
{({ permission: publishPermission }) => ( diff --git a/frontend/web/components/pages/EnvironmentSettingsPage.js b/frontend/web/components/pages/EnvironmentSettingsPage.js index 687969ddf64e..4ba9dfa4782a 100644 --- a/frontend/web/components/pages/EnvironmentSettingsPage.js +++ b/frontend/web/components/pages/EnvironmentSettingsPage.js @@ -17,7 +17,7 @@ import Icon from 'components/Icon' import PageTitle from 'components/PageTitle' import { getStore } from 'common/store' import { getRoles } from 'common/services/useRole' -import { getRolesEnvironmentPermissions } from 'common/services/useRolePermission' +import { getRoleEnvironmentPermissions } from 'common/services/useRolePermission' import AccountStore from 'common/stores/account-store' import { Link } from 'react-router-dom' import { enableFeatureVersioning } from 'common/services/useEnableFeatureVersioning' @@ -67,7 +67,7 @@ const EnvironmentSettingsPage = class extends Component { { organisation_id: AccountStore.getOrganisation().id }, { forceRefetch: true }, ).then((roles) => { - getRolesEnvironmentPermissions( + getRoleEnvironmentPermissions( getStore(), { env_id: env.id, @@ -76,6 +76,7 @@ const EnvironmentSettingsPage = class extends Component { }, { forceRefetch: true }, ).then((res) => { + debugger const matchingItems = roles.data.results.filter((item1) => res.data.results.some((item2) => item2.role === item1.id), ) @@ -165,8 +166,6 @@ const EnvironmentSettingsPage = class extends Component { allow_client_traits: !!this.state.allow_client_traits, banner_colour: this.state.banner_colour, banner_text: this.state.banner_text, - use_identity_overrides_in_local_eval: - this.state.use_identity_overrides_in_local_eval, description: description || env.description, hide_disabled_flags: this.state.hide_disabled_flags, hide_sensitive_data: !!this.state.hide_sensitive_data, @@ -176,6 +175,8 @@ const EnvironmentSettingsPage = class extends Component { name: name || env.name, use_identity_composite_key_for_hashing: !!this.state.use_identity_composite_key_for_hashing, + use_identity_overrides_in_local_eval: + this.state.use_identity_overrides_in_local_eval, use_mv_v2_evaluation: !!this.state.use_mv_v2_evaluation, }), ) @@ -256,10 +257,10 @@ const EnvironmentSettingsPage = class extends Component { props: { webhooks, webhooksLoading }, state: { allow_client_traits, - use_identity_overrides_in_local_eval, hide_sensitive_data, name, use_identity_composite_key_for_hashing, + use_identity_overrides_in_local_eval, use_v2_feature_versioning, }, } = this @@ -286,8 +287,6 @@ const EnvironmentSettingsPage = class extends Component { ) { setTimeout(() => { this.setState({ - use_identity_overrides_in_local_eval: - !!env.use_identity_overrides_in_local_eval, allow_client_traits: !!env.allow_client_traits, banner_colour: env.banner_colour || Constants.tagColors[0], banner_text: env.banner_text, @@ -301,6 +300,8 @@ const EnvironmentSettingsPage = class extends Component { name: env.name, use_identity_composite_key_for_hashing: !!env.use_identity_composite_key_for_hashing, + use_identity_overrides_in_local_eval: + !!env.use_identity_overrides_in_local_eval, use_v2_feature_versioning: !!env.use_v2_feature_versioning, }) }, 10) @@ -447,15 +448,18 @@ const EnvironmentSettingsPage = class extends Component { {Utils.getFlagsmithHasFeature('feature_versioning') && (
- { - this.setState({ - use_v2_feature_versioning: true, - }) - }} - /> + {use_v2_feature_versioning === false && ( + { + this.setState({ + use_v2_feature_versioning: true, + }) + }} + /> + )} + {' '} for your selected environment. - - {({ permission }) => ( - - -
- { + +
+ { + this.setState( + { + search: Utils.safeParseEventValue(e), + }, + this.filter, + ) + }} + value={this.state.search} + /> + + { + this.setState( + { + tag_strategy, + }, + this.filter, + ) + }} + value={this.state.tags} + onToggleArchived={(value) => { + if (value !== this.state.showArchived) { + FeatureListStore.isLoading = true this.setState( { - search: - Utils.safeParseEventValue(e), + showArchived: + !this.state.showArchived, }, this.filter, ) - }} - value={this.state.search} - /> - - { - this.setState( - { - tag_strategy, - }, - this.filter, - ) - }} - value={this.state.tags} - onToggleArchived={(value) => { - if ( - value !== this.state.showArchived - ) { - FeatureListStore.isLoading = true - this.setState( - { - showArchived: - !this.state.showArchived, - }, - this.filter, - ) - } - }} - showArchived={this.state.showArchived} - onClearAll={() => { - FeatureListStore.isLoading = true - this.setState( - { showArchived: false, tags: [] }, - this.filter, - ) - }} - onChange={(tags, isAutomated) => { - FeatureListStore.isLoading = true - if ( - tags.includes('') && - tags.length > 1 - ) { - if ( - !this.state.tags.includes('') - ) { - this.setState( - { tags: [''] }, - this.filter, - ) - } else { - this.setState( - { - tags: tags.filter( - (v) => !!v, - ), - }, - this.filter, - ) - } - } else { - this.setState( - { tags }, - this.filter, - ) - } - }} - /> - { + } + }} + showArchived={this.state.showArchived} + onClearAll={() => { + FeatureListStore.isLoading = true + this.setState( + { showArchived: false, tags: [] }, + this.filter, + ) + }} + onChange={(tags, isAutomated) => { + FeatureListStore.isLoading = true + if ( + tags.includes('') && + tags.length > 1 + ) { + if (!this.state.tags.includes('')) { this.setState( - { - is_enabled: enabled, - value_search: valueSearch, - }, + { tags: [''] }, this.filter, ) - }} - /> - { - FeatureListStore.isLoading = true + } else { this.setState( { - owners: owners, + tags: tags.filter((v) => !!v), }, this.filter, ) - }} - /> - { - FeatureListStore.isLoading = true - this.setState( - { - group_owners: group_owners, - }, - this.filter, - ) - }} - /> - - { - FeatureListStore.isLoading = true - this.setState({ sort }, this.filter) - }} - /> - -
- - } - nextPage={() => - this.filter(FeatureListStore.paging.next) - } - prevPage={() => - this.filter(FeatureListStore.paging.previous) - } - goToPage={(page) => this.filter(page)} - items={projectFlags?.filter((v) => !v.ignore)} - renderFooter={() => ( - <> - + { + this.setState( + { + is_enabled: enabled, + value_search: valueSearch, + }, + this.filter, + ) + }} + /> + { + FeatureListStore.isLoading = true + this.setState( + { + owners: owners, + }, + this.filter, + ) + }} + /> + { + FeatureListStore.isLoading = true + this.setState( + { + group_owners: group_owners, + }, + this.filter, + ) + }} + /> + - { + FeatureListStore.isLoading = true + this.setState({ sort }, this.filter) + }} /> - + +
+ + } + nextPage={() => + this.filter(FeatureListStore.paging.next) + } + prevPage={() => + this.filter(FeatureListStore.paging.previous) + } + goToPage={(page) => this.filter(page)} + items={projectFlags?.filter((v) => !v.ignore)} + renderFooter={() => ( + <> + + + + )} + renderRow={(projectFlag, i) => ( + ( + id={this.props.match.params.environmentId} + > + {({ permission }) => ( )} - /> -
- )} -
+ + )} + /> + { - getRolesProjectPermissions( + getRoleProjectPermissions( getStore(), { organisation_id: AccountStore.getOrganisation().id, diff --git a/frontend/web/components/pages/UserPage.tsx b/frontend/web/components/pages/UserPage.tsx index 43454dc20e09..f8b3967f9971 100644 --- a/frontend/web/components/pages/UserPage.tsx +++ b/frontend/web/components/pages/UserPage.tsx @@ -336,626 +336,720 @@ const UserPage: FC = (props) => { id={environmentId} > {({ permission: manageUserPermission }) => ( - - {({ permission }) => ( -
- - {( - { - environmentFlags, - identity, - identityFlags, - isLoading, - projectFlags, - traits, - }: any, - { toggleFlag }: any, - ) => - isLoading && - !tags.length && - !showArchived && - typeof search !== 'string' && - (!identityFlags || !actualFlags || !projectFlags) ? ( -
- -
- ) : ( - <> - -
- + + {( + { + environmentFlags, + identity, + identityFlags, + isLoading, + projectFlags, + traits, + }: any, + { toggleFlag }: any, + ) => + isLoading && + !tags.length && + !showArchived && + typeof search !== 'string' && + (!identityFlags || !actualFlags || !projectFlags) ? ( +
+ +
+ ) : ( + <> + +
+ + {showAliases && ( +
+ + Alias:{' '} + } + > + Aliases allow you to add searchable names to + an identity + + - {showAliases && ( -
- - Alias:{' '} - - } +
+ )} +
+ +
+ } + > + View and manage feature states and traits for this user. +
+
+
+
+ + + + Features +
+ - Aliases allow you to add searchable names - to an identity - - +
+
+ } + renderFooter={() => ( + <> + + + + + )} + header={ + +
+ { + FeatureListStore.isLoading = true + setSearch(Utils.safeParseEventValue(e)) + }} + value={search} /> - - )} -
- -
- } - > - View and manage feature states and traits for this - user. -
- -
-
- - - - Features -
- - Overriding features here will take - priority over any segment override. - Any features that are not overridden - for this user will fallback to any - segment overrides or the environment - defaults. - -
-
- } - renderFooter={() => ( - <> - + { + setTagStrategy(strategy) + }} + isLoading={FeatureListStore.isLoading} + onToggleArchived={(value) => { + if (value !== showArchived) { + FeatureListStore.isLoading = true + setShowArchived(!showArchived) + } + }} + showArchived={showArchived} + onChange={(newTags) => { + FeatureListStore.isLoading = true + setTags( + newTags.includes('') && + newTags.length > 1 + ? [''] + : newTags, + ) + }} /> - { + setIsEnabled(enabled) + setValueSearch(valueSearch) + }} + /> + { + FeatureListStore.isLoading = true + setOwners(newOwners) + }} /> - { + FeatureListStore.isLoading = true + setGroupOwners(newGroupOwners) + }} + /> + + { + FeatureListStore.isLoading = true + setSort(newSort) + }} /> - - )} - header={ - -
- { - FeatureListStore.isLoading = true - setSearch( - Utils.safeParseEventValue(e), - ) - }} - value={search} - /> - - { - setTagStrategy(strategy) - }} - isLoading={ - FeatureListStore.isLoading - } - onToggleArchived={(value) => { - if (value !== showArchived) { - FeatureListStore.isLoading = - true - setShowArchived(!showArchived) - } - }} - showArchived={showArchived} - onChange={(newTags) => { - FeatureListStore.isLoading = true - setTags( - newTags.includes('') && - newTags.length > 1 - ? [''] - : newTags, - ) - }} - /> - { - setIsEnabled(enabled) - setValueSearch(valueSearch) - }} - /> - { - FeatureListStore.isLoading = true - setOwners(newOwners) - }} - /> - { - FeatureListStore.isLoading = true - setGroupOwners(newGroupOwners) - }} - /> - - { - FeatureListStore.isLoading = true - setSort(newSort) - }} - /> - -
- } - isLoading={FeatureListStore.isLoading} - items={projectFlags} - renderRow={( - { description, id: featureId, name }: any, - i: number, - ) => { - const identityFlag = - identityFlags[featureId] || {} - const environmentFlag = - (environmentFlags && - environmentFlags[featureId]) || - {} - const hasUserOverride = - identityFlag.identity || - identityFlag.identity_uuid - const flagEnabled = hasUserOverride - ? identityFlag.enabled - : environmentFlag.enabled - const flagValue = hasUserOverride - ? identityFlag.feature_state_value - : environmentFlag.feature_state_value - const actualEnabled = - actualFlags && actualFlags[name]?.enabled - const actualValue = - actualFlags && - actualFlags[name]?.feature_state_value - const flagEnabledDifferent = hasUserOverride - ? false - : actualEnabled !== flagEnabled - const flagValueDifferent = hasUserOverride - ? false - : !valuesEqual(actualValue, flagValue) - const projectFlag = projectFlags?.find( - (p: any) => - p.id === environmentFlag.feature, - ) - const isMultiVariateOverride = - flagValueDifferent && - projectFlag?.multivariate_options?.find( - (v: any) => - Utils.featureStateToValue(v) === - actualValue, +
+ + } + isLoading={FeatureListStore.isLoading} + items={projectFlags} + renderRow={( + { description, id: featureId, name, tags }: any, + i: number, + ) => { + return ( + + {({ permission }) => { + const identityFlag = + identityFlags[featureId] || {} + const environmentFlag = + (environmentFlags && + environmentFlags[featureId]) || + {} + const hasUserOverride = + identityFlag.identity || + identityFlag.identity_uuid + const flagEnabled = hasUserOverride + ? identityFlag.enabled + : environmentFlag.enabled + const flagValue = hasUserOverride + ? identityFlag.feature_state_value + : environmentFlag.feature_state_value + const actualEnabled = + actualFlags && + actualFlags[name]?.enabled + const actualValue = + actualFlags && + actualFlags[name]?.feature_state_value + const flagEnabledDifferent = + hasUserOverride + ? false + : actualEnabled !== flagEnabled + const flagValueDifferent = hasUserOverride + ? false + : !valuesEqual(actualValue, flagValue) + const projectFlag = projectFlags?.find( + (p: any) => + p.id === environmentFlag.feature, ) - const flagDifferent = - flagEnabledDifferent || flagValueDifferent - - const onClick = () => { - if (permission) { - editFeature( - projectFlag, - environmentFlags[featureId], - identityFlags[featureId] || - actualFlags![name], - identityFlags[featureId] - ?.multivariate_feature_state_values, + const isMultiVariateOverride = + flagValueDifferent && + projectFlag?.multivariate_options?.find( + (v: any) => + Utils.featureStateToValue(v) === + actualValue, ) + const flagDifferent = + flagEnabledDifferent || + flagValueDifferent + + const onClick = () => { + if (permission) { + editFeature( + projectFlag, + environmentFlags[featureId], + identityFlags[featureId] || + actualFlags![name], + identityFlags[featureId] + ?.multivariate_feature_state_values, + ) + } } - } - const isCompact = - getViewMode() === 'compact' - if (name === preselect && actualFlags) { - setPreselect(null) - onClick() - } + const isCompact = + getViewMode() === 'compact' + if (name === preselect && actualFlags) { + setPreselect(null) + onClick() + } - return ( -
- - - - - - - {description ? ( - {name} - } - > - {description} - - ) : ( - name - )} - - - - - - {hasUserOverride ? ( -
- Overriding defaults -
- ) : flagEnabledDifferent ? ( -
+ + + + - - {isMultiVariateOverride ? ( - - This flag is being - overridden by a - variation defined on - your feature, the - control value is{' '} - - {flagEnabled - ? 'on' - : 'off'} - {' '} - for this user - + + {description ? ( + {name} + } + > + {description} + ) : ( - - This flag is being - overridden by segments - and would normally be{' '} - - {flagEnabled - ? 'on' - : 'off'} - {' '} - for this user - + name )} - - -
- ) : flagValueDifferent ? ( - isMultiVariateOverride ? ( -
- - This feature is being - overridden by a % - variation in the - environment, the control - value of this feature is{' '} - + + + + + {hasUserOverride ? ( +
+ Overriding defaults
- ) : ( + ) : flagEnabledDifferent ? (
- - This feature is being - overridden by segments and - would normally be{' '} - {' '} - for this user - + + + {isMultiVariateOverride ? ( + + This flag is being + overridden by a + variation defined on + your feature, the + control value is{' '} + + {flagEnabled + ? 'on' + : 'off'} + {' '} + for this user + + ) : ( + + This flag is being + overridden by + segments and would + normally be{' '} + + {flagEnabled + ? 'on' + : 'off'} + {' '} + for this user + + )} + +
- ) - ) : ( - getViewMode() === 'default' && ( -
- Using environment defaults -
- ) - )} - - - -
- -
-
e.stopPropagation()} - > - {Utils.renderWithPermission( - permission, - Constants.environmentPermissions( - Utils.getManageFeaturePermissionDescription( - false, - true, + ) : flagValueDifferent ? ( + isMultiVariateOverride ? ( +
+ + This feature is being + overridden by a % + variation in the + environment, the control + value of this feature is{' '} + + +
+ ) : ( +
+ + This feature is being + overridden by segments + and would normally be{' '} + {' '} + for this user + +
+ ) + ) : ( + getViewMode() === + 'default' && ( +
+ Using environment defaults +
+ ) + )} + + + +
+ +
+
e.stopPropagation()} + > + {Utils.renderWithPermission( + permission, + Constants.environmentPermissions( + Utils.getManageFeaturePermissionDescription( + false, + true, + ), ), - ), - - confirmToggle( - projectFlag, - actualFlags![name], - () => - toggleFlag({ - environmentFlag: - actualFlags![name], - environmentId, - identity: id, - identityFlag, - projectFlag: { - id: featureId, - }, - }), - ) - } - />, - )} -
-
e.stopPropagation()} - > - {hasUserOverride && ( - <> - {Utils.renderWithPermission( - permission, - Constants.environmentPermissions( - Utils.getManageFeaturePermissionDescription( - false, - true, + + confirmToggle( + projectFlag, + actualFlags![name], + () => + toggleFlag({ + environmentFlag: + actualFlags![name], + environmentId, + identity: id, + identityFlag, + projectFlag: { + id: featureId, + }, + }), + ) + } + />, + )} +
+
e.stopPropagation()} + > + {hasUserOverride && ( + <> + {Utils.renderWithPermission( + permission, + Constants.environmentPermissions( + Utils.getManageFeaturePermissionDescription( + false, + true, + ), ), - ), - , - )} - - )} + , + )} + + )} +
+ ) + }} + + ) + }} + renderSearchWithNoResults + paging={FeatureListStore.paging} + search={search} + nextPage={() => + AppActions.getFeatures( + projectId, + environmentId, + true, + search, + sort, + FeatureListStore.paging.next, + getFilter(), + ) + } + prevPage={() => + AppActions.getFeatures( + projectId, + environmentId, + true, + search, + sort, + FeatureListStore.paging.previous, + getFilter(), + ) + } + goToPage={(pageNumber: number) => + AppActions.getFeatures( + projectId, + environmentId, + true, + search, + sort, + pageNumber, + getFilter(), + ) + } + /> + + {!preventAddTrait && ( + + + {Utils.renderWithPermission( + manageUserPermission, + Constants.environmentPermissions( + Utils.getManageUserPermissionDescription(), + ), + , + )} +
+ } + header={ + + + Trait + + Value +
+ Remove +
+
+ } + renderRow={( + { id, trait_key, trait_value }: any, + i: number, + ) => ( + + editTrait({ + id, + trait_key, + trait_value, + }) + } + > + +
+ {trait_key}
- ) - }} - renderSearchWithNoResults - paging={FeatureListStore.paging} - search={search} - nextPage={() => - AppActions.getFeatures( - projectId, - environmentId, - true, - search, - sort, - FeatureListStore.paging.next, - getFilter(), - ) - } - prevPage={() => - AppActions.getFeatures( - projectId, - environmentId, - true, - search, - sort, - FeatureListStore.paging.previous, - getFilter(), - ) - } - goToPage={(pageNumber: number) => - AppActions.getFeatures( - projectId, - environmentId, - true, - search, - sort, - pageNumber, - getFilter(), - ) - } - /> - - {!preventAddTrait && ( - - + + + +
e.stopPropagation()} + > + {Utils.renderWithPermission( + manageUserPermission, + Constants.environmentPermissions( + Utils.getManageUserPermissionDescription(), + ), + , + )} +
+
+ )} + renderNoResults={ + + className='no-pad' + action={ +
{Utils.renderWithPermission( manageUserPermission, Constants.environmentPermissions( @@ -963,7 +1057,9 @@ const UserPage: FC = (props) => { ),
} + > +
+ + This user has no traits. + +
+
+ } + filterRow={( + { trait_key }: any, + searchString: string, + ) => + trait_key + .toLowerCase() + .indexOf(searchString.toLowerCase()) > -1 + } + /> + + )} + + {({ segments }: any) => + !segments ? ( +
+ +
+ ) : ( + + - - Trait + + Name - Value + Description -
- Remove -
} + items={segments || []} renderRow={( - { id, trait_key, trait_value }: any, + { created_date, description, name }: any, i: number, ) => ( - editTrait({ - id, - trait_key, - trait_value, - }) - } + key={i} + onClick={() => editSegment(segments[i])} > - +
+ editSegment(segments[i]) + } > - {trait_key} + + {name} + +
+
+ Created{' '} + {moment(created_date).format( + 'DD/MMM/YYYY', + )}
- - - -
e.stopPropagation()} - > - {Utils.renderWithPermission( - manageUserPermission, - Constants.environmentPermissions( - Utils.getManageUserPermissionDescription(), - ), - , + + {description && ( +
{description}
)} -
+
)} renderNoResults={ - {Utils.renderWithPermission( - manageUserPermission, - Constants.environmentPermissions( - Utils.getManageUserPermissionDescription(), - ), - , - )} -
- } >
- This user has no traits. + This user is not a member of any + segments.
} filterRow={( - { trait_key }: any, + { name }: any, searchString: string, ) => - trait_key + name .toLowerCase() .indexOf(searchString.toLowerCase()) > -1 } /> - )} - - {({ segments }: any) => - !segments ? ( -
- -
- ) : ( - - - - Name - - - Description - - - } - items={segments || []} - renderRow={( - { - created_date, - description, - name, - }: any, - i: number, - ) => ( - - editSegment(segments[i]) - } - > - -
- editSegment(segments[i]) - } - > - - {name} - -
-
- Created{' '} - {moment(created_date).format( - 'DD/MMM/YYYY', - )} -
-
- - {description && ( -
{description}
- )} -
-
- )} - renderNoResults={ - -
- - This user is not a member of any - segments. - -
-
- } - filterRow={( - { name }: any, - searchString: string, - ) => - name - .toLowerCase() - .indexOf( - searchString.toLowerCase(), - ) > -1 - } - /> -
- ) - } -
- -
-
- - - - - - -
-
- - ) - } - -
- )} - + ) + } + + +
+
+ + + + + + +
+
+ + ) + } + +
)}
diff --git a/frontend/web/components/pages/UsersAndPermissionsPage.tsx b/frontend/web/components/pages/UsersAndPermissionsPage.tsx index 473824f93b5c..dbe521f29b1c 100644 --- a/frontend/web/components/pages/UsersAndPermissionsPage.tsx +++ b/frontend/web/components/pages/UsersAndPermissionsPage.tsx @@ -38,6 +38,7 @@ import Icon from 'components/Icon' import RolesTable from 'components/RolesTable' import UsersGroups from 'components/UsersGroups' import PlanBasedBanner, { getPlanBasedOption } from 'components/PlanBasedAccess' +import { useHasPermission } from 'common/providers/Permission' type UsersAndPermissionsPageType = { router: RouterChildContext['router'] @@ -68,15 +69,22 @@ const UsersAndPermissionsInner: FC = ({ subscriptionMeta, users, }) => { - const orgId = AccountStore.getOrganisation().id const paymentsEnabled = Utils.getFlagsmithHasFeature('payments_enabled') const verifySeatsLimit = Utils.getFlagsmithHasFeature( 'verify_seats_limit_for_invite_links', ) - const permissionsError = !( - AccountStore.getUser() && AccountStore.getOrganisationRole() === 'ADMIN' - ) + const manageUsersPermission = useHasPermission({ + id: AccountStore.getOrganisation()?.id, + level: 'organisation', + permission: 'MANAGE_USERS', + }) + const manageGroupsPermission = useHasPermission({ + id: AccountStore.getOrganisation()?.id, + level: 'organisation', + permission: 'MANAGE_USER_GROUPS', + }) + const roleChanged = (id: number, { value: role }: { value: string }) => { AppActions.updateUserRole(id, role) } @@ -222,11 +230,12 @@ const UsersAndPermissionsInner: FC = ({
Team Members
{Utils.renderWithPermission( - !permissionsError, + !manageUsersPermission.permission, Constants.organisationPermissions('Admin'),
- + { !!this.state.tags.length) && !isLoading) ? (
- - {({ permission }) => ( -
- { - this.setState( - { search: Utils.safeParseEventValue(e) }, - () => { - AppActions.searchFeatures( - this.props.projectId, - this.props.environmentId, - true, - this.state.search, - this.state.sort, - this.getFilter(), - this.props.pageSize, - ) - }, - ) - }} - nextPage={() => - AppActions.getFeatures( - this.props.projectId, - this.props.environmentId, - true, - this.state.search, - this.state.sort, - ( - FeatureListStore.paging as PagedResponse - ).next || 1, - this.getFilter(), - this.props.pageSize, - ) - } - prevPage={() => - AppActions.getFeatures( +
+ { + this.setState( + { search: Utils.safeParseEventValue(e) }, + () => { + AppActions.searchFeatures( this.props.projectId, this.props.environmentId, true, this.state.search, this.state.sort, - ( - FeatureListStore.paging as PagedResponse - ).previous, this.getFilter(), this.props.pageSize, ) - } - goToPage={(page: number) => - AppActions.getFeatures( - this.props.projectId, - this.props.environmentId, - true, - this.state.search, - this.state.sort, - page, - this.getFilter(), - this.props.pageSize, - ) - } - onSortChange={(sort: string) => { - this.setState({ sort }, () => { - AppActions.getFeatures( - this.props.projectId, - this.props.environmentId, - true, - this.state.search, - this.state.sort, - 0, - this.getFilter(), - this.props.pageSize, - ) - }) - }} - sorting={[ - { - default: true, - label: 'Name', - order: 'asc', - value: 'name', - }, - { - label: 'Created Date', - order: 'asc', - value: 'created_date', - }, - ]} - items={projectFlags} - header={ - this.props.hideTags ? null : ( - - + }, + ) + }} + nextPage={() => + AppActions.getFeatures( + this.props.projectId, + this.props.environmentId, + true, + this.state.search, + this.state.sort, + ( + FeatureListStore.paging as PagedResponse + ).next || 1, + this.getFilter(), + this.props.pageSize, + ) + } + prevPage={() => + AppActions.getFeatures( + this.props.projectId, + this.props.environmentId, + true, + this.state.search, + this.state.sort, + ( + FeatureListStore.paging as PagedResponse + ).previous, + this.getFilter(), + this.props.pageSize, + ) + } + goToPage={(page: number) => + AppActions.getFeatures( + this.props.projectId, + this.props.environmentId, + true, + this.state.search, + this.state.sort, + page, + this.getFilter(), + this.props.pageSize, + ) + } + onSortChange={(sort: string) => { + this.setState({ sort }, () => { + AppActions.getFeatures( + this.props.projectId, + this.props.environmentId, + true, + this.state.search, + this.state.sort, + 0, + this.getFilter(), + this.props.pageSize, + ) + }) + }} + sorting={[ + { + default: true, + label: 'Name', + order: 'asc', + value: 'name', + }, + { + label: 'Created Date', + order: 'asc', + value: 'created_date', + }, + ]} + items={projectFlags} + header={ + this.props.hideTags ? null : ( + + + this.setState( + { showArchived: false, tags: [] }, + this.filter, + ) + } + projectId={projectId} + value={this.state.tags} + tagStrategy={this.state.tag_strategy} + onChangeStrategy={(tag_strategy) => { + this.setState( + { tag_strategy }, + this.filter, + ) + }} + onChange={(tags) => { + FeatureListStore.isLoading = true + if ( + tags.includes('') && + tags.length > 1 + ) { + if (!this.state.tags.includes('')) { this.setState( - { showArchived: false, tags: [] }, + { tags: [''] }, this.filter, ) - } - projectId={projectId} - value={this.state.tags} - tagStrategy={this.state.tag_strategy} - onChangeStrategy={(tag_strategy) => { + } else { this.setState( - { tag_strategy }, + { + tags: tags.filter((v) => !!v), + }, this.filter, ) - }} - onChange={(tags) => { + } + } else { + this.setState({ tags }, this.filter) + } + AsyncStorage.setItem( + `${projectId}tags`, + JSON.stringify(tags), + ) + }} + > +
+ { FeatureListStore.isLoading = true - if ( - tags.includes('') && - tags.length > 1 - ) { - if ( - !this.state.tags.includes('') - ) { - this.setState( - { tags: [''] }, - this.filter, - ) - } else { - this.setState( - { - tags: tags.filter( - (v) => !!v, - ), - }, - this.filter, - ) - } - } else { - this.setState( - { tags }, - this.filter, - ) - } - AsyncStorage.setItem( - `${projectId}tags`, - JSON.stringify(tags), + this.setState( + { + showArchived: + !this.state.showArchived, + }, + this.filter, ) }} - > -
- { - FeatureListStore.isLoading = - true - this.setState( - { - showArchived: - !this.state.showArchived, - }, - this.filter, - ) - }} - className='px-2 py-2 ml-2 mr-2' - tag={{ - color: '#0AADDF', - label: 'Archived', - }} - /> -
- - - ) - } - renderRow={( - projectFlag: ProjectFlag, - i: number, - ) => ( + className='px-2 py-2 ml-2 mr-2' + tag={{ + color: '#0AADDF', + label: 'Archived', + }} + /> +
+
+
+ ) + } + renderRow={( + projectFlag: ProjectFlag, + i: number, + ) => ( + + {({ permission }) => ( { projectFlag={projectFlag} /> )} - filterRow={() => true} - /> -
- )} - + + )} + filterRow={() => true} + /> +
) : null}
diff --git a/frontend/web/components/tags/AddEditTags.tsx b/frontend/web/components/tags/AddEditTags.tsx index 0363268e061c..0eb8c0ad0cea 100644 --- a/frontend/web/components/tags/AddEditTags.tsx +++ b/frontend/web/components/tags/AddEditTags.tsx @@ -217,7 +217,11 @@ const AddEditTags: FC = ({ <>
editTag(tag)} - className='clickable' + className={ + !readOnly + ? 'clickable' + : 'opacity-0 pointer-events-none' + } >
diff --git a/frontend/web/components/tags/TagValues.tsx b/frontend/web/components/tags/TagValues.tsx index 719641b22f1b..f99ddda2f03c 100644 --- a/frontend/web/components/tags/TagValues.tsx +++ b/frontend/web/components/tags/TagValues.tsx @@ -2,6 +2,9 @@ import React, { FC, Fragment, ReactNode } from 'react' import Button from 'components/base/forms/Button' import Tag from './Tag' import { useGetTagsQuery } from 'common/services/useTag' +import Utils from 'common/utils/utils' +import Constants from 'common/constants' +import { useHasPermission } from 'common/providers/Permission' type TagValuesType = { onAdd?: () => void @@ -22,6 +25,16 @@ const TagValues: FC = ({ }) => { const { data: tags } = useGetTagsQuery({ projectId }) const Wrapper = inline ? Fragment : Row + const permissionType = Utils.getFlagsmithHasFeature('manage_tags_permission') + ? 'MANAGE_TAGS' + : 'ADMIN' + + const { permission: createEditTagPermission } = useHasPermission({ + id: projectId, + level: 'project', + permission: permissionType, + }) + return ( {children} @@ -36,11 +49,22 @@ const TagValues: FC = ({ /> ), )} - {!!onAdd && ( - - )} + {!!onAdd && + Utils.renderWithPermission( + createEditTagPermission, + Constants.projectPermissions( + permissionType === 'ADMIN' ? 'Admin' : 'Manage Tags', + ), + , + )} ) } diff --git a/frontend/web/project/project-components.js b/frontend/web/project/project-components.js index 5a81e3a065ac..345ff3968c82 100644 --- a/frontend/web/project/project-components.js +++ b/frontend/web/project/project-components.js @@ -149,7 +149,7 @@ global.Select = class extends PureComponent { className={`react-select ${props.size ? props.size : ''}`} classNamePrefix='react-select' {...props} - components={{ ...(props.components || {}), Option }} + components={{ Option, ...(props.components || {}) }} />
) diff --git a/frontend/web/styles/components/_panel.scss b/frontend/web/styles/components/_panel.scss index 65695c058c7d..fa9f32df0837 100644 --- a/frontend/web/styles/components/_panel.scss +++ b/frontend/web/styles/components/_panel.scss @@ -77,7 +77,9 @@ } } } - +.overflow-visible>.panel-content { + overflow: visible; +} .dark { .panel { .icon { diff --git a/frontend/web/styles/project/_modals.scss b/frontend/web/styles/project/_modals.scss index d0ebfa3df366..9cca000670b1 100644 --- a/frontend/web/styles/project/_modals.scss +++ b/frontend/web/styles/project/_modals.scss @@ -160,7 +160,7 @@ $side-width: 750px; .assignees-list-item { color: $bg-dark100; font-weight: 500; - padding: 16px 0; + padding: 8px 0; border-bottom: 1px solid $basic-alpha-16; } &.right { From ede72293c4ae47207cdbc40e822fc5e20a0e02a2 Mon Sep 17 00:00:00 2001 From: Flagsmith Bot <65724737+flagsmithdev@users.noreply.github.com> Date: Wed, 4 Dec 2024 13:52:29 +0000 Subject: [PATCH 55/77] chore(main): release 2.156.0 (#4882) --- .release-please-manifest.json | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ version.txt | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7beabe708fd1..d7d19324d164 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.155.0" + ".": "2.156.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc89e66f633..8c3c51c5eb77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2.156.0](https://github.com/Flagsmith/flagsmith/compare/v2.155.0...v2.156.0) (2024-12-04) + + +### Features + +* combine value + segment change requests for versioned environments ([#4738](https://github.com/Flagsmith/flagsmith/issues/4738)) ([e6b0e2f](https://github.com/Flagsmith/flagsmith/commit/e6b0e2f799d0c6bc513e622ca19b9698f6f76db2)) +* Enterprise licensing ([#3624](https://github.com/Flagsmith/flagsmith/issues/3624)) ([fbd1a13](https://github.com/Flagsmith/flagsmith/commit/fbd1a13497ef805e3b23e1877e7ccfe087cf197b)) +* Scheduled segment overrides ([#4805](https://github.com/Flagsmith/flagsmith/issues/4805)) ([bb7849c](https://github.com/Flagsmith/flagsmith/commit/bb7849c799fff98c1750adf1d5a057479994d41b)) +* tag based permissions ([#4853](https://github.com/Flagsmith/flagsmith/issues/4853)) ([7c4e2ff](https://github.com/Flagsmith/flagsmith/commit/7c4e2ff23ea66bf8e3dfb57b3492454a831b1bed)) + + +### Bug Fixes + +* add migration to clean up corrupt data caused by feature versioning ([#4873](https://github.com/Flagsmith/flagsmith/issues/4873)) ([e17f92d](https://github.com/Flagsmith/flagsmith/commit/e17f92df02c5b08a8f6ba498ce49185d37beb994)) +* button style ([#4884](https://github.com/Flagsmith/flagsmith/issues/4884)) ([679acdd](https://github.com/Flagsmith/flagsmith/commit/679acdd26cb18f16626a4809caa0aea65060e466)) +* cannot create new segments due to segment validation ([#4886](https://github.com/Flagsmith/flagsmith/issues/4886)) ([05ab7cf](https://github.com/Flagsmith/flagsmith/commit/05ab7cf876611de613aececc0f70f5987955e446)) +* Set Hubspot cookie name from request body ([#4880](https://github.com/Flagsmith/flagsmith/issues/4880)) ([7d3b253](https://github.com/Flagsmith/flagsmith/commit/7d3b2531c9abc02aceeee5193472ddc92bc772ac)) + ## [2.155.0](https://github.com/Flagsmith/flagsmith/compare/v2.154.0...v2.155.0) (2024-12-03) diff --git a/version.txt b/version.txt index 9170c650ade7..3e99ffa98e0b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.155.0 +2.156.0 From f5e3410dec3ed09ffe99a913836ce2ebd1044a6d Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Wed, 4 Dec 2024 16:26:56 +0000 Subject: [PATCH 56/77] fix: save versioned segment overrides (#4892) --- frontend/web/components/modals/CreateFlag.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index 16d1804d78e6..bbfb8e59d9c0 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -1414,8 +1414,10 @@ const CreateFlag = class extends Component { identity, ),