From 99c6e798d7058b57dd9eedadb46f95b3fba271fb Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 11:51:18 -0300 Subject: [PATCH 01/14] red stuff --- .../unit/environments/onboarding/__init__.py | 0 .../test_unit_environments_views.py | 154 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 api/tests/unit/environments/onboarding/__init__.py create mode 100644 api/tests/unit/environments/onboarding/test_unit_environments_views.py diff --git a/api/tests/unit/environments/onboarding/__init__.py b/api/tests/unit/environments/onboarding/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py new file mode 100644 index 000000000000..978bb6a36f4c --- /dev/null +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -0,0 +1,154 @@ +import freezegun +import pytest +from django.utils import timezone +from pytest_django.fixtures import DjangoAssertNumQueries +from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] +from pytest_mock import MockerFixture +from rest_framework.test import APIClient + +from environments.models import Environment +from environments.onboarding import views + +pytestmark = pytest.mark.freeze_time("2077-07-07T07:07:07Z") + + +@pytest.fixture +def onboarded_environment(environment: Environment) -> Environment: + environment.first_evaluated_at = timezone.now() + environment.first_evaluated_sdk_label = "flagsmith-python-sdk" + environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"]) + return environment + + +def test_get_onboarding_status__never_evaluated__responds_200( + api_client: APIClient, + environment: Environment, +) -> None: + # Given / When + response = api_client.get( + f"/api/v1/environments/{environment.api_key}/onboarding-status/" + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "first_evaluated_at": None, + "first_evaluated_sdk_label": None, + } + + +def test_get_onboarding_status__evaluated__responds_200( + api_client: APIClient, + onboarded_environment: Environment, +) -> None: + # Given / When + response = api_client.get( + f"/api/v1/environments/{onboarded_environment.api_key}/onboarding-status/" + ) + + # Then + assert response.status_code == 200 + assert response.json() == { + "first_evaluated_at": "2077-07-07T07:07:07Z", + "first_evaluated_sdk_label": "flagsmith-python-sdk", + } + + +@pytest.mark.parametrize( + "either_environment", + (lazy_fixture("environment"), lazy_fixture("onboarded_environment")), +) +def test_put_onboarding_status_async__regardless_evaluated__enqueues_and_responds_204( + api_client: APIClient, + django_assert_num_queries: DjangoAssertNumQueries, + either_environment: Environment, + mocker: MockerFixture, +) -> None: + # Given + task = mocker.patch.object(views, "record_environment_first_evaluation") + + # When + with django_assert_num_queries(0): + response = api_client.put( + f"/api/v1/environments/{either_environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + task.delay.assert_called_once_with( + args=(either_environment.api_key, "flagsmith-python-sdk"), + ) + + +def test_put_onboarding_status_sync__never_evaluated__updates_environment( + api_client: APIClient, + environment: Environment, + mocker: MockerFixture, +) -> None: + # Given + assert environment.first_evaluated_at is None + assert environment.first_evaluated_sdk_label is None + + # When + response = api_client.put( + f"/api/v1/environments/{environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + environment.refresh_from_db() + assert environment.first_evaluated_at == timezone.now() + assert environment.first_evaluated_sdk_label == "flagsmith-python-sdk" + + +def test_put_onboarding_status_sync__evaluated__does_not_update_environment( + api_client: APIClient, + onboarded_environment: Environment, +) -> None: + # Given + assert onboarded_environment.first_evaluated_at is not None + assert onboarded_environment.first_evaluated_sdk_label is not None + + # When + with freezegun.freeze_time("2088-08-08T08:08:08Z"): + response = api_client.put( + f"/api/v1/environments/{onboarded_environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-php-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + onboarded_environment.refresh_from_db() + assert "2077" in onboarded_environment.first_evaluated_at.isoformat() + assert "python" in onboarded_environment.first_evaluated_sdk_label + + +def test_put_onboarding_status__invalid_sdk_label__responds_400( + api_client: APIClient, + environment: Environment, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "invalid-sdk-label", + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "first_evaluated_sdk_label": ['"invalid-sdk-label" is not a valid choice.'], + } From 24b67ebb8c25d421921c195602fad3272c2efea7 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 09:59:04 -0300 Subject: [PATCH 02/14] let there be data --- .../0038_add_first_evaluated_fields.py | 51 +++++++++++++++++++ api/environments/models.py | 15 ++++++ 2 files changed, 66 insertions(+) create mode 100644 api/environments/migrations/0038_add_first_evaluated_fields.py diff --git a/api/environments/migrations/0038_add_first_evaluated_fields.py b/api/environments/migrations/0038_add_first_evaluated_fields.py new file mode 100644 index 000000000000..3b91d0a8b5fe --- /dev/null +++ b/api/environments/migrations/0038_add_first_evaluated_fields.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.16 on 2026-07-20 22:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("environments", "0037_add_uuid_field"), + ] + + operations = [ + migrations.AddField( + model_name="environment", + name="first_evaluated_at", + field=models.DateTimeField( + blank=True, + help_text="When the environment's flags were first evaluated by an SDK.", + null=True, + ), + ), + migrations.AddField( + model_name="environment", + name="first_evaluated_sdk_label", + field=models.CharField( + blank=True, + help_text="SDK that first evaluated the environment's flags.", + max_length=100, + null=True, + ), + ), + migrations.AddField( + model_name="historicalenvironment", + name="first_evaluated_at", + field=models.DateTimeField( + blank=True, + help_text="When the environment's flags were first evaluated by an SDK.", + null=True, + ), + ), + migrations.AddField( + model_name="historicalenvironment", + name="first_evaluated_sdk_label", + field=models.CharField( + blank=True, + help_text="SDK that first evaluated the environment's flags.", + max_length=100, + null=True, + ), + ), + ] diff --git a/api/environments/models.py b/api/environments/models.py index 18b90242f9f6..9ba7b123ab59 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -2,6 +2,7 @@ import typing import uuid from copy import deepcopy +from datetime import datetime from typing import TYPE_CHECKING, Literal from common.core.utils import using_database_replica @@ -24,6 +25,7 @@ from softdelete.models import SoftDeleteObject # type: ignore[import-untyped] from app.utils import create_hash +from app_analytics.types import KnownSDK from audit.constants import ( ENVIRONMENT_CREATED_MESSAGE, ENVIRONMENT_UPDATED_MESSAGE, @@ -160,6 +162,19 @@ class Environment( help_text="Attribute used to indicate when an environment is still being created (via clone for example)", ) + first_evaluated_at = models.DateTimeField[datetime | None, datetime | None]( + null=True, + blank=True, + help_text="When the environment's flags were first evaluated by an SDK.", + ) + + first_evaluated_sdk_label = models.CharField[KnownSDK, KnownSDK]( + null=True, + blank=True, + max_length=100, + help_text="SDK that first evaluated the environment's flags.", + ) + objects = EnvironmentManager() class Meta: From 4cf4b707d691fcb55bd290be5969f0acd3c39363 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 11:51:06 -0300 Subject: [PATCH 03/14] green stuff --- api/environments/onboarding/__init__.py | 0 api/environments/onboarding/serializers.py | 16 +++++++++ api/environments/onboarding/tasks.py | 21 +++++++++++ api/environments/onboarding/urls.py | 13 +++++++ api/environments/onboarding/views.py | 42 ++++++++++++++++++++++ api/environments/urls.py | 4 +++ 6 files changed, 96 insertions(+) create mode 100644 api/environments/onboarding/__init__.py create mode 100644 api/environments/onboarding/serializers.py create mode 100644 api/environments/onboarding/tasks.py create mode 100644 api/environments/onboarding/urls.py create mode 100644 api/environments/onboarding/views.py diff --git a/api/environments/onboarding/__init__.py b/api/environments/onboarding/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/environments/onboarding/serializers.py b/api/environments/onboarding/serializers.py new file mode 100644 index 000000000000..eb9a3ca129e7 --- /dev/null +++ b/api/environments/onboarding/serializers.py @@ -0,0 +1,16 @@ +from typing import get_args + +from rest_framework import serializers + +from app_analytics.types import KnownSDK +from environments.models import Environment + + +class EnvironmentOnboardingStatusSerializer(serializers.ModelSerializer[Environment]): + class Meta: + model = Environment + fields = ("first_evaluated_at", "first_evaluated_sdk_label") + + +class EnvironmentOnboardingStatusUpdateSerializer(serializers.Serializer[None]): + first_evaluated_sdk_label = serializers.ChoiceField(choices=get_args(KnownSDK)) diff --git a/api/environments/onboarding/tasks.py b/api/environments/onboarding/tasks.py new file mode 100644 index 000000000000..11486272fbbc --- /dev/null +++ b/api/environments/onboarding/tasks.py @@ -0,0 +1,21 @@ +from django.utils import timezone +from task_processor.decorators import register_task_handler +from task_processor.models import TaskPriority + +from app_analytics.types import KnownSDK +from environments.models import Environment + + +@register_task_handler(priority=TaskPriority.HIGH) +def record_environment_first_evaluation( + api_key: str, + sdk_label: KnownSDK, +) -> None: + """Mark this environment as having been evaluated by a client SDK.""" + Environment.objects.filter( + api_key=api_key, + first_evaluated_at__isnull=True, + ).update( + first_evaluated_at=timezone.now(), + first_evaluated_sdk_label=sdk_label, + ) diff --git a/api/environments/onboarding/urls.py b/api/environments/onboarding/urls.py new file mode 100644 index 000000000000..5ea62892eb75 --- /dev/null +++ b/api/environments/onboarding/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from environments.onboarding.views import EnvironmentOnboardingStatusAPIView + +app_name = "onboarding" + +urlpatterns = [ + path( + "", + EnvironmentOnboardingStatusAPIView.as_view(), + name="onboarding-status", + ), +] diff --git a/api/environments/onboarding/views.py b/api/environments/onboarding/views.py new file mode 100644 index 000000000000..22233034d8d4 --- /dev/null +++ b/api/environments/onboarding/views.py @@ -0,0 +1,42 @@ +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from environments.models import Environment +from environments.onboarding.serializers import ( + EnvironmentOnboardingStatusSerializer, + EnvironmentOnboardingStatusUpdateSerializer, +) +from environments.onboarding.tasks import record_environment_first_evaluation + + +class EnvironmentOnboardingStatusAPIView(APIView): + authentication_classes = () + permission_classes = () + throttle_classes = [] + + def get(self, _: Request, environment_api_key: str) -> Response: + """Obtain information on whether features for this environment have been evaluated yet.""" + environment = ( + Environment.objects.select_related(None) + .only( + "first_evaluated_at", + "first_evaluated_sdk_label", + ) + .get(api_key=environment_api_key) + ) + serializer = EnvironmentOnboardingStatusSerializer(environment) + return Response(serializer.data) + + def put(self, request: Request, environment_api_key: str) -> Response: + """Mark this environment as having been evaluated by a client SDK.""" + serializer = EnvironmentOnboardingStatusUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + record_environment_first_evaluation.delay( + args=( + environment_api_key, + serializer.validated_data["first_evaluated_sdk_label"], + ), + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/api/environments/urls.py b/api/environments/urls.py index be71c2179840..e0f66d90052b 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -185,4 +185,8 @@ "/experiment-metrics/", include("experimentation.metric_urls"), ), + path( + "/onboarding-status/", + include("environments.onboarding.urls"), + ), ] From 3eb3773def87155c059dfd98c65d812e0035d062 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 15:40:19 -0300 Subject: [PATCH 04/14] =?UTF-8?q?go=20international=20=F0=9F=8C=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/environments/models.py | 21 ++++-- api/environments/onboarding/tasks.py | 5 +- api/tests/unit/conftest.py | 6 +- .../test_unit_environments_views.py | 45 ++++++++++++ .../test_unit_environments_models.py | 71 ++++++++++--------- .../mappers/test_unit_mappers_dynamodb.py | 18 +++++ .../util/mappers/test_unit_mappers_engine.py | 1 + api/util/engine_models/environments/models.py | 1 + api/util/mappers/engine.py | 1 + api/util/mappers/sdk.py | 37 ++++------ 10 files changed, 140 insertions(+), 66 deletions(-) diff --git a/api/environments/models.py b/api/environments/models.py index 9ba7b123ab59..9e3d1c892025 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -317,15 +317,24 @@ def get_from_cache(cls, api_key: str | None) -> "Environment | None": return environment @classmethod - def write_environment_documents( + def write_environment_documents( # noqa: C901 cls, - environment_id: int = None, # type: ignore[assignment] - project_id: int = None, # type: ignore[assignment] + environment_id: int | None = None, + project_id: int | None = None, + api_key: str | None = None, ) -> None: + if environment_id: + environments_filter = Q(id=environment_id) + elif project_id: + environments_filter = Q(project_id=project_id) + elif api_key: + environments_filter = Q(api_key=api_key) + else: + raise TypeError( + "One of environment_id, project_id, api_key must be provided" + ) + # use a list to make sure the entire qs is evaluated up front - environments_filter = ( - Q(id=environment_id) if environment_id else Q(project_id=project_id) - ) environments = list( cls.objects.filter_for_document_builder( environments_filter, diff --git a/api/environments/onboarding/tasks.py b/api/environments/onboarding/tasks.py index 11486272fbbc..99a244268633 100644 --- a/api/environments/onboarding/tasks.py +++ b/api/environments/onboarding/tasks.py @@ -12,10 +12,13 @@ def record_environment_first_evaluation( sdk_label: KnownSDK, ) -> None: """Mark this environment as having been evaluated by a client SDK.""" - Environment.objects.filter( + updated = Environment.objects.filter( api_key=api_key, first_evaluated_at__isnull=True, ).update( first_evaluated_at=timezone.now(), first_evaluated_sdk_label=sdk_label, ) + + if updated: + Environment.write_environment_documents(api_key=api_key) diff --git a/api/tests/unit/conftest.py b/api/tests/unit/conftest.py index fb8a870878c2..fe6bf12daf50 100644 --- a/api/tests/unit/conftest.py +++ b/api/tests/unit/conftest.py @@ -144,8 +144,10 @@ def realtime_enabled_project_environment_two(realtime_enabled_project): # type: @pytest.fixture() -def dynamo_enabled_project_environment_one(dynamo_enabled_project): # type: ignore[no-untyped-def] - return Environment.objects.create( +def dynamo_enabled_project_environment_one( + dynamo_enabled_project: Project, +) -> Environment: + return Environment.objects.create( # type: ignore[no-any-return] name="Env 1", project=dynamo_enabled_project, api_key="env-1-key" ) diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py index 978bb6a36f4c..df156f4627eb 100644 --- a/api/tests/unit/environments/onboarding/test_unit_environments_views.py +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import freezegun import pytest from django.utils import timezone @@ -20,6 +22,11 @@ def onboarded_environment(environment: Environment) -> Environment: return environment +@pytest.fixture(autouse=True) +def write_environment_documents(mocker: MockerFixture) -> Mock: + return mocker.patch.object(Environment, "write_environment_documents") + + def test_get_onboarding_status__never_evaluated__responds_200( api_client: APIClient, environment: Environment, @@ -134,6 +141,44 @@ def test_put_onboarding_status_sync__evaluated__does_not_update_environment( assert "python" in onboarded_environment.first_evaluated_sdk_label +def test_put_onboarding_status_sync__never_evaluated__writes_environment_document( + api_client: APIClient, + environment: Environment, + write_environment_documents: Mock, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + write_environment_documents.assert_called_once_with(api_key=environment.api_key) + + +def test_put_onboarding_status_sync__evaluated__skips_environment_document( + api_client: APIClient, + onboarded_environment: Environment, + write_environment_documents: Mock, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{onboarded_environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + write_environment_documents.assert_not_called() + + def test_put_onboarding_status__invalid_sdk_label__responds_400( api_client: APIClient, environment: Environment, diff --git a/api/tests/unit/environments/test_unit_environments_models.py b/api/tests/unit/environments/test_unit_environments_models.py index 5a1256b278b0..c42d6aceb559 100644 --- a/api/tests/unit/environments/test_unit_environments_models.py +++ b/api/tests/unit/environments/test_unit_environments_models.py @@ -461,11 +461,10 @@ def test_trait_persistence_allowed__parametrised_origins__returns_expected( # t assert result == expected_result -def test_write_environment_documents__single_environment__writes_correct_queryset( # type: ignore[no-untyped-def] - dynamo_enabled_project, - dynamo_enabled_project_environment_one, - mock_dynamo_env_wrapper, -): +def test_write_environment_documents__environment_id__writes_single_environment( + dynamo_enabled_project_environment_one: Environment, + mock_dynamo_env_wrapper: Mock, +) -> None: # Given mock_dynamo_env_wrapper.reset_mock() @@ -475,21 +474,18 @@ def test_write_environment_documents__single_environment__writes_correct_queryse ) # Then - args, kwargs = mock_dynamo_env_wrapper.write_environments.call_args + [(args, kwargs)] = mock_dynamo_env_wrapper.write_environments.call_args_list assert kwargs == {} - assert len(args) == 1 - assert_queryset_equal( - args[0], - Environment.objects.filter(id=dynamo_enabled_project_environment_one.id), - ) + (queryset,) = args + assert list(queryset) == [dynamo_enabled_project_environment_one] -def test_write_environment_documents__project_id__writes_all_project_environments( # type: ignore[no-untyped-def] - dynamo_enabled_project, - dynamo_enabled_project_environment_one, - dynamo_enabled_project_environment_two, - mock_dynamo_env_wrapper, -): +def test_write_environment_documents__project_id__writes_all_project_environments( + dynamo_enabled_project: Project, + dynamo_enabled_project_environment_one: Environment, + dynamo_enabled_project_environment_two: Environment, + mock_dynamo_env_wrapper: Mock, +) -> None: # Given mock_dynamo_env_wrapper.reset_mock() @@ -497,35 +493,42 @@ def test_write_environment_documents__project_id__writes_all_project_environment Environment.write_environment_documents(project_id=dynamo_enabled_project.id) # Then - args, kwargs = mock_dynamo_env_wrapper.write_environments.call_args + [(args, kwargs)] = mock_dynamo_env_wrapper.write_environments.call_args_list assert kwargs == {} - assert len(args) == 1 - assert_queryset_equal( - args[0], Environment.objects.filter(project=dynamo_enabled_project) - ) + (queryset,) = args + assert list(queryset) == [ + dynamo_enabled_project_environment_one, + dynamo_enabled_project_environment_two, + ] -def test_write_environment_documents__environment_id_provided__writes_single_environment( # type: ignore[no-untyped-def] - dynamo_enabled_project, - dynamo_enabled_project_environment_one, - mock_dynamo_env_wrapper, -): +def test_write_environment_documents__api_key__writes_single_environment( + dynamo_enabled_project_environment_one: Environment, + mock_dynamo_env_wrapper: Mock, +) -> None: # Given mock_dynamo_env_wrapper.reset_mock() # When Environment.write_environment_documents( - environment_id=dynamo_enabled_project_environment_one.id + api_key=dynamo_enabled_project_environment_one.api_key ) # Then - args, kwargs = mock_dynamo_env_wrapper.write_environments.call_args + [(args, kwargs)] = mock_dynamo_env_wrapper.write_environments.call_args_list assert kwargs == {} - assert len(args) == 1 - assert_queryset_equal( - args[0], - Environment.objects.filter(id=dynamo_enabled_project_environment_one.id), - ) + (queryset,) = args + assert list(queryset) == [dynamo_enabled_project_environment_one] + + +def test_write_environment_documents__no_filters__raises() -> None: + # Given / When + # Then + with pytest.raises( + TypeError, + match="One of environment_id, project_id, api_key must be provided", + ): + Environment.write_environment_documents() def test_write_environments_to_dynamodb__project_environments_v2_migrated__call_expected( diff --git a/api/tests/unit/util/mappers/test_unit_mappers_dynamodb.py b/api/tests/unit/util/mappers/test_unit_mappers_dynamodb.py index 31e8b13ed978..32b3f0de2d75 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_dynamodb.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_dynamodb.py @@ -4,6 +4,8 @@ from decimal import Decimal from typing import TYPE_CHECKING +from django.utils import timezone + from environments.dynamodb.constants import ( ENVIRONMENTS_V2_ENVIRONMENT_META_DOCUMENT_KEY, ) @@ -59,6 +61,7 @@ def test_map_environment_to_environment_document__valid_environment__returns_exp "id": Decimal(environment.pk), "mixpanel_config": None, "name": "Test Environment", + "onboarding_pending": True, "project": { "enable_realtime_updates": False, "hide_disabled_flags": False, @@ -83,6 +86,20 @@ def test_map_environment_to_environment_document__valid_environment__returns_exp } +def test_map_environment_to_environment_document__first_evaluated__onboarding_pending_false( + environment: "Environment", +) -> None: + # Given + environment.first_evaluated_at = timezone.now() + environment.save(update_fields=["first_evaluated_at"]) + + # When + result = dynamodb.map_environment_to_environment_document(environment) + + # Then + assert result["onboarding_pending"] is False + + def test_map_environment_api_key_to_environment_api_key_document__valid_key__returns_expected_document( environment_api_key: "EnvironmentAPIKey", ) -> None: @@ -179,6 +196,7 @@ def test_map_environment_to_environment_v2_document__valid_environment__returns_ "id": Decimal(environment.pk), "mixpanel_config": None, "name": "Test Environment", + "onboarding_pending": True, "project": { "enable_realtime_updates": False, "hide_disabled_flags": False, diff --git a/api/tests/unit/util/mappers/test_unit_mappers_engine.py b/api/tests/unit/util/mappers/test_unit_mappers_engine.py index 75f49a25d998..f941ad82abad 100644 --- a/api/tests/unit/util/mappers/test_unit_mappers_engine.py +++ b/api/tests/unit/util/mappers/test_unit_mappers_engine.py @@ -467,6 +467,7 @@ def test_map_environment_to_engine__multiple_segments_and_versions__returns_expe use_identity_overrides_in_local_eval=environment.use_identity_overrides_in_local_eval, hide_sensitive_data=environment.hide_sensitive_data, hide_disabled_flags=environment.hide_disabled_flags, + onboarding_pending=True, amplitude_config=None, dynatrace_config=None, heap_config=None, diff --git a/api/util/engine_models/environments/models.py b/api/util/engine_models/environments/models.py index 697e79077465..4198a1b3814c 100644 --- a/api/util/engine_models/environments/models.py +++ b/api/util/engine_models/environments/models.py @@ -39,6 +39,7 @@ class EnvironmentModel(BaseModel): hide_disabled_flags: typing.Optional[bool] = None use_identity_composite_key_for_hashing: bool = False use_identity_overrides_in_local_eval: bool = False + onboarding_pending: typing.Optional[bool] = None amplitude_config: typing.Optional[IntegrationModel] = None dynatrace_config: typing.Optional[IntegrationModel] = None diff --git a/api/util/mappers/engine.py b/api/util/mappers/engine.py index 75da44509d3d..45a3a0232ef0 100644 --- a/api/util/mappers/engine.py +++ b/api/util/mappers/engine.py @@ -358,6 +358,7 @@ def map_environment_to_engine( hide_sensitive_data=environment.hide_sensitive_data, hide_disabled_flags=environment.hide_disabled_flags, use_identity_overrides_in_local_eval=environment.use_identity_overrides_in_local_eval, + onboarding_pending=environment.first_evaluated_at is None, # # Relationships: project=project_model, diff --git a/api/util/mappers/sdk.py b/api/util/mappers/sdk.py index 24f32b356dfc..0d9e9865c603 100644 --- a/api/util/mappers/sdk.py +++ b/api/util/mappers/sdk.py @@ -13,40 +13,31 @@ SDKDocumentValue: TypeAlias = dict[str, "SDKDocumentValue"] | str | bool | None | float SDKDocument: TypeAlias = dict[str, SDKDocumentValue] -SDK_DOCUMENT_EXCLUDE = [ +SDK_DOCUMENT_EXCLUDE = { *IDENTITY_INTEGRATIONS_RELATION_NAMES, "dynatrace_config", -] + "onboarding_pending", +} def map_environment_to_sdk_document(environment: "Environment") -> SDKDocument: - """ - Map an `environments.models.Environment` instance to an SDK document - used by SDKs with local evaluation mode. + """Map an `Environment` to a document used by SDKs on local evaluation. - It's virtually the same data that gets indexed in DynamoDB, - except it presents identity overrides and omits integrations configurations. + It's virtually the same data that gets indexed in DynamoDB, except it + presents identity overrides and omits information irrelevant to SDKs. """ - # Read relationships. - identities_with_overrides = {} + engine_environment = map_environment_to_engine(environment, with_integrations=False) + if environment.use_identity_overrides_in_local_eval: + identities_with_overrides = {} for feature_state in environment.feature_states.all(): if (identity_id := feature_state.identity_id) and ( identity_id not in identities_with_overrides ): identities_with_overrides[identity_id] = feature_state.identity + engine_environment.identity_overrides = [ + map_identity_to_engine(identity, with_traits=False) + for identity in identities_with_overrides.values() + ] - # Get the engine data. - engine_environment = map_environment_to_engine(environment, with_integrations=False) - - # No reading from ORM past this point! - - # Prepare relationships. - engine_environment.identity_overrides = [ - map_identity_to_engine(identity, with_traits=False) - for identity in identities_with_overrides.values() - ] - - return engine_environment.model_dump( - exclude=SDK_DOCUMENT_EXCLUDE, # type: ignore[arg-type] - ) + return engine_environment.model_dump(exclude=SDK_DOCUMENT_EXCLUDE) From 7922b5f1a7002b13f7a26d96a450fe16c7a6d63f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 16:42:02 -0300 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=AA=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/environments/onboarding/tasks.py | 33 ++++++-- .../test_unit_environments_views.py | 78 +++++++++++++++++++ .../observability/_events-catalogue.md | 30 +++++++ 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/api/environments/onboarding/tasks.py b/api/environments/onboarding/tasks.py index 99a244268633..79aa406cc697 100644 --- a/api/environments/onboarding/tasks.py +++ b/api/environments/onboarding/tasks.py @@ -1,3 +1,4 @@ +import structlog from django.utils import timezone from task_processor.decorators import register_task_handler from task_processor.models import TaskPriority @@ -5,6 +6,8 @@ from app_analytics.types import KnownSDK from environments.models import Environment +logger = structlog.get_logger("onboarding") + @register_task_handler(priority=TaskPriority.HIGH) def record_environment_first_evaluation( @@ -12,13 +15,27 @@ def record_environment_first_evaluation( sdk_label: KnownSDK, ) -> None: """Mark this environment as having been evaluated by a client SDK.""" - updated = Environment.objects.filter( - api_key=api_key, - first_evaluated_at__isnull=True, - ).update( - first_evaluated_at=timezone.now(), - first_evaluated_sdk_label=sdk_label, + try: + environment = Environment.objects.select_related("project").get(api_key=api_key) + except Environment.DoesNotExist: + logger.warning("environment.not_found", api_key=api_key) + return + + log = logger.bind( + environment__id=environment.id, + project__id=environment.project_id, + organisation__id=environment.project.organisation_id, + sdk__label=sdk_label, ) - if updated: - Environment.write_environment_documents(api_key=api_key) + if environment.first_evaluated_at is not None: + log.info("environment.already_evaluated") + return + + environment.first_evaluated_at = timezone.now() + environment.first_evaluated_sdk_label = sdk_label + environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"]) + + Environment.write_environment_documents(api_key=api_key) + + log.info("environment.first_evaluated") diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py index df156f4627eb..52e7509d3ba5 100644 --- a/api/tests/unit/environments/onboarding/test_unit_environments_views.py +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -6,6 +6,7 @@ from pytest_django.fixtures import DjangoAssertNumQueries from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from pytest_mock import MockerFixture +from pytest_structlog import StructuredLogCapture from rest_framework.test import APIClient from environments.models import Environment @@ -179,6 +180,83 @@ def test_put_onboarding_status_sync__evaluated__skips_environment_document( write_environment_documents.assert_not_called() +def test_put_onboarding_status_sync__never_evaluated__logs_first_evaluation( + api_client: APIClient, + environment: Environment, + log: StructuredLogCapture, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + assert log.has( + "environment.first_evaluated", + level="info", + environment__id=environment.id, + project__id=environment.project_id, + organisation__id=environment.project.organisation_id, + sdk__label="flagsmith-python-sdk", + ) + + +def test_put_onboarding_status_sync__evaluated__logs_already_evaluated( + api_client: APIClient, + onboarded_environment: Environment, + log: StructuredLogCapture, +) -> None: + # Given / When + response = api_client.put( + f"/api/v1/environments/{onboarded_environment.api_key}/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-php-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + assert log.has( + "environment.already_evaluated", + level="info", + environment__id=onboarded_environment.id, + project__id=onboarded_environment.project_id, + organisation__id=onboarded_environment.project.organisation_id, + sdk__label="flagsmith-php-sdk", + ) + assert not log.has("environment.first_evaluated") + + +@pytest.mark.django_db +def test_put_onboarding_status_sync__unknown_environment__logs_not_found( + api_client: APIClient, + log: StructuredLogCapture, +) -> None: + # Given / When + response = api_client.put( + "/api/v1/environments/unknown-api-key/onboarding-status/", + data={ + "first_evaluated_sdk_label": "flagsmith-python-sdk", + }, + format="json", + ) + + # Then + assert response.status_code == 204 + assert log.has( + "environment.not_found", + level="warning", + api_key="unknown-api-key", + ) + assert not log.has("environment.first_evaluated") + + def test_put_onboarding_status__invalid_sdk_label__responds_400( api_client: APIClient, environment: Environment, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 76161e5b84f8..208747dfc3bc 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -385,6 +385,36 @@ Logged at `info` from: Attributes: - `organisation.id` +### `onboarding.environment.already_evaluated` + +Logged at `info` from: + - `api/environments/onboarding/tasks.py:32` + +Attributes: + - `environment.id` + - `organisation.id` + - `project.id` + - `sdk.label` + +### `onboarding.environment.first_evaluated` + +Logged at `info` from: + - `api/environments/onboarding/tasks.py:41` + +Attributes: + - `environment.id` + - `organisation.id` + - `project.id` + - `sdk.label` + +### `onboarding.environment.not_found` + +Logged at `warning` from: + - `api/environments/onboarding/tasks.py:21` + +Attributes: + - `api_key` + ### `platform_hub.no_analytics_database_configured` Logged at `warning` from: From 772ad1beaad077a741b6ecf9c7c9b51af17039db Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 16:47:00 -0300 Subject: [PATCH 06/14] nah --- api/environments/models.py | 21 ++---- api/environments/onboarding/tasks.py | 2 +- api/tests/unit/conftest.py | 6 +- .../test_unit_environments_views.py | 2 +- .../test_unit_environments_models.py | 71 +++++++++---------- 5 files changed, 44 insertions(+), 58 deletions(-) diff --git a/api/environments/models.py b/api/environments/models.py index 9e3d1c892025..9ba7b123ab59 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -317,24 +317,15 @@ def get_from_cache(cls, api_key: str | None) -> "Environment | None": return environment @classmethod - def write_environment_documents( # noqa: C901 + def write_environment_documents( cls, - environment_id: int | None = None, - project_id: int | None = None, - api_key: str | None = None, + environment_id: int = None, # type: ignore[assignment] + project_id: int = None, # type: ignore[assignment] ) -> None: - if environment_id: - environments_filter = Q(id=environment_id) - elif project_id: - environments_filter = Q(project_id=project_id) - elif api_key: - environments_filter = Q(api_key=api_key) - else: - raise TypeError( - "One of environment_id, project_id, api_key must be provided" - ) - # use a list to make sure the entire qs is evaluated up front + environments_filter = ( + Q(id=environment_id) if environment_id else Q(project_id=project_id) + ) environments = list( cls.objects.filter_for_document_builder( environments_filter, diff --git a/api/environments/onboarding/tasks.py b/api/environments/onboarding/tasks.py index 79aa406cc697..caab6f5a63fc 100644 --- a/api/environments/onboarding/tasks.py +++ b/api/environments/onboarding/tasks.py @@ -36,6 +36,6 @@ def record_environment_first_evaluation( environment.first_evaluated_sdk_label = sdk_label environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"]) - Environment.write_environment_documents(api_key=api_key) + Environment.write_environment_documents(environment_id=environment.id) log.info("environment.first_evaluated") diff --git a/api/tests/unit/conftest.py b/api/tests/unit/conftest.py index fe6bf12daf50..fb8a870878c2 100644 --- a/api/tests/unit/conftest.py +++ b/api/tests/unit/conftest.py @@ -144,10 +144,8 @@ def realtime_enabled_project_environment_two(realtime_enabled_project): # type: @pytest.fixture() -def dynamo_enabled_project_environment_one( - dynamo_enabled_project: Project, -) -> Environment: - return Environment.objects.create( # type: ignore[no-any-return] +def dynamo_enabled_project_environment_one(dynamo_enabled_project): # type: ignore[no-untyped-def] + return Environment.objects.create( name="Env 1", project=dynamo_enabled_project, api_key="env-1-key" ) diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py index 52e7509d3ba5..14f77faa7700 100644 --- a/api/tests/unit/environments/onboarding/test_unit_environments_views.py +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -158,7 +158,7 @@ def test_put_onboarding_status_sync__never_evaluated__writes_environment_documen # Then assert response.status_code == 204 - write_environment_documents.assert_called_once_with(api_key=environment.api_key) + write_environment_documents.assert_called_once_with(environment_id=environment.id) def test_put_onboarding_status_sync__evaluated__skips_environment_document( diff --git a/api/tests/unit/environments/test_unit_environments_models.py b/api/tests/unit/environments/test_unit_environments_models.py index c42d6aceb559..5a1256b278b0 100644 --- a/api/tests/unit/environments/test_unit_environments_models.py +++ b/api/tests/unit/environments/test_unit_environments_models.py @@ -461,10 +461,11 @@ def test_trait_persistence_allowed__parametrised_origins__returns_expected( # t assert result == expected_result -def test_write_environment_documents__environment_id__writes_single_environment( - dynamo_enabled_project_environment_one: Environment, - mock_dynamo_env_wrapper: Mock, -) -> None: +def test_write_environment_documents__single_environment__writes_correct_queryset( # type: ignore[no-untyped-def] + dynamo_enabled_project, + dynamo_enabled_project_environment_one, + mock_dynamo_env_wrapper, +): # Given mock_dynamo_env_wrapper.reset_mock() @@ -474,18 +475,21 @@ def test_write_environment_documents__environment_id__writes_single_environment( ) # Then - [(args, kwargs)] = mock_dynamo_env_wrapper.write_environments.call_args_list + args, kwargs = mock_dynamo_env_wrapper.write_environments.call_args assert kwargs == {} - (queryset,) = args - assert list(queryset) == [dynamo_enabled_project_environment_one] + assert len(args) == 1 + assert_queryset_equal( + args[0], + Environment.objects.filter(id=dynamo_enabled_project_environment_one.id), + ) -def test_write_environment_documents__project_id__writes_all_project_environments( - dynamo_enabled_project: Project, - dynamo_enabled_project_environment_one: Environment, - dynamo_enabled_project_environment_two: Environment, - mock_dynamo_env_wrapper: Mock, -) -> None: +def test_write_environment_documents__project_id__writes_all_project_environments( # type: ignore[no-untyped-def] + dynamo_enabled_project, + dynamo_enabled_project_environment_one, + dynamo_enabled_project_environment_two, + mock_dynamo_env_wrapper, +): # Given mock_dynamo_env_wrapper.reset_mock() @@ -493,42 +497,35 @@ def test_write_environment_documents__project_id__writes_all_project_environment Environment.write_environment_documents(project_id=dynamo_enabled_project.id) # Then - [(args, kwargs)] = mock_dynamo_env_wrapper.write_environments.call_args_list + args, kwargs = mock_dynamo_env_wrapper.write_environments.call_args assert kwargs == {} - (queryset,) = args - assert list(queryset) == [ - dynamo_enabled_project_environment_one, - dynamo_enabled_project_environment_two, - ] + assert len(args) == 1 + assert_queryset_equal( + args[0], Environment.objects.filter(project=dynamo_enabled_project) + ) -def test_write_environment_documents__api_key__writes_single_environment( - dynamo_enabled_project_environment_one: Environment, - mock_dynamo_env_wrapper: Mock, -) -> None: +def test_write_environment_documents__environment_id_provided__writes_single_environment( # type: ignore[no-untyped-def] + dynamo_enabled_project, + dynamo_enabled_project_environment_one, + mock_dynamo_env_wrapper, +): # Given mock_dynamo_env_wrapper.reset_mock() # When Environment.write_environment_documents( - api_key=dynamo_enabled_project_environment_one.api_key + environment_id=dynamo_enabled_project_environment_one.id ) # Then - [(args, kwargs)] = mock_dynamo_env_wrapper.write_environments.call_args_list + args, kwargs = mock_dynamo_env_wrapper.write_environments.call_args assert kwargs == {} - (queryset,) = args - assert list(queryset) == [dynamo_enabled_project_environment_one] - - -def test_write_environment_documents__no_filters__raises() -> None: - # Given / When - # Then - with pytest.raises( - TypeError, - match="One of environment_id, project_id, api_key must be provided", - ): - Environment.write_environment_documents() + assert len(args) == 1 + assert_queryset_equal( + args[0], + Environment.objects.filter(id=dynamo_enabled_project_environment_one.id), + ) def test_write_environments_to_dynamodb__project_environments_v2_migrated__call_expected( From 17a7f4dcf7e1c2e0d523c211bfff29fcdb55c870 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 17:50:28 -0300 Subject: [PATCH 07/14] yay anyway --- api/app_analytics/constants.py | 6 ++ api/app_analytics/mappers.py | 12 ++- api/features/views.py | 9 ++ .../unit/features/test_unit_features_views.py | 91 +++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/api/app_analytics/constants.py b/api/app_analytics/constants.py index 01e7dd35e825..670f37578680 100644 --- a/api/app_analytics/constants.py +++ b/api/app_analytics/constants.py @@ -142,6 +142,12 @@ "Flagsmith-SDK-User-Agent": "sdk_user_agent", "User-Agent": "user_agent", } + +SDK_USER_AGENT_HEADERS: list[str] = [ + "Flagsmith-SDK-User-Agent", # Exists because browsers own `User-Agent` + "User-Agent", +] + LABELS: tuple[Label, ...] = get_args(Label) NO_ANALYTICS_DATABASE_CONFIGURED_WARNING = ( diff --git a/api/app_analytics/mappers.py b/api/app_analytics/mappers.py index 2a6bcd3fc11c..998bba797bfd 100644 --- a/api/app_analytics/mappers.py +++ b/api/app_analytics/mappers.py @@ -8,6 +8,7 @@ from app_analytics.constants import ( LABELS, SDK_INFLUX_IDS_BY_USER_AGENT, + SDK_USER_AGENT_HEADERS, SDK_USER_AGENT_KNOWN_VERSIONS, SDK_USER_AGENTS_BY_INFLUX_ID, TRACK_HEADERS, @@ -36,10 +37,19 @@ def map_user_agent_to_sdk_user_agent(value: str) -> str | None: return None +def map_request_to_sdk_label(request: HttpRequest) -> KnownSDK | None: + for header in SDK_USER_AGENT_HEADERS: + if (value := request.headers.get(header)) and ( + sdk_user_agent := map_user_agent_to_sdk_user_agent(value) + ): + return cast(KnownSDK, sdk_user_agent.partition("/")[0]) + return None + + _request_header_labels_model_fields: dict[str, Any] = { str(label): ( Annotated[str | None, BeforeValidator(map_user_agent_to_sdk_user_agent)] - if label in ("user_agent", "sdk_user_agent") + if header in SDK_USER_AGENT_HEADERS else str | None, Field(default=None, alias=header), ) diff --git a/api/features/views.py b/api/features/views.py index 462053151125..3ae0e0ad3f67 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -42,6 +42,7 @@ from app.pagination import CustomPagination from app_analytics.analytics_db_service import get_feature_evaluation_data from app_analytics.influxdb_wrapper import get_multiple_event_list_for_feature +from app_analytics.mappers import map_request_to_sdk_label from app_analytics.throttles import InfluxQueryThrottle from core.constants import FLAGSMITH_UPDATED_AT_HEADER, SDK_ENVIRONMENT_KEY_HEADER from core.request_origin import RequestOrigin @@ -55,6 +56,7 @@ IdentitySourceIdentityRequestSerializer, ) from environments.models import Environment +from environments.onboarding.tasks import record_environment_first_evaluation from environments.permissions.permissions import ( EnvironmentKeyPermissions, NestedEnvironmentPermissions, @@ -1007,6 +1009,13 @@ def get(self, request, identifier=None, *args, **kwargs): # type: ignore[no-unt *Note*: using this endpoint with an identifier is deprecated. Please use `/api/v1/identities/?identifier=` instead. """ + if request.environment.first_evaluated_at is None and ( + sdk_label := map_request_to_sdk_label(request) + ): + record_environment_first_evaluation.delay( + args=(request.environment.api_key, sdk_label), + ) + if identifier: return self._get_flags_response_with_identifier(request, identifier) diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index c6f42c9352b2..9b976f2a3a88 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -1415,6 +1415,97 @@ def test_get_flags__user_throttle_set__is_not_throttled( # type: ignore[no-unty assert response.status_code == status.HTTP_200_OK +@pytest.mark.parametrize( + "headers,expected_sdk_label", + [ + ( + {"Flagsmith-SDK-User-Agent": "flagsmith-js-sdk/9.3.1"}, + "flagsmith-js-sdk", + ), + ( + {"User-Agent": "flagsmith-python-sdk/6.0.0"}, + "flagsmith-python-sdk", + ), + ( + { + "Flagsmith-SDK-User-Agent": "flagsmith-js-sdk/9.3.1", + "User-Agent": "flagsmith-python-sdk/6.0.0", + }, + "flagsmith-js-sdk", + ), + ], +) +def test_get_flags__environment_never_evaluated__records_first_evaluation( + api_client: APIClient, + environment: Environment, + mocker: MockerFixture, + headers: dict[str, str], + expected_sdk_label: str, +) -> None: + # Given + task = mocker.patch.object(views, "record_environment_first_evaluation") + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + + # When + response = api_client.get("/api/v1/flags/", headers=headers) + + # Then + assert response.status_code == 200 + task.delay.assert_called_once_with( + args=(environment.api_key, expected_sdk_label), + ) + + +@pytest.mark.parametrize( + "headers", + [ + {}, + { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15" + }, + ], +) +def test_get_flags__sdk_not_identified__does_not_record_first_evaluation( + api_client: APIClient, + environment: Environment, + mocker: MockerFixture, + headers: dict[str, str], +) -> None: + # Given + task = mocker.patch.object(views, "record_environment_first_evaluation") + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + + # When + response = api_client.get("/api/v1/flags/", headers=headers) + + # Then + assert response.status_code == 200 + task.delay.assert_not_called() + + +def test_get_flags__environment_already_evaluated__does_not_record_first_evaluation( + api_client: APIClient, + environment: Environment, + mocker: MockerFixture, +) -> None: + # Given + task = mocker.patch.object(views, "record_environment_first_evaluation") + environment.first_evaluated_at = timezone.now() + environment.first_evaluated_sdk_label = "flagsmith-js-sdk" + environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"]) + api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) + + # When + response = api_client.get( + "/api/v1/flags/", + headers={"Flagsmith-SDK-User-Agent": "flagsmith-js-sdk/9.3.1"}, + ) + + # Then + assert response.status_code == 200 + task.delay.assert_not_called() + + def test_list_feature_states__simple_view_set__returns_expected_count( environment: Environment, feature: Feature, From 797590a7da28c7c7457bb63914bcb1c18187dd6f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 18:52:05 -0300 Subject: [PATCH 08/14] 404 --- api/environments/onboarding/views.py | 10 +++++----- .../onboarding/test_unit_environments_views.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/api/environments/onboarding/views.py b/api/environments/onboarding/views.py index 22233034d8d4..fed0886be572 100644 --- a/api/environments/onboarding/views.py +++ b/api/environments/onboarding/views.py @@ -1,3 +1,4 @@ +from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -18,13 +19,12 @@ class EnvironmentOnboardingStatusAPIView(APIView): def get(self, _: Request, environment_api_key: str) -> Response: """Obtain information on whether features for this environment have been evaluated yet.""" - environment = ( - Environment.objects.select_related(None) - .only( + environment = get_object_or_404( + Environment.objects.select_related(None).only( "first_evaluated_at", "first_evaluated_sdk_label", - ) - .get(api_key=environment_api_key) + ), + api_key=environment_api_key, ) serializer = EnvironmentOnboardingStatusSerializer(environment) return Response(serializer.data) diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py index 14f77faa7700..a9ff9c5c6839 100644 --- a/api/tests/unit/environments/onboarding/test_unit_environments_views.py +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -62,6 +62,17 @@ def test_get_onboarding_status__evaluated__responds_200( } +@pytest.mark.django_db +def test_get_onboarding_status__unknown_environment__responds_404( + api_client: APIClient, +) -> None: + # Given / When + response = api_client.get("/api/v1/environments/unknown-api-key/onboarding-status/") + + # Then + assert response.status_code == 404 + + @pytest.mark.parametrize( "either_environment", (lazy_fixture("environment"), lazy_fixture("onboarded_environment")), From edad9abc5a0a0486f9a03655ae9b242c04c1ac78 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 19:06:57 -0300 Subject: [PATCH 09/14] clone but offboard it --- api/environments/models.py | 4 +++- .../test_unit_environments_models.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/api/environments/models.py b/api/environments/models.py index 9ba7b123ab59..42c55e15fa28 100644 --- a/api/environments/models.py +++ b/api/environments/models.py @@ -168,7 +168,7 @@ class Environment( help_text="When the environment's flags were first evaluated by an SDK.", ) - first_evaluated_sdk_label = models.CharField[KnownSDK, KnownSDK]( + first_evaluated_sdk_label = models.CharField[KnownSDK | None, KnownSDK | None]( null=True, blank=True, max_length=100, @@ -255,6 +255,8 @@ def clone( clone.name = name clone.api_key = api_key if api_key else create_hash() clone.is_creating = True + clone.first_evaluated_at = None + clone.first_evaluated_sdk_label = None clone.save() from environments.tasks import clone_environment_feature_states diff --git a/api/tests/unit/environments/test_unit_environments_models.py b/api/tests/unit/environments/test_unit_environments_models.py index 5a1256b278b0..cbba88ab635e 100644 --- a/api/tests/unit/environments/test_unit_environments_models.py +++ b/api/tests/unit/environments/test_unit_environments_models.py @@ -97,6 +97,22 @@ def test_environment_clone__default__does_not_modify_original_instance( assert clone.api_key != original_api_key +def test_environment_clone__evaluated_source__resets_first_evaluation_fields( + environment: Environment, +) -> None: + # Given + environment.first_evaluated_at = timezone.now() + environment.first_evaluated_sdk_label = "flagsmith-python-sdk" + environment.save() + + # When + clone = environment.clone(name="Cloned env") + + # Then + assert clone.first_evaluated_at is None + assert clone.first_evaluated_sdk_label is None + + def test_environment_clone__with_feature__creates_feature_states( # type: ignore[no-untyped-def] environment: Environment, feature: Feature ): From 01064ba077834fc8d1101012f0aaffe5177899a2 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 21 Jul 2026 19:18:56 -0300 Subject: [PATCH 10/14] fix api spec --- api/environments/onboarding/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/environments/onboarding/views.py b/api/environments/onboarding/views.py index fed0886be572..7bef533c24fb 100644 --- a/api/environments/onboarding/views.py +++ b/api/environments/onboarding/views.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -17,6 +18,7 @@ class EnvironmentOnboardingStatusAPIView(APIView): permission_classes = () throttle_classes = [] + @extend_schema(responses={200: EnvironmentOnboardingStatusSerializer}) def get(self, _: Request, environment_api_key: str) -> Response: """Obtain information on whether features for this environment have been evaluated yet.""" environment = get_object_or_404( @@ -29,6 +31,10 @@ def get(self, _: Request, environment_api_key: str) -> Response: serializer = EnvironmentOnboardingStatusSerializer(environment) return Response(serializer.data) + @extend_schema( + request=EnvironmentOnboardingStatusUpdateSerializer, + responses={204: None}, + ) def put(self, request: Request, environment_api_key: str) -> Response: """Mark this environment as having been evaluated by a client SDK.""" serializer = EnvironmentOnboardingStatusUpdateSerializer(data=request.data) From 9f6b6beb4b3bfd0efc0134be9ad8140d79a6a6cf Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 22 Jul 2026 17:00:38 -0300 Subject: [PATCH 11/14] wait it --- .../onboarding/{tasks.py => services.py} | 11 +--- api/environments/onboarding/views.py | 33 ++++------- api/features/views.py | 6 +- .../test_unit_environments_views.py | 57 +++---------------- .../unit/features/test_unit_features_views.py | 20 ++++--- .../observability/_events-catalogue.md | 12 +--- 6 files changed, 38 insertions(+), 101 deletions(-) rename api/environments/onboarding/{tasks.py => services.py} (71%) diff --git a/api/environments/onboarding/tasks.py b/api/environments/onboarding/services.py similarity index 71% rename from api/environments/onboarding/tasks.py rename to api/environments/onboarding/services.py index caab6f5a63fc..83b639b88aca 100644 --- a/api/environments/onboarding/tasks.py +++ b/api/environments/onboarding/services.py @@ -1,7 +1,5 @@ import structlog from django.utils import timezone -from task_processor.decorators import register_task_handler -from task_processor.models import TaskPriority from app_analytics.types import KnownSDK from environments.models import Environment @@ -9,18 +7,11 @@ logger = structlog.get_logger("onboarding") -@register_task_handler(priority=TaskPriority.HIGH) def record_environment_first_evaluation( - api_key: str, + environment: Environment, sdk_label: KnownSDK, ) -> None: """Mark this environment as having been evaluated by a client SDK.""" - try: - environment = Environment.objects.select_related("project").get(api_key=api_key) - except Environment.DoesNotExist: - logger.warning("environment.not_found", api_key=api_key) - return - log = logger.bind( environment__id=environment.id, project__id=environment.project_id, diff --git a/api/environments/onboarding/views.py b/api/environments/onboarding/views.py index 7bef533c24fb..815e832694c1 100644 --- a/api/environments/onboarding/views.py +++ b/api/environments/onboarding/views.py @@ -1,35 +1,28 @@ -from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema from rest_framework import status +from rest_framework.generics import RetrieveAPIView from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.views import APIView from environments.models import Environment from environments.onboarding.serializers import ( EnvironmentOnboardingStatusSerializer, EnvironmentOnboardingStatusUpdateSerializer, ) -from environments.onboarding.tasks import record_environment_first_evaluation +from environments.onboarding.services import record_environment_first_evaluation -class EnvironmentOnboardingStatusAPIView(APIView): +class EnvironmentOnboardingStatusAPIView(RetrieveAPIView[Environment]): + """Obtain information on whether features for this environment have been evaluated yet.""" + authentication_classes = () permission_classes = () throttle_classes = [] - @extend_schema(responses={200: EnvironmentOnboardingStatusSerializer}) - def get(self, _: Request, environment_api_key: str) -> Response: - """Obtain information on whether features for this environment have been evaluated yet.""" - environment = get_object_or_404( - Environment.objects.select_related(None).only( - "first_evaluated_at", - "first_evaluated_sdk_label", - ), - api_key=environment_api_key, - ) - serializer = EnvironmentOnboardingStatusSerializer(environment) - return Response(serializer.data) + queryset = Environment.objects.select_related("project") + lookup_field = "api_key" + lookup_url_kwarg = "environment_api_key" + serializer_class = EnvironmentOnboardingStatusSerializer @extend_schema( request=EnvironmentOnboardingStatusUpdateSerializer, @@ -39,10 +32,8 @@ def put(self, request: Request, environment_api_key: str) -> Response: """Mark this environment as having been evaluated by a client SDK.""" serializer = EnvironmentOnboardingStatusUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) - record_environment_first_evaluation.delay( - args=( - environment_api_key, - serializer.validated_data["first_evaluated_sdk_label"], - ), + record_environment_first_evaluation( + environment=self.get_object(), + sdk_label=serializer.validated_data["first_evaluated_sdk_label"], ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/api/features/views.py b/api/features/views.py index 3ae0e0ad3f67..42a28d858f37 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -56,7 +56,7 @@ IdentitySourceIdentityRequestSerializer, ) from environments.models import Environment -from environments.onboarding.tasks import record_environment_first_evaluation +from environments.onboarding.services import record_environment_first_evaluation from environments.permissions.permissions import ( EnvironmentKeyPermissions, NestedEnvironmentPermissions, @@ -1012,9 +1012,7 @@ def get(self, request, identifier=None, *args, **kwargs): # type: ignore[no-unt if request.environment.first_evaluated_at is None and ( sdk_label := map_request_to_sdk_label(request) ): - record_environment_first_evaluation.delay( - args=(request.environment.api_key, sdk_label), - ) + record_environment_first_evaluation(request.environment, sdk_label) if identifier: return self._get_flags_response_with_identifier(request, identifier) diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py index a9ff9c5c6839..fc7b6932d6a5 100644 --- a/api/tests/unit/environments/onboarding/test_unit_environments_views.py +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -3,14 +3,11 @@ import freezegun import pytest from django.utils import timezone -from pytest_django.fixtures import DjangoAssertNumQueries -from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] from pytest_mock import MockerFixture from pytest_structlog import StructuredLogCapture from rest_framework.test import APIClient from environments.models import Environment -from environments.onboarding import views pytestmark = pytest.mark.freeze_time("2077-07-07T07:07:07Z") @@ -73,40 +70,9 @@ def test_get_onboarding_status__unknown_environment__responds_404( assert response.status_code == 404 -@pytest.mark.parametrize( - "either_environment", - (lazy_fixture("environment"), lazy_fixture("onboarded_environment")), -) -def test_put_onboarding_status_async__regardless_evaluated__enqueues_and_responds_204( - api_client: APIClient, - django_assert_num_queries: DjangoAssertNumQueries, - either_environment: Environment, - mocker: MockerFixture, -) -> None: - # Given - task = mocker.patch.object(views, "record_environment_first_evaluation") - - # When - with django_assert_num_queries(0): - response = api_client.put( - f"/api/v1/environments/{either_environment.api_key}/onboarding-status/", - data={ - "first_evaluated_sdk_label": "flagsmith-python-sdk", - }, - format="json", - ) - - # Then - assert response.status_code == 204 - task.delay.assert_called_once_with( - args=(either_environment.api_key, "flagsmith-python-sdk"), - ) - - -def test_put_onboarding_status_sync__never_evaluated__updates_environment( +def test_put_onboarding_status__never_evaluated__updates_environment( api_client: APIClient, environment: Environment, - mocker: MockerFixture, ) -> None: # Given assert environment.first_evaluated_at is None @@ -128,7 +94,7 @@ def test_put_onboarding_status_sync__never_evaluated__updates_environment( assert environment.first_evaluated_sdk_label == "flagsmith-python-sdk" -def test_put_onboarding_status_sync__evaluated__does_not_update_environment( +def test_put_onboarding_status__evaluated__does_not_update_environment( api_client: APIClient, onboarded_environment: Environment, ) -> None: @@ -153,7 +119,7 @@ def test_put_onboarding_status_sync__evaluated__does_not_update_environment( assert "python" in onboarded_environment.first_evaluated_sdk_label -def test_put_onboarding_status_sync__never_evaluated__writes_environment_document( +def test_put_onboarding_status__never_evaluated__writes_environment_document( api_client: APIClient, environment: Environment, write_environment_documents: Mock, @@ -172,7 +138,7 @@ def test_put_onboarding_status_sync__never_evaluated__writes_environment_documen write_environment_documents.assert_called_once_with(environment_id=environment.id) -def test_put_onboarding_status_sync__evaluated__skips_environment_document( +def test_put_onboarding_status__evaluated__skips_environment_document( api_client: APIClient, onboarded_environment: Environment, write_environment_documents: Mock, @@ -191,7 +157,7 @@ def test_put_onboarding_status_sync__evaluated__skips_environment_document( write_environment_documents.assert_not_called() -def test_put_onboarding_status_sync__never_evaluated__logs_first_evaluation( +def test_put_onboarding_status__never_evaluated__logs_first_evaluation( api_client: APIClient, environment: Environment, log: StructuredLogCapture, @@ -217,7 +183,7 @@ def test_put_onboarding_status_sync__never_evaluated__logs_first_evaluation( ) -def test_put_onboarding_status_sync__evaluated__logs_already_evaluated( +def test_put_onboarding_status__evaluated__logs_already_evaluated( api_client: APIClient, onboarded_environment: Environment, log: StructuredLogCapture, @@ -245,9 +211,8 @@ def test_put_onboarding_status_sync__evaluated__logs_already_evaluated( @pytest.mark.django_db -def test_put_onboarding_status_sync__unknown_environment__logs_not_found( +def test_put_onboarding_status__unknown_environment__responds_404( api_client: APIClient, - log: StructuredLogCapture, ) -> None: # Given / When response = api_client.put( @@ -259,13 +224,7 @@ def test_put_onboarding_status_sync__unknown_environment__logs_not_found( ) # Then - assert response.status_code == 204 - assert log.has( - "environment.not_found", - level="warning", - api_key="unknown-api-key", - ) - assert not log.has("environment.first_evaluated") + assert response.status_code == 404 def test_put_onboarding_status__invalid_sdk_label__responds_400( diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 9b976f2a3a88..5f0af1d548fc 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -1443,7 +1443,9 @@ def test_get_flags__environment_never_evaluated__records_first_evaluation( expected_sdk_label: str, ) -> None: # Given - task = mocker.patch.object(views, "record_environment_first_evaluation") + record_environment_first_evaluation = mocker.patch.object( + views, "record_environment_first_evaluation" + ) api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) # When @@ -1451,8 +1453,8 @@ def test_get_flags__environment_never_evaluated__records_first_evaluation( # Then assert response.status_code == 200 - task.delay.assert_called_once_with( - args=(environment.api_key, expected_sdk_label), + record_environment_first_evaluation.assert_called_once_with( + environment, expected_sdk_label ) @@ -1472,7 +1474,9 @@ def test_get_flags__sdk_not_identified__does_not_record_first_evaluation( headers: dict[str, str], ) -> None: # Given - task = mocker.patch.object(views, "record_environment_first_evaluation") + record_environment_first_evaluation = mocker.patch.object( + views, "record_environment_first_evaluation" + ) api_client.credentials(HTTP_X_ENVIRONMENT_KEY=environment.api_key) # When @@ -1480,7 +1484,7 @@ def test_get_flags__sdk_not_identified__does_not_record_first_evaluation( # Then assert response.status_code == 200 - task.delay.assert_not_called() + record_environment_first_evaluation.assert_not_called() def test_get_flags__environment_already_evaluated__does_not_record_first_evaluation( @@ -1489,7 +1493,9 @@ def test_get_flags__environment_already_evaluated__does_not_record_first_evaluat mocker: MockerFixture, ) -> None: # Given - task = mocker.patch.object(views, "record_environment_first_evaluation") + record_environment_first_evaluation = mocker.patch.object( + views, "record_environment_first_evaluation" + ) environment.first_evaluated_at = timezone.now() environment.first_evaluated_sdk_label = "flagsmith-js-sdk" environment.save(update_fields=["first_evaluated_at", "first_evaluated_sdk_label"]) @@ -1503,7 +1509,7 @@ def test_get_flags__environment_already_evaluated__does_not_record_first_evaluat # Then assert response.status_code == 200 - task.delay.assert_not_called() + record_environment_first_evaluation.assert_not_called() def test_list_feature_states__simple_view_set__returns_expected_count( diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 208747dfc3bc..121b409c4f34 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -388,7 +388,7 @@ Attributes: ### `onboarding.environment.already_evaluated` Logged at `info` from: - - `api/environments/onboarding/tasks.py:32` + - `api/environments/onboarding/services.py:23` Attributes: - `environment.id` @@ -399,7 +399,7 @@ Attributes: ### `onboarding.environment.first_evaluated` Logged at `info` from: - - `api/environments/onboarding/tasks.py:41` + - `api/environments/onboarding/services.py:32` Attributes: - `environment.id` @@ -407,14 +407,6 @@ Attributes: - `project.id` - `sdk.label` -### `onboarding.environment.not_found` - -Logged at `warning` from: - - `api/environments/onboarding/tasks.py:21` - -Attributes: - - `api_key` - ### `platform_hub.no_analytics_database_configured` Logged at `warning` from: From 1fedc196a94e8333d369e1a4552b295a805c219d Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 22 Jul 2026 17:20:33 -0300 Subject: [PATCH 12/14] smithers-only --- api/environments/onboarding/urls.py | 17 ++++++----- api/tests/conftest.py | 23 +++++++++++++++ api/tests/test_helpers.py | 23 +++++++++++++++ .../test_unit_environments_views.py | 24 +++++++++++++++ api/tests/unit/onboarding/conftest.py | 29 ------------------- api/tests/unit/onboarding/test_views.py | 17 ++++++----- 6 files changed, 89 insertions(+), 44 deletions(-) delete mode 100644 api/tests/unit/onboarding/conftest.py diff --git a/api/environments/onboarding/urls.py b/api/environments/onboarding/urls.py index 5ea62892eb75..c882bc964098 100644 --- a/api/environments/onboarding/urls.py +++ b/api/environments/onboarding/urls.py @@ -1,13 +1,16 @@ +from common.core.utils import is_saas from django.urls import path from environments.onboarding.views import EnvironmentOnboardingStatusAPIView app_name = "onboarding" -urlpatterns = [ - path( - "", - EnvironmentOnboardingStatusAPIView.as_view(), - name="onboarding-status", - ), -] +urlpatterns = [] +if is_saas(): + urlpatterns.append( + path( + "", + EnvironmentOnboardingStatusAPIView.as_view(), + name="onboarding-status", + ) + ) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index f03355c7feb9..34dbff6db678 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -113,6 +113,7 @@ ) from projects.tags.models import Tag from segments.models import Condition, Segment, SegmentRule +from tests.test_helpers import reload_urlconf from tests.types import ( AdminClientAuthType, EnableFeaturesFixture, @@ -167,6 +168,28 @@ def pytest_configure(config: pytest.Config) -> None: # --------------------------------------------------------------------------- +@pytest.fixture() +def is_oss(mocker: MockerFixture) -> typing.Generator[None, None, None]: + mocked_is_oss = mocker.patch("common.core.utils.is_oss", return_value=True) + reload_urlconf() + + yield + + mocker.stop(mocked_is_oss) + reload_urlconf() + + +@pytest.fixture() +def is_saas(mocker: MockerFixture) -> typing.Generator[None, None, None]: + mocked_is_saas = mocker.patch("common.core.utils.is_saas", return_value=True) + reload_urlconf() + + yield + + mocker.stop(mocked_is_saas) + reload_urlconf() + + @pytest.fixture() def post_request_mock(mocker: MockerFixture) -> MagicMock: def mocked_request(*args, **kwargs) -> None: # type: ignore[no-untyped-def] diff --git a/api/tests/test_helpers.py b/api/tests/test_helpers.py index 7d9b213faa54..8cd50842e77e 100644 --- a/api/tests/test_helpers.py +++ b/api/tests/test_helpers.py @@ -1,8 +1,31 @@ +from importlib import reload +from types import ModuleType from typing import Any +from django.urls import URLResolver, clear_url_caches, get_resolver from flag_engine.segments.types import ConditionOperator +def reload_urlconf() -> None: + """Re-import every URL module, children first, so that routes gated on + deployment flags at import time are re-evaluated.""" + reloaded: set[str] = set() + + def reload_resolver_modules(resolver: URLResolver) -> None: + for url_pattern in resolver.url_patterns: + if isinstance(url_pattern, URLResolver): + reload_resolver_modules(url_pattern) + if ( + isinstance(module := resolver.urlconf_module, ModuleType) + and module.__name__ not in reloaded + ): + reloaded.add(module.__name__) + reload(module) + + reload_resolver_modules(get_resolver()) + clear_url_caches() + + def generate_segment_data( segment_name: str, project_id: int, diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py index fc7b6932d6a5..011eb863513c 100644 --- a/api/tests/unit/environments/onboarding/test_unit_environments_views.py +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -25,6 +25,7 @@ def write_environment_documents(mocker: MockerFixture) -> Mock: return mocker.patch.object(Environment, "write_environment_documents") +@pytest.mark.usefixtures("is_saas") def test_get_onboarding_status__never_evaluated__responds_200( api_client: APIClient, environment: Environment, @@ -42,6 +43,7 @@ def test_get_onboarding_status__never_evaluated__responds_200( } +@pytest.mark.usefixtures("is_saas") def test_get_onboarding_status__evaluated__responds_200( api_client: APIClient, onboarded_environment: Environment, @@ -60,6 +62,7 @@ def test_get_onboarding_status__evaluated__responds_200( @pytest.mark.django_db +@pytest.mark.usefixtures("is_saas") def test_get_onboarding_status__unknown_environment__responds_404( api_client: APIClient, ) -> None: @@ -70,6 +73,7 @@ def test_get_onboarding_status__unknown_environment__responds_404( assert response.status_code == 404 +@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__never_evaluated__updates_environment( api_client: APIClient, environment: Environment, @@ -94,6 +98,7 @@ def test_put_onboarding_status__never_evaluated__updates_environment( assert environment.first_evaluated_sdk_label == "flagsmith-python-sdk" +@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__evaluated__does_not_update_environment( api_client: APIClient, onboarded_environment: Environment, @@ -119,6 +124,7 @@ def test_put_onboarding_status__evaluated__does_not_update_environment( assert "python" in onboarded_environment.first_evaluated_sdk_label +@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__never_evaluated__writes_environment_document( api_client: APIClient, environment: Environment, @@ -138,6 +144,7 @@ def test_put_onboarding_status__never_evaluated__writes_environment_document( write_environment_documents.assert_called_once_with(environment_id=environment.id) +@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__evaluated__skips_environment_document( api_client: APIClient, onboarded_environment: Environment, @@ -157,6 +164,7 @@ def test_put_onboarding_status__evaluated__skips_environment_document( write_environment_documents.assert_not_called() +@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__never_evaluated__logs_first_evaluation( api_client: APIClient, environment: Environment, @@ -183,6 +191,7 @@ def test_put_onboarding_status__never_evaluated__logs_first_evaluation( ) +@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__evaluated__logs_already_evaluated( api_client: APIClient, onboarded_environment: Environment, @@ -211,6 +220,7 @@ def test_put_onboarding_status__evaluated__logs_already_evaluated( @pytest.mark.django_db +@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__unknown_environment__responds_404( api_client: APIClient, ) -> None: @@ -227,6 +237,7 @@ def test_put_onboarding_status__unknown_environment__responds_404( assert response.status_code == 404 +@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__invalid_sdk_label__responds_400( api_client: APIClient, environment: Environment, @@ -245,3 +256,16 @@ def test_put_onboarding_status__invalid_sdk_label__responds_400( assert response.json() == { "first_evaluated_sdk_label": ['"invalid-sdk-label" is not a valid choice.'], } + + +def test_get_onboarding_status__not_saas__responds_404( + api_client: APIClient, + environment: Environment, +) -> None: + # Given / When + response = api_client.get( + f"/api/v1/environments/{environment.api_key}/onboarding-status/" + ) + + # Then + assert response.status_code == 404 diff --git a/api/tests/unit/onboarding/conftest.py b/api/tests/unit/onboarding/conftest.py deleted file mode 100644 index 8188fe4f4050..000000000000 --- a/api/tests/unit/onboarding/conftest.py +++ /dev/null @@ -1,29 +0,0 @@ -from importlib import reload - -import pytest -from django.urls import clear_url_caches -from pytest_mock import MockerFixture - - -def reload_onboarding_urls() -> None: - import api.urls.v1 as v1_urls - import app.urls as root_urls - import onboarding.urls as onboarding_urls - - reload(onboarding_urls) - reload(v1_urls) - reload(root_urls) - - clear_url_caches() - - -@pytest.fixture() -def is_oss(mocker: MockerFixture) -> None: - mocker.patch("common.core.utils.is_oss", return_value=True) - reload_onboarding_urls() - - -@pytest.fixture() -def is_saas(mocker: MockerFixture) -> None: - mocker.patch("common.core.utils.is_saas", return_value=True) - reload_onboarding_urls() diff --git a/api/tests/unit/onboarding/test_views.py b/api/tests/unit/onboarding/test_views.py index d309f909d884..f7055a81dc87 100644 --- a/api/tests/unit/onboarding/test_views.py +++ b/api/tests/unit/onboarding/test_views.py @@ -1,5 +1,4 @@ -from unittest.mock import MagicMock - +import pytest from django.urls import reverse from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture @@ -11,8 +10,9 @@ from users.models import FFAdminUser +@pytest.mark.usefixtures("is_oss") def test_send_onboarding_request__non_admin_user__returns_forbidden( - staff_client: APIClient, is_oss: MagicMock + staff_client: APIClient, ) -> None: # Given url = reverse("api-v1:onboarding:send-onboarding-request") @@ -24,8 +24,9 @@ def test_send_onboarding_request__non_admin_user__returns_forbidden( assert response.status_code == status.HTTP_403_FORBIDDEN +@pytest.mark.usefixtures("is_oss") def test_send_onboarding_request__no_organisation__returns_bad_request( - admin_client_original: APIClient, is_oss: MagicMock + admin_client_original: APIClient, ) -> None: # Given url = reverse("api-v1:onboarding:send-onboarding-request") @@ -41,12 +42,12 @@ def test_send_onboarding_request__no_organisation__returns_bad_request( ) +@pytest.mark.usefixtures("is_oss") def test_send_onboarding_request__valid_admin_with_org__returns_no_content( admin_client_original: APIClient, mocker: MockerFixture, organisation: Organisation, admin_user: FFAdminUser, - is_oss: MagicMock, ) -> None: # Given mocked_requests = mocker.patch("onboarding.tasks.requests") @@ -69,11 +70,11 @@ def test_send_onboarding_request__valid_admin_with_org__returns_no_content( ) +@pytest.mark.usefixtures("is_saas") def test_receive_onboarding_request__no_hubspot_token__returns_bad_request( settings: SettingsWrapper, api_client: APIClient, db: None, - is_saas: MagicMock, ) -> None: # Given settings.HUBSPOT_ACCESS_TOKEN = None @@ -88,12 +89,12 @@ def test_receive_onboarding_request__no_hubspot_token__returns_bad_request( assert response.json()["message"] == "HubSpot access token not configured" +@pytest.mark.usefixtures("is_saas") def test_receive_onboarding_request__valid_data__creates_lead( settings: SettingsWrapper, api_client: APIClient, mocker: MockerFixture, db: None, - is_saas: None, ) -> None: # Given settings.HUBSPOT_ACCESS_TOKEN = "some-token" @@ -118,12 +119,12 @@ def test_receive_onboarding_request__valid_data__creates_lead( mocked_create_self_hosted_onboarding_lead.assert_called_once_with(**data) +@pytest.mark.usefixtures("is_saas") def test_receive_onboarding_request__repeated_requests__throttles_by_ip( settings: SettingsWrapper, api_client: APIClient, mocker: MockerFixture, db: None, - is_saas: MagicMock, ) -> None: # Given settings.HUBSPOT_ACCESS_TOKEN = "some-token" From b0ef881817762ada0157c234f70467a77cc395ae Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 22 Jul 2026 18:42:54 -0300 Subject: [PATCH 13/14] tweak api visibility --- api/environments/onboarding/urls.py | 17 +++++------ api/environments/onboarding/views.py | 5 +--- api/tests/conftest.py | 23 --------------- api/tests/test_helpers.py | 23 --------------- .../test_unit_environments_views.py | 24 --------------- api/tests/unit/onboarding/conftest.py | 29 +++++++++++++++++++ api/tests/unit/onboarding/test_views.py | 17 +++++------ 7 files changed, 45 insertions(+), 93 deletions(-) create mode 100644 api/tests/unit/onboarding/conftest.py diff --git a/api/environments/onboarding/urls.py b/api/environments/onboarding/urls.py index c882bc964098..5ea62892eb75 100644 --- a/api/environments/onboarding/urls.py +++ b/api/environments/onboarding/urls.py @@ -1,16 +1,13 @@ -from common.core.utils import is_saas from django.urls import path from environments.onboarding.views import EnvironmentOnboardingStatusAPIView app_name = "onboarding" -urlpatterns = [] -if is_saas(): - urlpatterns.append( - path( - "", - EnvironmentOnboardingStatusAPIView.as_view(), - name="onboarding-status", - ) - ) +urlpatterns = [ + path( + "", + EnvironmentOnboardingStatusAPIView.as_view(), + name="onboarding-status", + ), +] diff --git a/api/environments/onboarding/views.py b/api/environments/onboarding/views.py index 815e832694c1..01a804d41169 100644 --- a/api/environments/onboarding/views.py +++ b/api/environments/onboarding/views.py @@ -24,10 +24,7 @@ class EnvironmentOnboardingStatusAPIView(RetrieveAPIView[Environment]): lookup_url_kwarg = "environment_api_key" serializer_class = EnvironmentOnboardingStatusSerializer - @extend_schema( - request=EnvironmentOnboardingStatusUpdateSerializer, - responses={204: None}, - ) + @extend_schema(exclude=True) def put(self, request: Request, environment_api_key: str) -> Response: """Mark this environment as having been evaluated by a client SDK.""" serializer = EnvironmentOnboardingStatusUpdateSerializer(data=request.data) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 34dbff6db678..f03355c7feb9 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -113,7 +113,6 @@ ) from projects.tags.models import Tag from segments.models import Condition, Segment, SegmentRule -from tests.test_helpers import reload_urlconf from tests.types import ( AdminClientAuthType, EnableFeaturesFixture, @@ -168,28 +167,6 @@ def pytest_configure(config: pytest.Config) -> None: # --------------------------------------------------------------------------- -@pytest.fixture() -def is_oss(mocker: MockerFixture) -> typing.Generator[None, None, None]: - mocked_is_oss = mocker.patch("common.core.utils.is_oss", return_value=True) - reload_urlconf() - - yield - - mocker.stop(mocked_is_oss) - reload_urlconf() - - -@pytest.fixture() -def is_saas(mocker: MockerFixture) -> typing.Generator[None, None, None]: - mocked_is_saas = mocker.patch("common.core.utils.is_saas", return_value=True) - reload_urlconf() - - yield - - mocker.stop(mocked_is_saas) - reload_urlconf() - - @pytest.fixture() def post_request_mock(mocker: MockerFixture) -> MagicMock: def mocked_request(*args, **kwargs) -> None: # type: ignore[no-untyped-def] diff --git a/api/tests/test_helpers.py b/api/tests/test_helpers.py index 8cd50842e77e..7d9b213faa54 100644 --- a/api/tests/test_helpers.py +++ b/api/tests/test_helpers.py @@ -1,31 +1,8 @@ -from importlib import reload -from types import ModuleType from typing import Any -from django.urls import URLResolver, clear_url_caches, get_resolver from flag_engine.segments.types import ConditionOperator -def reload_urlconf() -> None: - """Re-import every URL module, children first, so that routes gated on - deployment flags at import time are re-evaluated.""" - reloaded: set[str] = set() - - def reload_resolver_modules(resolver: URLResolver) -> None: - for url_pattern in resolver.url_patterns: - if isinstance(url_pattern, URLResolver): - reload_resolver_modules(url_pattern) - if ( - isinstance(module := resolver.urlconf_module, ModuleType) - and module.__name__ not in reloaded - ): - reloaded.add(module.__name__) - reload(module) - - reload_resolver_modules(get_resolver()) - clear_url_caches() - - def generate_segment_data( segment_name: str, project_id: int, diff --git a/api/tests/unit/environments/onboarding/test_unit_environments_views.py b/api/tests/unit/environments/onboarding/test_unit_environments_views.py index 011eb863513c..fc7b6932d6a5 100644 --- a/api/tests/unit/environments/onboarding/test_unit_environments_views.py +++ b/api/tests/unit/environments/onboarding/test_unit_environments_views.py @@ -25,7 +25,6 @@ def write_environment_documents(mocker: MockerFixture) -> Mock: return mocker.patch.object(Environment, "write_environment_documents") -@pytest.mark.usefixtures("is_saas") def test_get_onboarding_status__never_evaluated__responds_200( api_client: APIClient, environment: Environment, @@ -43,7 +42,6 @@ def test_get_onboarding_status__never_evaluated__responds_200( } -@pytest.mark.usefixtures("is_saas") def test_get_onboarding_status__evaluated__responds_200( api_client: APIClient, onboarded_environment: Environment, @@ -62,7 +60,6 @@ def test_get_onboarding_status__evaluated__responds_200( @pytest.mark.django_db -@pytest.mark.usefixtures("is_saas") def test_get_onboarding_status__unknown_environment__responds_404( api_client: APIClient, ) -> None: @@ -73,7 +70,6 @@ def test_get_onboarding_status__unknown_environment__responds_404( assert response.status_code == 404 -@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__never_evaluated__updates_environment( api_client: APIClient, environment: Environment, @@ -98,7 +94,6 @@ def test_put_onboarding_status__never_evaluated__updates_environment( assert environment.first_evaluated_sdk_label == "flagsmith-python-sdk" -@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__evaluated__does_not_update_environment( api_client: APIClient, onboarded_environment: Environment, @@ -124,7 +119,6 @@ def test_put_onboarding_status__evaluated__does_not_update_environment( assert "python" in onboarded_environment.first_evaluated_sdk_label -@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__never_evaluated__writes_environment_document( api_client: APIClient, environment: Environment, @@ -144,7 +138,6 @@ def test_put_onboarding_status__never_evaluated__writes_environment_document( write_environment_documents.assert_called_once_with(environment_id=environment.id) -@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__evaluated__skips_environment_document( api_client: APIClient, onboarded_environment: Environment, @@ -164,7 +157,6 @@ def test_put_onboarding_status__evaluated__skips_environment_document( write_environment_documents.assert_not_called() -@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__never_evaluated__logs_first_evaluation( api_client: APIClient, environment: Environment, @@ -191,7 +183,6 @@ def test_put_onboarding_status__never_evaluated__logs_first_evaluation( ) -@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__evaluated__logs_already_evaluated( api_client: APIClient, onboarded_environment: Environment, @@ -220,7 +211,6 @@ def test_put_onboarding_status__evaluated__logs_already_evaluated( @pytest.mark.django_db -@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__unknown_environment__responds_404( api_client: APIClient, ) -> None: @@ -237,7 +227,6 @@ def test_put_onboarding_status__unknown_environment__responds_404( assert response.status_code == 404 -@pytest.mark.usefixtures("is_saas") def test_put_onboarding_status__invalid_sdk_label__responds_400( api_client: APIClient, environment: Environment, @@ -256,16 +245,3 @@ def test_put_onboarding_status__invalid_sdk_label__responds_400( assert response.json() == { "first_evaluated_sdk_label": ['"invalid-sdk-label" is not a valid choice.'], } - - -def test_get_onboarding_status__not_saas__responds_404( - api_client: APIClient, - environment: Environment, -) -> None: - # Given / When - response = api_client.get( - f"/api/v1/environments/{environment.api_key}/onboarding-status/" - ) - - # Then - assert response.status_code == 404 diff --git a/api/tests/unit/onboarding/conftest.py b/api/tests/unit/onboarding/conftest.py new file mode 100644 index 000000000000..8188fe4f4050 --- /dev/null +++ b/api/tests/unit/onboarding/conftest.py @@ -0,0 +1,29 @@ +from importlib import reload + +import pytest +from django.urls import clear_url_caches +from pytest_mock import MockerFixture + + +def reload_onboarding_urls() -> None: + import api.urls.v1 as v1_urls + import app.urls as root_urls + import onboarding.urls as onboarding_urls + + reload(onboarding_urls) + reload(v1_urls) + reload(root_urls) + + clear_url_caches() + + +@pytest.fixture() +def is_oss(mocker: MockerFixture) -> None: + mocker.patch("common.core.utils.is_oss", return_value=True) + reload_onboarding_urls() + + +@pytest.fixture() +def is_saas(mocker: MockerFixture) -> None: + mocker.patch("common.core.utils.is_saas", return_value=True) + reload_onboarding_urls() diff --git a/api/tests/unit/onboarding/test_views.py b/api/tests/unit/onboarding/test_views.py index f7055a81dc87..d309f909d884 100644 --- a/api/tests/unit/onboarding/test_views.py +++ b/api/tests/unit/onboarding/test_views.py @@ -1,4 +1,5 @@ -import pytest +from unittest.mock import MagicMock + from django.urls import reverse from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture @@ -10,9 +11,8 @@ from users.models import FFAdminUser -@pytest.mark.usefixtures("is_oss") def test_send_onboarding_request__non_admin_user__returns_forbidden( - staff_client: APIClient, + staff_client: APIClient, is_oss: MagicMock ) -> None: # Given url = reverse("api-v1:onboarding:send-onboarding-request") @@ -24,9 +24,8 @@ def test_send_onboarding_request__non_admin_user__returns_forbidden( assert response.status_code == status.HTTP_403_FORBIDDEN -@pytest.mark.usefixtures("is_oss") def test_send_onboarding_request__no_organisation__returns_bad_request( - admin_client_original: APIClient, + admin_client_original: APIClient, is_oss: MagicMock ) -> None: # Given url = reverse("api-v1:onboarding:send-onboarding-request") @@ -42,12 +41,12 @@ def test_send_onboarding_request__no_organisation__returns_bad_request( ) -@pytest.mark.usefixtures("is_oss") def test_send_onboarding_request__valid_admin_with_org__returns_no_content( admin_client_original: APIClient, mocker: MockerFixture, organisation: Organisation, admin_user: FFAdminUser, + is_oss: MagicMock, ) -> None: # Given mocked_requests = mocker.patch("onboarding.tasks.requests") @@ -70,11 +69,11 @@ def test_send_onboarding_request__valid_admin_with_org__returns_no_content( ) -@pytest.mark.usefixtures("is_saas") def test_receive_onboarding_request__no_hubspot_token__returns_bad_request( settings: SettingsWrapper, api_client: APIClient, db: None, + is_saas: MagicMock, ) -> None: # Given settings.HUBSPOT_ACCESS_TOKEN = None @@ -89,12 +88,12 @@ def test_receive_onboarding_request__no_hubspot_token__returns_bad_request( assert response.json()["message"] == "HubSpot access token not configured" -@pytest.mark.usefixtures("is_saas") def test_receive_onboarding_request__valid_data__creates_lead( settings: SettingsWrapper, api_client: APIClient, mocker: MockerFixture, db: None, + is_saas: None, ) -> None: # Given settings.HUBSPOT_ACCESS_TOKEN = "some-token" @@ -119,12 +118,12 @@ def test_receive_onboarding_request__valid_data__creates_lead( mocked_create_self_hosted_onboarding_lead.assert_called_once_with(**data) -@pytest.mark.usefixtures("is_saas") def test_receive_onboarding_request__repeated_requests__throttles_by_ip( settings: SettingsWrapper, api_client: APIClient, mocker: MockerFixture, db: None, + is_saas: MagicMock, ) -> None: # Given settings.HUBSPOT_ACCESS_TOKEN = "some-token" From 2197c0bfc3288019ab5f326150bb6add310e3fcd Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Wed, 22 Jul 2026 21:45:01 +0000 Subject: [PATCH 14/14] chore: Update documentation artefacts --- openapi.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index b27f5a3cef04..ef7d55b433d1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6507,6 +6507,25 @@ paths: - Master API Key: [] tags: - Environments + '/api/v1/environments/{environment_api_key}/onboarding-status/': + get: + operationId: api_v1_environments_onboarding_status_retrieve + description: Obtain information on whether features for this environment have been evaluated yet. + parameters: + - name: environment_api_key + in: path + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/EnvironmentOnboardingStatus' + tags: + - Environments '/api/v1/environments/{environment_api_key}/user-group-permissions/': get: operationId: api_v1_environments_user_group_permissions_list @@ -19659,6 +19678,21 @@ components: items: $ref: '#/components/schemas/MetricItem' readOnly: true + EnvironmentOnboardingStatus: + type: object + properties: + first_evaluated_at: + description: When the environment's flags were first evaluated by an SDK. + type: + - string + - 'null' + format: date-time + first_evaluated_sdk_label: + description: SDK that first evaluated the environment's flags. + type: + - string + - 'null' + maxLength: 100 EnvironmentRetrieveSerializerWithMetadata: description: Functionality for serializers that need to handle metadata type: object