From fd5ac621d5972fc92d3ce82018770619d0ece271 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 10 Aug 2026 12:19:14 +0530 Subject: [PATCH 01/18] feat(cohorts): add environment cohort CRUD API --- api/audit/related_object_type.py | 1 + .../0002_cohort_deletion_requested_at.py | 18 +++ api/cohorts/models.py | 3 + api/cohorts/permissions.py | 24 ++++ api/cohorts/serializers.py | 30 +++++ api/cohorts/services.py | 98 +++++++++++++++ api/cohorts/tasks.py | 2 + api/cohorts/urls.py | 10 ++ api/cohorts/views.py | 59 +++++++++ api/environments/urls.py | 4 + api/segments/views.py | 10 ++ api/tests/unit/cohorts/test_services.py | 105 +++++++++++++++- api/tests/unit/cohorts/test_tasks.py | 34 ++++++ api/tests/unit/cohorts/test_views.py | 115 ++++++++++++++++++ .../unit/segments/test_unit_segments_views.py | 47 +++++++ .../observability/_events-catalogue.md | 34 +++++- 16 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 api/cohorts/migrations/0002_cohort_deletion_requested_at.py create mode 100644 api/cohorts/permissions.py create mode 100644 api/cohorts/serializers.py create mode 100644 api/cohorts/urls.py create mode 100644 api/cohorts/views.py create mode 100644 api/tests/unit/cohorts/test_views.py diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index 53f6d0a9fbe3..4c94a6b209f4 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -15,3 +15,4 @@ class RelatedObjectType(enum.Enum): WAREHOUSE_CONNECTION = "Warehouse connection" EXPERIMENT = "Experiment" METRIC = "Metric" + COHORT = "Cohort" diff --git a/api/cohorts/migrations/0002_cohort_deletion_requested_at.py b/api/cohorts/migrations/0002_cohort_deletion_requested_at.py new file mode 100644 index 000000000000..4de64bb770b4 --- /dev/null +++ b/api/cohorts/migrations/0002_cohort_deletion_requested_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.16 on 2026-08-06 07:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cohorts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="cohort", + name="deletion_requested_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/api/cohorts/models.py b/api/cohorts/models.py index cb2e9f910dd8..1d95dfacbcaf 100644 --- a/api/cohorts/models.py +++ b/api/cohorts/models.py @@ -26,6 +26,9 @@ class Cohort(SoftDeleteExportableModel): ) version = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) + # Deletion drains memberships from DynamoDB first; the cohort is only + # soft-deleted once drained. This marks it as awaiting that final step. + deletion_requested_at = models.DateTimeField(null=True, blank=True) @property def system_trait_key(self) -> str: diff --git a/api/cohorts/permissions.py b/api/cohorts/permissions.py new file mode 100644 index 000000000000..fdcf184c845a --- /dev/null +++ b/api/cohorts/permissions.py @@ -0,0 +1,24 @@ +from common.environments.permissions import VIEW_ENVIRONMENT +from common.projects.permissions import MANAGE_SEGMENTS +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import APIView + +from environments.models import Environment +from users.models import FFAdminUser + +_READ_ACTIONS = ("list", "retrieve") + + +class CohortPermission(BasePermission): + def has_permission(self, request: Request, view: APIView) -> bool: + try: + environment = Environment.objects.get( + api_key=view.kwargs.get("environment_api_key") + ) + except Environment.DoesNotExist: + return False + user: FFAdminUser = request.user # type: ignore[assignment] + if getattr(view, "action", None) in _READ_ACTIONS: + return user.has_environment_permission(VIEW_ENVIRONMENT, environment) + return user.has_project_permission(MANAGE_SEGMENTS, environment.project) diff --git a/api/cohorts/serializers.py b/api/cohorts/serializers.py new file mode 100644 index 000000000000..19e0339db5d4 --- /dev/null +++ b/api/cohorts/serializers.py @@ -0,0 +1,30 @@ +import typing + +from rest_framework import serializers + +from cohorts.models import Cohort +from cohorts.services import create_cohort + + +class CohortSerializer(serializers.ModelSerializer[Cohort]): + name = serializers.CharField(max_length=2000, source="segment.name") + + class Meta: + model = Cohort + fields = ( + "id", + "uuid", + "name", + "segment", + "source_type", + "version", + "created_at", + ) + read_only_fields = ("segment", "source_type", "version", "created_at") + + def create(self, validated_data: dict[str, typing.Any]) -> Cohort: + return create_cohort( + environment=validated_data["environment"], + name=validated_data["segment"]["name"], + user=validated_data["user"], + ) diff --git a/api/cohorts/services.py b/api/cohorts/services.py index 91e9ba3bcc98..64b45a03a0d0 100644 --- a/api/cohorts/services.py +++ b/api/cohorts/services.py @@ -1,11 +1,24 @@ +import typing + import structlog +from django.db import transaction from django.db.models import QuerySet from django.utils import timezone +from flag_engine.segments.constants import IS_SET +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType from cohorts.constants import COHORT_MEMBERSHIP_APPLY_BATCH_SIZE from cohorts.metrics import flagsmith_cohorts_membership_deltas_applied_total from cohorts.models import Cohort, CohortMembership, CohortMembershipState +from core.dataclasses import AuthorData from environments.dynamodb import DynamoIdentityWrapper +from segments.models import Condition, Segment, SegmentRule +from segments.services import delete_segment + +if typing.TYPE_CHECKING: + from environments.models import Environment + from users.models import FFAdminUser logger = structlog.get_logger("cohorts") @@ -66,3 +79,88 @@ def apply_pending_memberships(cohort: Cohort) -> bool: removes__count=removed_count, ) return pending_memberships(cohort).exists() + + +def _resolve_audit_log_author(user: "FFAdminUser") -> dict[str, int | None]: + if getattr(user, "is_master_api_key_user", False): + return {"author_id": None, "master_api_key_id": user.key.id} + return {"author_id": user.pk, "master_api_key_id": None} + + +def _create_cohort_audit_log( + cohort: Cohort, + user: "FFAdminUser", + *, + action: str, +) -> None: + AuditLog.objects.create( + environment=cohort.environment, + project=cohort.environment.project, + **_resolve_audit_log_author(user), + related_object_id=cohort.id, + related_object_type=RelatedObjectType.COHORT.name, + log=f"Cohort '{cohort.segment.name}' {action}", + ) + + +def create_cohort( + *, + environment: "Environment", + name: str, + user: "FFAdminUser", +) -> Cohort: + with transaction.atomic(): + segment = Segment.objects.create(name=name, project=environment.project) + rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) + cohort: Cohort = Cohort.objects.create(environment=environment, segment=segment) + Condition.objects.create( + rule=rule, + operator=IS_SET, + property=cohort.system_trait_key, + created_with_segment=True, + ) + _create_cohort_audit_log(cohort, user, action="created") + logger.info( + "cohort.created", + cohort__id=cohort.id, + segment__id=segment.id, + environment__id=environment.id, + project__id=environment.project_id, + organisation__id=environment.project.organisation_id, + ) + return cohort + + +def delete_cohort(cohort: Cohort, user: "FFAdminUser") -> None: + from cohorts.tasks import apply_cohort_membership_deltas + + cohort.deletion_requested_at = timezone.now() + cohort.save(update_fields=["deletion_requested_at"]) + _create_cohort_audit_log(cohort, user, action="deleted") + if not ( + cohort.environment.project.enable_dynamo_db + and DynamoIdentityWrapper().is_enabled + ): + finalise_cohort_deletion(cohort) + return + CohortMembership.objects.filter(cohort=cohort).update( + state=CohortMembershipState.PENDING_REMOVE, updated_at=timezone.now() + ) + apply_cohort_membership_deltas.delay(kwargs={"cohort_id": cohort.id}) + logger.info( + "cohort.deletion_requested", + cohort__id=cohort.id, + environment__id=cohort.environment_id, + ) + + +def finalise_cohort_deletion(cohort: Cohort) -> None: + segment = cohort.segment + with transaction.atomic(): + cohort.delete() + delete_segment(segment, AuthorData()) + logger.info( + "cohort.deleted", + cohort__id=cohort.id, + environment__id=cohort.environment_id, + ) diff --git a/api/cohorts/tasks.py b/api/cohorts/tasks.py index b992672619df..0041fdb9a065 100644 --- a/api/cohorts/tasks.py +++ b/api/cohorts/tasks.py @@ -31,6 +31,8 @@ def apply_cohort_membership_deltas(cohort_id: int) -> None: try: for _ in range(COHORT_MEMBERSHIP_APPLY_MAX_BATCHES_PER_RUN): if not services.apply_pending_memberships(cohort): + if cohort.deletion_requested_at is not None: + services.finalise_cohort_deletion(cohort) return except ClientError as exc: if exc.response["Error"]["Code"] in DYNAMODB_THROTTLING_ERROR_CODES: diff --git a/api/cohorts/urls.py b/api/cohorts/urls.py new file mode 100644 index 000000000000..1cb2c65460ae --- /dev/null +++ b/api/cohorts/urls.py @@ -0,0 +1,10 @@ +from rest_framework.routers import DefaultRouter + +from cohorts.views import CohortViewSet + +app_name = "cohorts" + +router = DefaultRouter() +router.register(r"", CohortViewSet, basename="cohorts") + +urlpatterns = router.urls diff --git a/api/cohorts/views.py b/api/cohorts/views.py new file mode 100644 index 000000000000..1a44bf18948c --- /dev/null +++ b/api/cohorts/views.py @@ -0,0 +1,59 @@ +import typing + +from django.db.models import QuerySet +from rest_framework import mixins, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import BaseSerializer + +from cohorts import services +from cohorts.models import Cohort +from cohorts.permissions import CohortPermission +from cohorts.serializers import CohortSerializer +from environments.views import NestedEnvironmentViewSet + +if typing.TYPE_CHECKING: + from users.models import FFAdminUser + + +class CohortViewSet( + NestedEnvironmentViewSet[Cohort], + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, +): + """Manage trait-synced cohorts for an environment.""" + + serializer_class = CohortSerializer + pagination_class = None + permission_classes = [IsAuthenticated, CohortPermission] + model_class = Cohort + lookup_field = "id" + lookup_url_kwarg = "cohort_id" + + def get_queryset(self) -> QuerySet[Cohort]: + # A cohort awaiting drain-then-delete is already gone from the + # user's point of view. + return ( + super() + .get_queryset() + .filter(deletion_requested_at__isnull=True) + .select_related("segment") + .order_by("id") + ) + + def perform_create(self, serializer: BaseSerializer[Cohort]) -> None: + serializer.save( + environment=self._get_environment(), + user=self._get_user(self.request), + ) + + def destroy(self, request: Request, *args: object, **kwargs: object) -> Response: + services.delete_cohort(self.get_object(), self._get_user(request)) + return Response(status=status.HTTP_202_ACCEPTED) + + @staticmethod + def _get_user(request: Request) -> "FFAdminUser": + return request.user # type: ignore[return-value] diff --git a/api/environments/urls.py b/api/environments/urls.py index e0f66d90052b..ad0bebebf622 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -177,6 +177,10 @@ "/warehouse-connections/", include("experimentation.urls"), ), + path( + "/cohorts/", + include("cohorts.urls"), + ), path( "/experiments/", include("experimentation.experiment_urls"), diff --git a/api/segments/views.py b/api/segments/views.py index b1dbafa4ed33..ce6345e652dd 100644 --- a/api/segments/views.py +++ b/api/segments/views.py @@ -214,6 +214,16 @@ def members(self, request: Request, *args: Any, **kwargs: Any) -> Response: next_cursor = members[-1]["identifier"] if has_more else None return Response({"results": members, "next_cursor": next_cursor}) + def check_object_permissions(self, request: Request, obj: Segment) -> None: + super().check_object_permissions(request, obj) + if ( + self.action in ("update", "partial_update", "destroy") + and obj.cohorts.exists() + ): + raise PermissionDenied( + "This segment is managed by a cohort and cannot be edited directly." + ) + def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: segment = self.get_object() author = AuthorData.from_request(request) diff --git a/api/tests/unit/cohorts/test_services.py b/api/tests/unit/cohorts/test_services.py index 1737caea6465..f5345a6a1738 100644 --- a/api/tests/unit/cohorts/test_services.py +++ b/api/tests/unit/cohorts/test_services.py @@ -1,9 +1,19 @@ +from flag_engine.segments.constants import IS_SET from pytest_mock import MockerFixture from pytest_structlog import StructuredLogCapture +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType from cohorts.models import Cohort, CohortMembership, CohortMembershipState -from cohorts.services import apply_pending_memberships +from cohorts.services import ( + apply_pending_memberships, + create_cohort, + delete_cohort, +) from environments.dynamodb import DynamoIdentityWrapper +from environments.models import Environment +from segments.models import Segment, SegmentRule +from users.models import FFAdminUser def test_apply_pending_memberships__no_pending_rows__returns_false( @@ -108,3 +118,96 @@ def transition_row(**kwargs: str) -> None: assert membership.state == CohortMembershipState.PENDING_REMOVE assert result is True assert not log.has("membership.applied") + + +def test_create_cohort__valid_name__creates_segment_with_is_set_condition( + environment: Environment, + admin_user: FFAdminUser, +) -> None: + # Given / When + cohort = create_cohort(environment=environment, name="Beta users", user=admin_user) + + # Then + segment = cohort.segment + assert segment.name == "Beta users" + assert segment.project == environment.project + rule = segment.rules.get() + assert rule.type == SegmentRule.ALL_RULE + condition = rule.conditions.get() + assert condition.operator == IS_SET + assert condition.property == cohort.system_trait_key + assert condition.created_with_segment is True + + +def test_create_cohort__valid_name__writes_audit_log_and_event( + environment: Environment, + admin_user: FFAdminUser, + log: StructuredLogCapture, +) -> None: + # Given / When + cohort = create_cohort(environment=environment, name="Beta users", user=admin_user) + + # Then + audit_log = AuditLog.objects.get(related_object_type=RelatedObjectType.COHORT.name) + assert audit_log.related_object_id == cohort.id + assert audit_log.author == admin_user + assert audit_log.log == "Cohort 'Beta users' created" + assert log.has( + "cohort.created", + cohort__id=cohort.id, + segment__id=cohort.segment_id, + environment__id=environment.id, + ) + + +def test_delete_cohort__non_edge__deletes_cohort_and_segment_immediately( + cohort: Cohort, + admin_user: FFAdminUser, + log: StructuredLogCapture, +) -> None: + # Given + CohortMembership.objects.create( + cohort=cohort, identifier="user-1", state=CohortMembershipState.APPLIED + ) + + # When + delete_cohort(cohort, admin_user) + + # Then + assert not Cohort.objects.filter(id=cohort.id).exists() + assert not Segment.objects.filter(id=cohort.segment_id).exists() + assert not CohortMembership.objects.filter(cohort_id=cohort.id).exists() + audit_log = AuditLog.objects.get(related_object_type=RelatedObjectType.COHORT.name) + assert audit_log.log == f"Cohort '{cohort.segment.name}' deleted" + assert log.has("cohort.deleted", cohort__id=cohort.id) + + +def test_delete_cohort__edge__drains_traits_then_deletes( + edge_cohort: Cohort, + admin_user: FFAdminUser, + dynamodb_identity_wrapper: DynamoIdentityWrapper, + log: StructuredLogCapture, +) -> None: + # Given: one member already applied (trait present), one still pending add + api_key = edge_cohort.environment.api_key + dynamodb_identity_wrapper.set_system_trait( + environment_api_key=api_key, + identifier="applied", + trait_key=edge_cohort.system_trait_key, + ) + CohortMembership.objects.create( + cohort=edge_cohort, identifier="applied", state=CohortMembershipState.APPLIED + ) + CohortMembership.objects.create(cohort=edge_cohort, identifier="pending") + + # When (synchronous task runner executes the enqueued applier inline) + delete_cohort(edge_cohort, admin_user) + + # Then + document = dynamodb_identity_wrapper.get_item(f"{api_key}_applied") + assert document is not None + assert document["system_traits"] == {} + assert dynamodb_identity_wrapper.get_item(f"{api_key}_pending") is None + assert not Cohort.objects.filter(id=edge_cohort.id).exists() + assert not CohortMembership.objects.filter(cohort_id=edge_cohort.id).exists() + assert log.has("cohort.deletion_requested", cohort__id=edge_cohort.id) diff --git a/api/tests/unit/cohorts/test_tasks.py b/api/tests/unit/cohorts/test_tasks.py index 9b00be6b7a81..82efcc1df026 100644 --- a/api/tests/unit/cohorts/test_tasks.py +++ b/api/tests/unit/cohorts/test_tasks.py @@ -1,5 +1,6 @@ import pytest from botocore.exceptions import ClientError +from django.utils import timezone from prometheus_client import REGISTRY from pytest_mock import MockerFixture from pytest_structlog import StructuredLogCapture @@ -9,6 +10,7 @@ from cohorts.models import Cohort, CohortMembership, CohortMembershipState from cohorts.tasks import apply_cohort_membership_deltas from environments.dynamodb import DynamoIdentityWrapper +from segments.models import Segment def test_apply_cohort_membership_deltas__pending_adds__applies_to_documents( @@ -203,3 +205,35 @@ def test_apply_cohort_membership_deltas__deltas_applied__increments_metric( # Then assert REGISTRY.get_sample_value(metric, {"operation": "add"}) == before + 1 + + +def test_apply_cohort_membership_deltas__deletion_requested__finalises_after_drain( + edge_cohort: Cohort, + dynamodb_identity_wrapper: DynamoIdentityWrapper, + log: StructuredLogCapture, +) -> None: + # Given + api_key = edge_cohort.environment.api_key + dynamodb_identity_wrapper.set_system_trait( + environment_api_key=api_key, + identifier="member", + trait_key=edge_cohort.system_trait_key, + ) + CohortMembership.objects.create( + cohort=edge_cohort, + identifier="member", + state=CohortMembershipState.PENDING_REMOVE, + ) + edge_cohort.deletion_requested_at = timezone.now() + edge_cohort.save() + + # When + apply_cohort_membership_deltas(cohort_id=edge_cohort.id) + + # Then + document = dynamodb_identity_wrapper.get_item(f"{api_key}_member") + assert document is not None + assert document["system_traits"] == {} + assert not Cohort.objects.filter(id=edge_cohort.id).exists() + assert not Segment.objects.filter(id=edge_cohort.segment_id).exists() + assert log.has("cohort.deleted", cohort__id=edge_cohort.id) diff --git a/api/tests/unit/cohorts/test_views.py b/api/tests/unit/cohorts/test_views.py new file mode 100644 index 000000000000..56c7073bb0fa --- /dev/null +++ b/api/tests/unit/cohorts/test_views.py @@ -0,0 +1,115 @@ +from common.environments.permissions import VIEW_ENVIRONMENT +from common.projects.permissions import MANAGE_SEGMENTS +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from cohorts.models import Cohort +from environments.models import Environment +from segments.models import Segment +from tests.types import ( + WithEnvironmentPermissionsCallable, + WithProjectPermissionsCallable, +) + + +def test_create_cohort__staff_with_manage_segments__returns_201( + staff_client: APIClient, + environment: Environment, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([MANAGE_SEGMENTS]) # type: ignore[call-arg] + url = reverse( + "api-v1:environments:cohorts:cohorts-list", args=[environment.api_key] + ) + + # When + response = staff_client.post(url, data={"name": "Beta users"}, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED + cohort = Cohort.objects.get(id=response.json()["id"]) + assert response.json()["name"] == "Beta users" + assert response.json()["segment"] == cohort.segment_id + assert cohort.environment == environment + + +def test_create_cohort__staff_without_permission__returns_403( + staff_client: APIClient, + environment: Environment, +) -> None: + # Given + url = reverse( + "api-v1:environments:cohorts:cohorts-list", args=[environment.api_key] + ) + + # When + response = staff_client.post(url, data={"name": "Beta users"}, format="json") + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_create_cohort__unknown_environment__returns_403( + staff_client: APIClient, +) -> None: + # Given + url = reverse("api-v1:environments:cohorts:cohorts-list", args=["missing-key"]) + + # When + response = staff_client.post(url, data={"name": "Beta users"}, format="json") + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_list_cohorts__deletion_requested_cohort__excluded( + staff_client: APIClient, + environment: Environment, + cohort: Cohort, + with_environment_permissions: WithEnvironmentPermissionsCallable, +) -> None: + # Given + with_environment_permissions([VIEW_ENVIRONMENT]) # type: ignore[call-arg] + deleting_segment = Segment.objects.create( + name="going away", project=environment.project + ) + Cohort.objects.create( + environment=environment, + segment=deleting_segment, + deletion_requested_at=timezone.now(), + ) + url = reverse( + "api-v1:environments:cohorts:cohorts-list", args=[environment.api_key] + ) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert [row["id"] for row in response.json()] == [cohort.id] + assert response.json()[0]["name"] == cohort.segment.name + + +def test_delete_cohort__staff_with_manage_segments__returns_202( + staff_client: APIClient, + environment: Environment, + cohort: Cohort, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([MANAGE_SEGMENTS]) # type: ignore[call-arg] + url = reverse( + "api-v1:environments:cohorts:cohorts-detail", + args=[environment.api_key, cohort.id], + ) + + # When + response = staff_client.delete(url) + + # Then + assert response.status_code == status.HTTP_202_ACCEPTED + assert not Cohort.objects.filter(id=cohort.id).exists() diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index c015d2e348f1..ab987e326c53 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -21,6 +21,7 @@ from audit.constants import SEGMENT_DELETED_MESSAGE from audit.models import AuditLog from audit.related_object_type import RelatedObjectType +from cohorts.models import Cohort from environments.models import Environment from features.models import Feature, FeatureSegment, FeatureState from features.versioning.models import EnvironmentFeatureVersion @@ -1967,3 +1968,49 @@ def test_create_segment__body_project_differs_from_url__does_not_create_in_other assert response.status_code == status.HTTP_201_CREATED assert response.json()["project"] == project.id assert not Segment.objects.filter(project=other_project).exists() + + +def test_update_segment__cohort_managed__returns_403( + admin_client: APIClient, + project: Project, + segment: Segment, + environment: Environment, +) -> None: + # Given + Cohort.objects.create(environment=environment, segment=segment) + url = reverse( + "api-v1:projects:project-segments-detail", args=[project.id, segment.id] + ) + data = { + "name": "New segment name", + "project": project.id, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], + } + + # When + response = admin_client.put( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_delete_segment__cohort_managed__returns_403( + admin_client: APIClient, + project: Project, + segment: Segment, + environment: Environment, +) -> None: + # Given + Cohort.objects.create(environment=environment, segment=segment) + url = reverse( + "api-v1:projects:project-segments-detail", args=[project.id, segment.id] + ) + + # When + response = admin_client.delete(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Segment.objects.filter(id=segment.id).exists() diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index af4f83009db0..8bdcda49cedb 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -71,10 +71,40 @@ Attributes: - `feature.count` - `organisation.id` +### `cohorts.cohort.created` + +Logged at `info` from: + - `api/cohorts/services.py:123` + +Attributes: + - `cohort.id` + - `environment.id` + - `organisation.id` + - `project.id` + - `segment.id` + +### `cohorts.cohort.deleted` + +Logged at `info` from: + - `api/cohorts/services.py:162` + +Attributes: + - `cohort.id` + - `environment.id` + +### `cohorts.cohort.deletion_requested` + +Logged at `info` from: + - `api/cohorts/services.py:150` + +Attributes: + - `cohort.id` + - `environment.id` + ### `cohorts.membership.applied` Logged at `info` from: - - `api/cohorts/services.py:61` + - `api/cohorts/services.py:74` Attributes: - `adds.count` @@ -95,7 +125,7 @@ Attributes: ### `cohorts.membership.apply.throttled` Logged at `warning` from: - - `api/cohorts/tasks.py:37` + - `api/cohorts/tasks.py:39` Attributes: - `cohort.id` From a6c9ff0b61b72d9c8691cd1bafc6ba3eee4e2306 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Mon, 10 Aug 2026 06:51:25 +0000 Subject: [PATCH 02/18] chore: Update documentation artefacts --- openapi.yaml | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 86cd0495cb33..a062e3fc9c07 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2037,6 +2037,114 @@ paths: - Master API Key: [] tags: - Environments + '/api/v1/environments/{environment_api_key}/cohorts/': + get: + operationId: api_v1_environments_cohorts_list + description: Manage trait-synced cohorts for an environment. + parameters: + - name: environment_api_key + in: path + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Cohort' + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - Environments + post: + operationId: api_v1_environments_cohorts_create + description: Manage trait-synced cohorts for an environment. + parameters: + - name: environment_api_key + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Cohort' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Cohort' + multipart/form-data: + schema: + $ref: '#/components/schemas/Cohort' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Cohort' + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - Environments + '/api/v1/environments/{environment_api_key}/cohorts/{cohort_id}/': + get: + operationId: api_v1_environments_cohorts_retrieve + description: Manage trait-synced cohorts for an environment. + parameters: + - name: cohort_id + in: path + description: A unique integer value identifying this cohort. + required: true + schema: + type: integer + - name: environment_api_key + in: path + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Cohort' + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - Environments + delete: + operationId: api_v1_environments_cohorts_destroy + description: Manage trait-synced cohorts for an environment. + parameters: + - name: cohort_id + in: path + description: A unique integer value identifying this cohort. + required: true + schema: + type: integer + - name: environment_api_key + in: path + required: true + schema: + type: string + responses: + '204': + description: No response body + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - Environments '/api/v1/environments/{environment_api_key}/create-change-request/': post: operationId: create_environment_feature_change_request @@ -18681,6 +18789,35 @@ components: maxLength: 2000 required: - name + Cohort: + type: object + properties: + id: + type: integer + readOnly: true + uuid: + type: string + format: uuid + readOnly: true + name: + type: string + maxLength: 2000 + segment: + type: integer + readOnly: true + source_type: + allOf: + - $ref: '#/components/schemas/SourceTypeEnum' + readOnly: true + version: + type: integer + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - name Condition: type: object properties: @@ -26518,6 +26655,11 @@ components: type: boolean required: - channel_id + SourceTypeEnum: + description: '* `csv` - CSV' + type: string + enum: + - csv StageAction: type: object properties: From 134630d8f8bf7e2aa94b544ca98aab5d49cdaa76 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 10 Aug 2026 12:27:54 +0530 Subject: [PATCH 03/18] refactor(cohorts): remove audit logs for now --- api/audit/related_object_type.py | 1 - api/cohorts/serializers.py | 1 - api/cohorts/services.py | 36 ++----------------- api/cohorts/views.py | 18 +--------- api/tests/unit/cohorts/test_services.py | 23 +++--------- .../observability/_events-catalogue.md | 8 ++--- 6 files changed, 12 insertions(+), 75 deletions(-) diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index 4c94a6b209f4..53f6d0a9fbe3 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -15,4 +15,3 @@ class RelatedObjectType(enum.Enum): WAREHOUSE_CONNECTION = "Warehouse connection" EXPERIMENT = "Experiment" METRIC = "Metric" - COHORT = "Cohort" diff --git a/api/cohorts/serializers.py b/api/cohorts/serializers.py index 19e0339db5d4..fc311c817349 100644 --- a/api/cohorts/serializers.py +++ b/api/cohorts/serializers.py @@ -26,5 +26,4 @@ def create(self, validated_data: dict[str, typing.Any]) -> Cohort: return create_cohort( environment=validated_data["environment"], name=validated_data["segment"]["name"], - user=validated_data["user"], ) diff --git a/api/cohorts/services.py b/api/cohorts/services.py index 64b45a03a0d0..c2c66f06f2b0 100644 --- a/api/cohorts/services.py +++ b/api/cohorts/services.py @@ -6,8 +6,6 @@ from django.utils import timezone from flag_engine.segments.constants import IS_SET -from audit.models import AuditLog -from audit.related_object_type import RelatedObjectType from cohorts.constants import COHORT_MEMBERSHIP_APPLY_BATCH_SIZE from cohorts.metrics import flagsmith_cohorts_membership_deltas_applied_total from cohorts.models import Cohort, CohortMembership, CohortMembershipState @@ -18,7 +16,6 @@ if typing.TYPE_CHECKING: from environments.models import Environment - from users.models import FFAdminUser logger = structlog.get_logger("cohorts") @@ -81,34 +78,7 @@ def apply_pending_memberships(cohort: Cohort) -> bool: return pending_memberships(cohort).exists() -def _resolve_audit_log_author(user: "FFAdminUser") -> dict[str, int | None]: - if getattr(user, "is_master_api_key_user", False): - return {"author_id": None, "master_api_key_id": user.key.id} - return {"author_id": user.pk, "master_api_key_id": None} - - -def _create_cohort_audit_log( - cohort: Cohort, - user: "FFAdminUser", - *, - action: str, -) -> None: - AuditLog.objects.create( - environment=cohort.environment, - project=cohort.environment.project, - **_resolve_audit_log_author(user), - related_object_id=cohort.id, - related_object_type=RelatedObjectType.COHORT.name, - log=f"Cohort '{cohort.segment.name}' {action}", - ) - - -def create_cohort( - *, - environment: "Environment", - name: str, - user: "FFAdminUser", -) -> Cohort: +def create_cohort(*, environment: "Environment", name: str) -> Cohort: with transaction.atomic(): segment = Segment.objects.create(name=name, project=environment.project) rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) @@ -119,7 +89,6 @@ def create_cohort( property=cohort.system_trait_key, created_with_segment=True, ) - _create_cohort_audit_log(cohort, user, action="created") logger.info( "cohort.created", cohort__id=cohort.id, @@ -131,12 +100,11 @@ def create_cohort( return cohort -def delete_cohort(cohort: Cohort, user: "FFAdminUser") -> None: +def delete_cohort(cohort: Cohort) -> None: from cohorts.tasks import apply_cohort_membership_deltas cohort.deletion_requested_at = timezone.now() cohort.save(update_fields=["deletion_requested_at"]) - _create_cohort_audit_log(cohort, user, action="deleted") if not ( cohort.environment.project.enable_dynamo_db and DynamoIdentityWrapper().is_enabled diff --git a/api/cohorts/views.py b/api/cohorts/views.py index 1a44bf18948c..07f6509019ea 100644 --- a/api/cohorts/views.py +++ b/api/cohorts/views.py @@ -1,11 +1,8 @@ -import typing - from django.db.models import QuerySet from rest_framework import mixins, status from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import BaseSerializer from cohorts import services from cohorts.models import Cohort @@ -13,9 +10,6 @@ from cohorts.serializers import CohortSerializer from environments.views import NestedEnvironmentViewSet -if typing.TYPE_CHECKING: - from users.models import FFAdminUser - class CohortViewSet( NestedEnvironmentViewSet[Cohort], @@ -44,16 +38,6 @@ def get_queryset(self) -> QuerySet[Cohort]: .order_by("id") ) - def perform_create(self, serializer: BaseSerializer[Cohort]) -> None: - serializer.save( - environment=self._get_environment(), - user=self._get_user(self.request), - ) - def destroy(self, request: Request, *args: object, **kwargs: object) -> Response: - services.delete_cohort(self.get_object(), self._get_user(request)) + services.delete_cohort(self.get_object()) return Response(status=status.HTTP_202_ACCEPTED) - - @staticmethod - def _get_user(request: Request) -> "FFAdminUser": - return request.user # type: ignore[return-value] diff --git a/api/tests/unit/cohorts/test_services.py b/api/tests/unit/cohorts/test_services.py index f5345a6a1738..1d1d6e1fe308 100644 --- a/api/tests/unit/cohorts/test_services.py +++ b/api/tests/unit/cohorts/test_services.py @@ -2,8 +2,6 @@ from pytest_mock import MockerFixture from pytest_structlog import StructuredLogCapture -from audit.models import AuditLog -from audit.related_object_type import RelatedObjectType from cohorts.models import Cohort, CohortMembership, CohortMembershipState from cohorts.services import ( apply_pending_memberships, @@ -13,7 +11,6 @@ from environments.dynamodb import DynamoIdentityWrapper from environments.models import Environment from segments.models import Segment, SegmentRule -from users.models import FFAdminUser def test_apply_pending_memberships__no_pending_rows__returns_false( @@ -122,10 +119,9 @@ def transition_row(**kwargs: str) -> None: def test_create_cohort__valid_name__creates_segment_with_is_set_condition( environment: Environment, - admin_user: FFAdminUser, ) -> None: # Given / When - cohort = create_cohort(environment=environment, name="Beta users", user=admin_user) + cohort = create_cohort(environment=environment, name="Beta users") # Then segment = cohort.segment @@ -139,19 +135,14 @@ def test_create_cohort__valid_name__creates_segment_with_is_set_condition( assert condition.created_with_segment is True -def test_create_cohort__valid_name__writes_audit_log_and_event( +def test_create_cohort__valid_name__logs_created_event( environment: Environment, - admin_user: FFAdminUser, log: StructuredLogCapture, ) -> None: # Given / When - cohort = create_cohort(environment=environment, name="Beta users", user=admin_user) + cohort = create_cohort(environment=environment, name="Beta users") # Then - audit_log = AuditLog.objects.get(related_object_type=RelatedObjectType.COHORT.name) - assert audit_log.related_object_id == cohort.id - assert audit_log.author == admin_user - assert audit_log.log == "Cohort 'Beta users' created" assert log.has( "cohort.created", cohort__id=cohort.id, @@ -162,7 +153,6 @@ def test_create_cohort__valid_name__writes_audit_log_and_event( def test_delete_cohort__non_edge__deletes_cohort_and_segment_immediately( cohort: Cohort, - admin_user: FFAdminUser, log: StructuredLogCapture, ) -> None: # Given @@ -171,20 +161,17 @@ def test_delete_cohort__non_edge__deletes_cohort_and_segment_immediately( ) # When - delete_cohort(cohort, admin_user) + delete_cohort(cohort) # Then assert not Cohort.objects.filter(id=cohort.id).exists() assert not Segment.objects.filter(id=cohort.segment_id).exists() assert not CohortMembership.objects.filter(cohort_id=cohort.id).exists() - audit_log = AuditLog.objects.get(related_object_type=RelatedObjectType.COHORT.name) - assert audit_log.log == f"Cohort '{cohort.segment.name}' deleted" assert log.has("cohort.deleted", cohort__id=cohort.id) def test_delete_cohort__edge__drains_traits_then_deletes( edge_cohort: Cohort, - admin_user: FFAdminUser, dynamodb_identity_wrapper: DynamoIdentityWrapper, log: StructuredLogCapture, ) -> None: @@ -201,7 +188,7 @@ def test_delete_cohort__edge__drains_traits_then_deletes( CohortMembership.objects.create(cohort=edge_cohort, identifier="pending") # When (synchronous task runner executes the enqueued applier inline) - delete_cohort(edge_cohort, admin_user) + delete_cohort(edge_cohort) # Then document = dynamodb_identity_wrapper.get_item(f"{api_key}_applied") diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 8bdcda49cedb..a8675e20722f 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -74,7 +74,7 @@ Attributes: ### `cohorts.cohort.created` Logged at `info` from: - - `api/cohorts/services.py:123` + - `api/cohorts/services.py:92` Attributes: - `cohort.id` @@ -86,7 +86,7 @@ Attributes: ### `cohorts.cohort.deleted` Logged at `info` from: - - `api/cohorts/services.py:162` + - `api/cohorts/services.py:130` Attributes: - `cohort.id` @@ -95,7 +95,7 @@ Attributes: ### `cohorts.cohort.deletion_requested` Logged at `info` from: - - `api/cohorts/services.py:150` + - `api/cohorts/services.py:118` Attributes: - `cohort.id` @@ -104,7 +104,7 @@ Attributes: ### `cohorts.membership.applied` Logged at `info` from: - - `api/cohorts/services.py:74` + - `api/cohorts/services.py:71` Attributes: - `adds.count` From 08e4f783a2d8dfd32dc0dbdac4c70c80e82d7a1e Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 10 Aug 2026 12:45:48 +0530 Subject: [PATCH 04/18] docs(cohorts): make deletion drain comment store-agnostic --- api/cohorts/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/cohorts/models.py b/api/cohorts/models.py index 1d95dfacbcaf..605b67be0526 100644 --- a/api/cohorts/models.py +++ b/api/cohorts/models.py @@ -26,8 +26,8 @@ class Cohort(SoftDeleteExportableModel): ) version = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) - # Deletion drains memberships from DynamoDB first; the cohort is only - # soft-deleted once drained. This marks it as awaiting that final step. + # Deletion drains memberships from the identity store first; the cohort is + # only soft-deleted once drained. This marks it as awaiting that final step. deletion_requested_at = models.DateTimeField(null=True, blank=True) @property From fab0b116d0c246f21720d3a392c19e15795a42e8 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 10 Aug 2026 13:01:58 +0530 Subject: [PATCH 05/18] feat(cohorts): require startup plan for cohort API --- api/cohorts/permissions.py | 14 +++++++++ api/tests/unit/cohorts/test_views.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/api/cohorts/permissions.py b/api/cohorts/permissions.py index fdcf184c845a..a63701b88c4a 100644 --- a/api/cohorts/permissions.py +++ b/api/cohorts/permissions.py @@ -1,14 +1,19 @@ from common.environments.permissions import VIEW_ENVIRONMENT from common.projects.permissions import MANAGE_SEGMENTS +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.views import APIView from environments.models import Environment +from organisations.subscriptions.constants import SubscriptionPlanFamily +from organisations.subscriptions.permissions import require_minimum_plan from users.models import FFAdminUser _READ_ACTIONS = ("list", "retrieve") +_MinimumPlanPermission = require_minimum_plan(SubscriptionPlanFamily.START_UP) + class CohortPermission(BasePermission): def has_permission(self, request: Request, view: APIView) -> bool: @@ -18,6 +23,15 @@ def has_permission(self, request: Request, view: APIView) -> bool: ) except Environment.DoesNotExist: return False + # Cohorts are a paid feature. The plan permission reads the + # organisation off the object it's given; the project carries it. + plan_permission = _MinimumPlanPermission() + if not plan_permission.has_object_permission( + request, view, environment.project + ): + # `message` exists on the concrete class the factory returns, but + # its return annotation (`type[BasePermission]`) hides it. + raise PermissionDenied(plan_permission.message) # type: ignore[attr-defined] user: FFAdminUser = request.user # type: ignore[assignment] if getattr(view, "action", None) in _READ_ACTIONS: return user.has_environment_permission(VIEW_ENVIRONMENT, environment) diff --git a/api/tests/unit/cohorts/test_views.py b/api/tests/unit/cohorts/test_views.py index 56c7073bb0fa..b70d1ec3d24c 100644 --- a/api/tests/unit/cohorts/test_views.py +++ b/api/tests/unit/cohorts/test_views.py @@ -1,3 +1,4 @@ +import pytest from common.environments.permissions import VIEW_ENVIRONMENT from common.projects.permissions import MANAGE_SEGMENTS from django.urls import reverse @@ -7,6 +8,7 @@ from cohorts.models import Cohort from environments.models import Environment +from organisations.models import Subscription from segments.models import Segment from tests.types import ( WithEnvironmentPermissionsCallable, @@ -113,3 +115,45 @@ def test_delete_cohort__staff_with_manage_segments__returns_202( # Then assert response.status_code == status.HTTP_202_ACCEPTED assert not Cohort.objects.filter(id=cohort.id).exists() + + +@pytest.mark.saas_mode +def test_create_cohort__saas_free_plan__returns_403( + staff_client: APIClient, + environment: Environment, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([MANAGE_SEGMENTS]) # type: ignore[call-arg] + url = reverse( + "api-v1:environments:cohorts:cohorts-list", args=[environment.api_key] + ) + + # When + response = staff_client.post(url, data={"name": "Beta users"}, format="json") + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == ( + "This resource requires a START_UP plan or above." + ) + + +@pytest.mark.saas_mode +def test_create_cohort__saas_startup_plan__returns_201( + staff_client: APIClient, + environment: Environment, + with_project_permissions: WithProjectPermissionsCallable, + startup_subscription: Subscription, +) -> None: + # Given + with_project_permissions([MANAGE_SEGMENTS]) # type: ignore[call-arg] + url = reverse( + "api-v1:environments:cohorts:cohorts-list", args=[environment.api_key] + ) + + # When + response = staff_client.post(url, data={"name": "Beta users"}, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED From 96a3e9f3dd113eeaf8775b38498abbdb5b2c6336 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 10 Aug 2026 13:10:35 +0530 Subject: [PATCH 06/18] refactor(cohorts): declare plan gate on the viewset --- api/cohorts/permissions.py | 32 ++++++++++++++-------- api/cohorts/views.py | 4 +-- api/tests/unit/cohorts/test_permissions.py | 15 ++++++++++ 3 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 api/tests/unit/cohorts/test_permissions.py diff --git a/api/cohorts/permissions.py b/api/cohorts/permissions.py index a63701b88c4a..b4d3aacfc9c2 100644 --- a/api/cohorts/permissions.py +++ b/api/cohorts/permissions.py @@ -1,6 +1,5 @@ from common.environments.permissions import VIEW_ENVIRONMENT from common.projects.permissions import MANAGE_SEGMENTS -from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.views import APIView @@ -12,7 +11,27 @@ _READ_ACTIONS = ("list", "retrieve") -_MinimumPlanPermission = require_minimum_plan(SubscriptionPlanFamily.START_UP) +_MinimumStartupPlan = require_minimum_plan(SubscriptionPlanFamily.START_UP) + + +class CohortPlanPermission(_MinimumStartupPlan): # type: ignore[misc,valid-type] + def has_permission(self, request: Request, view: APIView) -> bool: + try: + environment = Environment.objects.get( + api_key=view.kwargs.get("environment_api_key") + ) + except Environment.DoesNotExist: + return False + # The base class reads the organisation from an `organisation` request + # param our URLs don't carry; the project provides it instead. + return bool(super().has_object_permission(request, view, environment.project)) + + def has_object_permission( + self, request: Request, view: APIView, obj: object + ) -> bool: + # DRF hands us a Cohort here, which doesn't carry an organisation; + # re-run the environment-based check instead. + return self.has_permission(request, view) class CohortPermission(BasePermission): @@ -23,15 +42,6 @@ def has_permission(self, request: Request, view: APIView) -> bool: ) except Environment.DoesNotExist: return False - # Cohorts are a paid feature. The plan permission reads the - # organisation off the object it's given; the project carries it. - plan_permission = _MinimumPlanPermission() - if not plan_permission.has_object_permission( - request, view, environment.project - ): - # `message` exists on the concrete class the factory returns, but - # its return annotation (`type[BasePermission]`) hides it. - raise PermissionDenied(plan_permission.message) # type: ignore[attr-defined] user: FFAdminUser = request.user # type: ignore[assignment] if getattr(view, "action", None) in _READ_ACTIONS: return user.has_environment_permission(VIEW_ENVIRONMENT, environment) diff --git a/api/cohorts/views.py b/api/cohorts/views.py index 07f6509019ea..7d980c285e97 100644 --- a/api/cohorts/views.py +++ b/api/cohorts/views.py @@ -6,7 +6,7 @@ from cohorts import services from cohorts.models import Cohort -from cohorts.permissions import CohortPermission +from cohorts.permissions import CohortPermission, CohortPlanPermission from cohorts.serializers import CohortSerializer from environments.views import NestedEnvironmentViewSet @@ -22,7 +22,7 @@ class CohortViewSet( serializer_class = CohortSerializer pagination_class = None - permission_classes = [IsAuthenticated, CohortPermission] + permission_classes = [IsAuthenticated, CohortPlanPermission, CohortPermission] model_class = Cohort lookup_field = "id" lookup_url_kwarg = "cohort_id" diff --git a/api/tests/unit/cohorts/test_permissions.py b/api/tests/unit/cohorts/test_permissions.py new file mode 100644 index 000000000000..b1cc9a50748e --- /dev/null +++ b/api/tests/unit/cohorts/test_permissions.py @@ -0,0 +1,15 @@ +from unittest.mock import MagicMock + +from cohorts.permissions import CohortPermission + + +def test_cohort_permission__unknown_environment__returns_false(db: None) -> None: + # Given + permission = CohortPermission() + view = MagicMock(kwargs={"environment_api_key": "missing"}) + + # When + result = permission.has_permission(MagicMock(), view) + + # Then + assert result is False From b9daea598b030f0bac0d2e14b285780ae27f7450 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Mon, 10 Aug 2026 07:42:27 +0000 Subject: [PATCH 07/18] chore: Update documentation artefacts --- openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index a062e3fc9c07..6c7f875f602f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2061,6 +2061,7 @@ paths: - Master API Key: [] tags: - Environments + x-flagsmith-minimum-plan: START_UP post: operationId: api_v1_environments_cohorts_create description: Manage trait-synced cohorts for an environment. @@ -2094,6 +2095,7 @@ paths: - Master API Key: [] tags: - Environments + x-flagsmith-minimum-plan: START_UP '/api/v1/environments/{environment_api_key}/cohorts/{cohort_id}/': get: operationId: api_v1_environments_cohorts_retrieve @@ -2122,6 +2124,7 @@ paths: - Master API Key: [] tags: - Environments + x-flagsmith-minimum-plan: START_UP delete: operationId: api_v1_environments_cohorts_destroy description: Manage trait-synced cohorts for an environment. @@ -2145,6 +2148,7 @@ paths: - Master API Key: [] tags: - Environments + x-flagsmith-minimum-plan: START_UP '/api/v1/environments/{environment_api_key}/create-change-request/': post: operationId: create_environment_feature_change_request From 58481a06fd7a3f2922ea470ddd20c108c410d2ec Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 10 Aug 2026 13:43:25 +0530 Subject: [PATCH 08/18] refactor(cohorts): drop uninformative viewset docstring --- api/cohorts/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/cohorts/views.py b/api/cohorts/views.py index 7d980c285e97..cbf916d02ff6 100644 --- a/api/cohorts/views.py +++ b/api/cohorts/views.py @@ -18,8 +18,6 @@ class CohortViewSet( mixins.RetrieveModelMixin, mixins.DestroyModelMixin, ): - """Manage trait-synced cohorts for an environment.""" - serializer_class = CohortSerializer pagination_class = None permission_classes = [IsAuthenticated, CohortPlanPermission, CohortPermission] From d4038e28b9613e2e8d13182a788fed4e092840c1 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Mon, 10 Aug 2026 08:15:33 +0000 Subject: [PATCH 09/18] chore: Update documentation artefacts --- openapi.yaml | 92 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 6c7f875f602f..fd4f6e1b2144 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2040,7 +2040,28 @@ paths: '/api/v1/environments/{environment_api_key}/cohorts/': get: operationId: api_v1_environments_cohorts_list - description: Manage trait-synced cohorts for an environment. + description: |- + Abstract base class for generic types. + + On Python 3.12 and newer, generic classes implicitly inherit from + Generic when they declare a parameter list after the class's name:: + + class Mapping[KT, VT]: + def __getitem__(self, key: KT) -> VT: + ... + # Etc. + + On older versions of Python, however, generic classes have to + explicitly inherit from Generic. + + After a class has been declared to be generic, it can then be used as + follows:: + + def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: + try: + return mapping[key] + except KeyError: + return default parameters: - name: environment_api_key in: path @@ -2064,7 +2085,28 @@ paths: x-flagsmith-minimum-plan: START_UP post: operationId: api_v1_environments_cohorts_create - description: Manage trait-synced cohorts for an environment. + description: |- + Abstract base class for generic types. + + On Python 3.12 and newer, generic classes implicitly inherit from + Generic when they declare a parameter list after the class's name:: + + class Mapping[KT, VT]: + def __getitem__(self, key: KT) -> VT: + ... + # Etc. + + On older versions of Python, however, generic classes have to + explicitly inherit from Generic. + + After a class has been declared to be generic, it can then be used as + follows:: + + def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: + try: + return mapping[key] + except KeyError: + return default parameters: - name: environment_api_key in: path @@ -2099,7 +2141,28 @@ paths: '/api/v1/environments/{environment_api_key}/cohorts/{cohort_id}/': get: operationId: api_v1_environments_cohorts_retrieve - description: Manage trait-synced cohorts for an environment. + description: |- + Abstract base class for generic types. + + On Python 3.12 and newer, generic classes implicitly inherit from + Generic when they declare a parameter list after the class's name:: + + class Mapping[KT, VT]: + def __getitem__(self, key: KT) -> VT: + ... + # Etc. + + On older versions of Python, however, generic classes have to + explicitly inherit from Generic. + + After a class has been declared to be generic, it can then be used as + follows:: + + def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: + try: + return mapping[key] + except KeyError: + return default parameters: - name: cohort_id in: path @@ -2127,7 +2190,28 @@ paths: x-flagsmith-minimum-plan: START_UP delete: operationId: api_v1_environments_cohorts_destroy - description: Manage trait-synced cohorts for an environment. + description: |- + Abstract base class for generic types. + + On Python 3.12 and newer, generic classes implicitly inherit from + Generic when they declare a parameter list after the class's name:: + + class Mapping[KT, VT]: + def __getitem__(self, key: KT) -> VT: + ... + # Etc. + + On older versions of Python, however, generic classes have to + explicitly inherit from Generic. + + After a class has been declared to be generic, it can then be used as + follows:: + + def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: + try: + return mapping[key] + except KeyError: + return default parameters: - name: cohort_id in: path From ee035744af21a23b752028eb98ebea38a0df680d Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 10 Aug 2026 14:23:52 +0530 Subject: [PATCH 10/18] feat(cohorts): reject non-edge projects with DynamoNotEnabledError --- api/cohorts/services.py | 11 ++- api/cohorts/tasks.py | 6 +- api/cohorts/views.py | 8 ++ api/tests/unit/cohorts/test_services.py | 21 +----- api/tests/unit/cohorts/test_tasks.py | 12 ++- api/tests/unit/cohorts/test_views.py | 73 ++++++++++++++----- .../observability/_events-catalogue.md | 14 ++-- 7 files changed, 87 insertions(+), 58 deletions(-) diff --git a/api/cohorts/services.py b/api/cohorts/services.py index c2c66f06f2b0..a6c7042e6ca2 100644 --- a/api/cohorts/services.py +++ b/api/cohorts/services.py @@ -16,6 +16,7 @@ if typing.TYPE_CHECKING: from environments.models import Environment + from projects.models import Project logger = structlog.get_logger("cohorts") @@ -100,17 +101,15 @@ def create_cohort(*, environment: "Environment", name: str) -> Cohort: return cohort +def edge_sync_enabled(project: "Project") -> bool: + return bool(project.enable_dynamo_db and DynamoIdentityWrapper().is_enabled) + + def delete_cohort(cohort: Cohort) -> None: from cohorts.tasks import apply_cohort_membership_deltas cohort.deletion_requested_at = timezone.now() cohort.save(update_fields=["deletion_requested_at"]) - if not ( - cohort.environment.project.enable_dynamo_db - and DynamoIdentityWrapper().is_enabled - ): - finalise_cohort_deletion(cohort) - return CohortMembership.objects.filter(cohort=cohort).update( state=CohortMembershipState.PENDING_REMOVE, updated_at=timezone.now() ) diff --git a/api/cohorts/tasks.py b/api/cohorts/tasks.py index 0041fdb9a065..6b98d6eb6cb8 100644 --- a/api/cohorts/tasks.py +++ b/api/cohorts/tasks.py @@ -11,7 +11,6 @@ DYNAMODB_THROTTLING_ERROR_CODES, ) from cohorts.models import Cohort -from environments.dynamodb import DynamoIdentityWrapper logger = structlog.get_logger("cohorts") @@ -22,10 +21,7 @@ def apply_cohort_membership_deltas(cohort_id: int) -> None: if (cohort := Cohort.objects.filter(id=cohort_id).first()) is None: log.info("membership.apply.skipped", reason="cohort_missing") return - if not ( - cohort.environment.project.enable_dynamo_db - and DynamoIdentityWrapper().is_enabled - ): + if not services.edge_sync_enabled(cohort.environment.project): log.info("membership.apply.skipped", reason="not_edge") return try: diff --git a/api/cohorts/views.py b/api/cohorts/views.py index cbf916d02ff6..500c9d052967 100644 --- a/api/cohorts/views.py +++ b/api/cohorts/views.py @@ -9,6 +9,7 @@ from cohorts.permissions import CohortPermission, CohortPlanPermission from cohorts.serializers import CohortSerializer from environments.views import NestedEnvironmentViewSet +from projects.exceptions import DynamoNotEnabledError class CohortViewSet( @@ -25,6 +26,13 @@ class CohortViewSet( lookup_field = "id" lookup_url_kwarg = "cohort_id" + def initial(self, request: Request, *args: object, **kwargs: object) -> None: + super().initial(request, *args, **kwargs) + # Cohorts only sync to edge identities for now; core (Postgres + # identities) support comes later. + if not services.edge_sync_enabled(self._get_environment().project): + raise DynamoNotEnabledError() + def get_queryset(self) -> QuerySet[Cohort]: # A cohort awaiting drain-then-delete is already gone from the # user's point of view. diff --git a/api/tests/unit/cohorts/test_services.py b/api/tests/unit/cohorts/test_services.py index 1d1d6e1fe308..b641dec22f1a 100644 --- a/api/tests/unit/cohorts/test_services.py +++ b/api/tests/unit/cohorts/test_services.py @@ -10,7 +10,7 @@ ) from environments.dynamodb import DynamoIdentityWrapper from environments.models import Environment -from segments.models import Segment, SegmentRule +from segments.models import SegmentRule def test_apply_pending_memberships__no_pending_rows__returns_false( @@ -151,25 +151,6 @@ def test_create_cohort__valid_name__logs_created_event( ) -def test_delete_cohort__non_edge__deletes_cohort_and_segment_immediately( - cohort: Cohort, - log: StructuredLogCapture, -) -> None: - # Given - CohortMembership.objects.create( - cohort=cohort, identifier="user-1", state=CohortMembershipState.APPLIED - ) - - # When - delete_cohort(cohort) - - # Then - assert not Cohort.objects.filter(id=cohort.id).exists() - assert not Segment.objects.filter(id=cohort.segment_id).exists() - assert not CohortMembership.objects.filter(cohort_id=cohort.id).exists() - assert log.has("cohort.deleted", cohort__id=cohort.id) - - def test_delete_cohort__edge__drains_traits_then_deletes( edge_cohort: Cohort, dynamodb_identity_wrapper: DynamoIdentityWrapper, diff --git a/api/tests/unit/cohorts/test_tasks.py b/api/tests/unit/cohorts/test_tasks.py index 82efcc1df026..b195d8a04e38 100644 --- a/api/tests/unit/cohorts/test_tasks.py +++ b/api/tests/unit/cohorts/test_tasks.py @@ -85,7 +85,9 @@ def test_apply_cohort_membership_deltas__non_edge_project__skips( log: StructuredLogCapture, ) -> None: # Given - mocker.patch("cohorts.tasks.DynamoIdentityWrapper").return_value.is_enabled = True + mocker.patch( + "cohorts.services.DynamoIdentityWrapper" + ).return_value.is_enabled = True membership = CohortMembership.objects.create(cohort=cohort, identifier="user-1") # When @@ -131,7 +133,9 @@ def test_apply_cohort_membership_deltas__dynamo_throttled__raises_backoff( log: StructuredLogCapture, ) -> None: # Given - mocker.patch("cohorts.tasks.DynamoIdentityWrapper").return_value.is_enabled = True + mocker.patch( + "cohorts.services.DynamoIdentityWrapper" + ).return_value.is_enabled = True mocker.patch.object( services, "apply_pending_memberships", @@ -152,7 +156,9 @@ def test_apply_cohort_membership_deltas__other_client_error__reraises( log: StructuredLogCapture, ) -> None: # Given - mocker.patch("cohorts.tasks.DynamoIdentityWrapper").return_value.is_enabled = True + mocker.patch( + "cohorts.services.DynamoIdentityWrapper" + ).return_value.is_enabled = True mocker.patch.object( services, "apply_pending_memberships", diff --git a/api/tests/unit/cohorts/test_views.py b/api/tests/unit/cohorts/test_views.py index b70d1ec3d24c..74eb5b2106b2 100644 --- a/api/tests/unit/cohorts/test_views.py +++ b/api/tests/unit/cohorts/test_views.py @@ -3,12 +3,15 @@ from common.projects.permissions import MANAGE_SEGMENTS from django.urls import reverse from django.utils import timezone +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient from cohorts.models import Cohort +from environments.dynamodb import DynamoIdentityWrapper from environments.models import Environment from organisations.models import Subscription +from projects.models import Project from segments.models import Segment from tests.types import ( WithEnvironmentPermissionsCallable, @@ -18,13 +21,18 @@ def test_create_cohort__staff_with_manage_segments__returns_201( staff_client: APIClient, - environment: Environment, + dynamo_enabled_project: Project, + dynamo_enabled_project_environment_one: Environment, + dynamodb_identity_wrapper: DynamoIdentityWrapper, with_project_permissions: WithProjectPermissionsCallable, ) -> None: # Given - with_project_permissions([MANAGE_SEGMENTS]) # type: ignore[call-arg] + with_project_permissions( # type: ignore[call-arg] + [MANAGE_SEGMENTS], project_id=dynamo_enabled_project.id + ) url = reverse( - "api-v1:environments:cohorts:cohorts-list", args=[environment.api_key] + "api-v1:environments:cohorts:cohorts-list", + args=[dynamo_enabled_project_environment_one.api_key], ) # When @@ -35,7 +43,7 @@ def test_create_cohort__staff_with_manage_segments__returns_201( cohort = Cohort.objects.get(id=response.json()["id"]) assert response.json()["name"] == "Beta users" assert response.json()["segment"] == cohort.segment_id - assert cohort.environment == environment + assert cohort.environment == dynamo_enabled_project_environment_one def test_create_cohort__staff_without_permission__returns_403( @@ -69,12 +77,15 @@ def test_create_cohort__unknown_environment__returns_403( def test_list_cohorts__deletion_requested_cohort__excluded( staff_client: APIClient, - environment: Environment, - cohort: Cohort, + edge_cohort: Cohort, + dynamodb_identity_wrapper: DynamoIdentityWrapper, with_environment_permissions: WithEnvironmentPermissionsCallable, ) -> None: # Given - with_environment_permissions([VIEW_ENVIRONMENT]) # type: ignore[call-arg] + environment = edge_cohort.environment + with_environment_permissions( # type: ignore[call-arg] + [VIEW_ENVIRONMENT], environment_id=environment.id + ) deleting_segment = Segment.objects.create( name="going away", project=environment.project ) @@ -92,21 +103,24 @@ def test_list_cohorts__deletion_requested_cohort__excluded( # Then assert response.status_code == status.HTTP_200_OK - assert [row["id"] for row in response.json()] == [cohort.id] - assert response.json()[0]["name"] == cohort.segment.name + assert [row["id"] for row in response.json()] == [edge_cohort.id] + assert response.json()[0]["name"] == edge_cohort.segment.name def test_delete_cohort__staff_with_manage_segments__returns_202( staff_client: APIClient, - environment: Environment, - cohort: Cohort, + dynamo_enabled_project: Project, + edge_cohort: Cohort, + dynamodb_identity_wrapper: DynamoIdentityWrapper, with_project_permissions: WithProjectPermissionsCallable, ) -> None: # Given - with_project_permissions([MANAGE_SEGMENTS]) # type: ignore[call-arg] + with_project_permissions( # type: ignore[call-arg] + [MANAGE_SEGMENTS], project_id=dynamo_enabled_project.id + ) url = reverse( "api-v1:environments:cohorts:cohorts-detail", - args=[environment.api_key, cohort.id], + args=[edge_cohort.environment.api_key, edge_cohort.id], ) # When @@ -114,7 +128,7 @@ def test_delete_cohort__staff_with_manage_segments__returns_202( # Then assert response.status_code == status.HTTP_202_ACCEPTED - assert not Cohort.objects.filter(id=cohort.id).exists() + assert not Cohort.objects.filter(id=edge_cohort.id).exists() @pytest.mark.saas_mode @@ -139,12 +153,36 @@ def test_create_cohort__saas_free_plan__returns_403( ) -@pytest.mark.saas_mode def test_create_cohort__saas_startup_plan__returns_201( staff_client: APIClient, - environment: Environment, + dynamo_enabled_project: Project, + dynamo_enabled_project_environment_one: Environment, + dynamodb_identity_wrapper: DynamoIdentityWrapper, with_project_permissions: WithProjectPermissionsCallable, startup_subscription: Subscription, + mocker: MockerFixture, +) -> None: + # Given (saas_mode's fake filesystem breaks moto, so patch is_saas instead) + mocker.patch("organisations.subscriptions.permissions.is_saas", return_value=True) + with_project_permissions( # type: ignore[call-arg] + [MANAGE_SEGMENTS], project_id=dynamo_enabled_project.id + ) + url = reverse( + "api-v1:environments:cohorts:cohorts-list", + args=[dynamo_enabled_project_environment_one.api_key], + ) + + # When + response = staff_client.post(url, data={"name": "Beta users"}, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED + + +def test_create_cohort__non_edge_project__returns_400( + staff_client: APIClient, + environment: Environment, + with_project_permissions: WithProjectPermissionsCallable, ) -> None: # Given with_project_permissions([MANAGE_SEGMENTS]) # type: ignore[call-arg] @@ -156,4 +194,5 @@ def test_create_cohort__saas_startup_plan__returns_201( response = staff_client.post(url, data={"name": "Beta users"}, format="json") # Then - assert response.status_code == status.HTTP_201_CREATED + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == "Dynamo DB is not enabled for this project" diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index a8675e20722f..a58897d9de70 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -74,7 +74,7 @@ Attributes: ### `cohorts.cohort.created` Logged at `info` from: - - `api/cohorts/services.py:92` + - `api/cohorts/services.py:93` Attributes: - `cohort.id` @@ -86,7 +86,7 @@ Attributes: ### `cohorts.cohort.deleted` Logged at `info` from: - - `api/cohorts/services.py:130` + - `api/cohorts/services.py:129` Attributes: - `cohort.id` @@ -95,7 +95,7 @@ Attributes: ### `cohorts.cohort.deletion_requested` Logged at `info` from: - - `api/cohorts/services.py:118` + - `api/cohorts/services.py:117` Attributes: - `cohort.id` @@ -104,7 +104,7 @@ Attributes: ### `cohorts.membership.applied` Logged at `info` from: - - `api/cohorts/services.py:71` + - `api/cohorts/services.py:72` Attributes: - `adds.count` @@ -115,8 +115,8 @@ Attributes: ### `cohorts.membership.apply.skipped` Logged at `info` from: - - `api/cohorts/tasks.py:23` - - `api/cohorts/tasks.py:29` + - `api/cohorts/tasks.py:22` + - `api/cohorts/tasks.py:25` Attributes: - `cohort.id` @@ -125,7 +125,7 @@ Attributes: ### `cohorts.membership.apply.throttled` Logged at `warning` from: - - `api/cohorts/tasks.py:39` + - `api/cohorts/tasks.py:35` Attributes: - `cohort.id` From b750237a77b7922abb4acc87a51aab1f7575b82d Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 11 Aug 2026 10:28:52 +0530 Subject: [PATCH 11/18] fix(cohorts): log deletion request before enqueue and describe API schema --- api/cohorts/services.py | 8 ++++---- api/cohorts/views.py | 15 +++++++++++++++ .../observability/_events-catalogue.md | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/api/cohorts/services.py b/api/cohorts/services.py index a6c7042e6ca2..e5664afc1537 100644 --- a/api/cohorts/services.py +++ b/api/cohorts/services.py @@ -110,15 +110,15 @@ def delete_cohort(cohort: Cohort) -> None: cohort.deletion_requested_at = timezone.now() cohort.save(update_fields=["deletion_requested_at"]) - CohortMembership.objects.filter(cohort=cohort).update( - state=CohortMembershipState.PENDING_REMOVE, updated_at=timezone.now() - ) - apply_cohort_membership_deltas.delay(kwargs={"cohort_id": cohort.id}) logger.info( "cohort.deletion_requested", cohort__id=cohort.id, environment__id=cohort.environment_id, ) + CohortMembership.objects.filter(cohort=cohort).update( + state=CohortMembershipState.PENDING_REMOVE, updated_at=timezone.now() + ) + apply_cohort_membership_deltas.delay(kwargs={"cohort_id": cohort.id}) def finalise_cohort_deletion(cohort: Cohort) -> None: diff --git a/api/cohorts/views.py b/api/cohorts/views.py index 500c9d052967..eda7c209a3d9 100644 --- a/api/cohorts/views.py +++ b/api/cohorts/views.py @@ -1,4 +1,5 @@ from django.db.models import QuerySet +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import mixins, status from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request @@ -12,6 +13,20 @@ from projects.exceptions import DynamoNotEnabledError +@extend_schema_view( + list=extend_schema(description="List the environment's cohorts."), + create=extend_schema( + description="Create a cohort and the managed segment that targets it." + ), + retrieve=extend_schema(description="Retrieve a cohort."), + destroy=extend_schema( + description=( + "Request cohort deletion. Memberships are drained from identity " + "data first; the cohort and its segment are deleted once drained." + ), + responses={202: None}, + ), +) class CohortViewSet( NestedEnvironmentViewSet[Cohort], mixins.ListModelMixin, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index a58897d9de70..cbdfbaa18c4a 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -95,7 +95,7 @@ Attributes: ### `cohorts.cohort.deletion_requested` Logged at `info` from: - - `api/cohorts/services.py:117` + - `api/cohorts/services.py:113` Attributes: - `cohort.id` From 34b34ceaeeb7ef9584e5c6d3b9490909042b6abb Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Tue, 11 Aug 2026 06:03:32 +0000 Subject: [PATCH 12/18] chore: Update documentation artefacts --- openapi.yaml | 94 +++------------------------------------------------- 1 file changed, 5 insertions(+), 89 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index fd4f6e1b2144..e099f3dc2a18 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2040,28 +2040,7 @@ paths: '/api/v1/environments/{environment_api_key}/cohorts/': get: operationId: api_v1_environments_cohorts_list - description: |- - Abstract base class for generic types. - - On Python 3.12 and newer, generic classes implicitly inherit from - Generic when they declare a parameter list after the class's name:: - - class Mapping[KT, VT]: - def __getitem__(self, key: KT) -> VT: - ... - # Etc. - - On older versions of Python, however, generic classes have to - explicitly inherit from Generic. - - After a class has been declared to be generic, it can then be used as - follows:: - - def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: - try: - return mapping[key] - except KeyError: - return default + description: List the environment's cohorts. parameters: - name: environment_api_key in: path @@ -2085,28 +2064,7 @@ paths: x-flagsmith-minimum-plan: START_UP post: operationId: api_v1_environments_cohorts_create - description: |- - Abstract base class for generic types. - - On Python 3.12 and newer, generic classes implicitly inherit from - Generic when they declare a parameter list after the class's name:: - - class Mapping[KT, VT]: - def __getitem__(self, key: KT) -> VT: - ... - # Etc. - - On older versions of Python, however, generic classes have to - explicitly inherit from Generic. - - After a class has been declared to be generic, it can then be used as - follows:: - - def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: - try: - return mapping[key] - except KeyError: - return default + description: Create a cohort and the managed segment that targets it. parameters: - name: environment_api_key in: path @@ -2141,28 +2099,7 @@ paths: '/api/v1/environments/{environment_api_key}/cohorts/{cohort_id}/': get: operationId: api_v1_environments_cohorts_retrieve - description: |- - Abstract base class for generic types. - - On Python 3.12 and newer, generic classes implicitly inherit from - Generic when they declare a parameter list after the class's name:: - - class Mapping[KT, VT]: - def __getitem__(self, key: KT) -> VT: - ... - # Etc. - - On older versions of Python, however, generic classes have to - explicitly inherit from Generic. - - After a class has been declared to be generic, it can then be used as - follows:: - - def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: - try: - return mapping[key] - except KeyError: - return default + description: Retrieve a cohort. parameters: - name: cohort_id in: path @@ -2190,28 +2127,7 @@ paths: x-flagsmith-minimum-plan: START_UP delete: operationId: api_v1_environments_cohorts_destroy - description: |- - Abstract base class for generic types. - - On Python 3.12 and newer, generic classes implicitly inherit from - Generic when they declare a parameter list after the class's name:: - - class Mapping[KT, VT]: - def __getitem__(self, key: KT) -> VT: - ... - # Etc. - - On older versions of Python, however, generic classes have to - explicitly inherit from Generic. - - After a class has been declared to be generic, it can then be used as - follows:: - - def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT: - try: - return mapping[key] - except KeyError: - return default + description: Request cohort deletion. Memberships are drained from identity data first; the cohort and its segment are deleted once drained. parameters: - name: cohort_id in: path @@ -2225,7 +2141,7 @@ paths: schema: type: string responses: - '204': + '202': description: No response body security: - tokenAuth: [] From 18ce5c8239d08ad14e14056cec48d8b581ee8839 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 11 Aug 2026 12:55:01 +0530 Subject: [PATCH 13/18] fix(cohorts): make deletion state change and task enqueue one transaction --- api/cohorts/services.py | 23 ++++++++++--------- .../observability/_events-catalogue.md | 4 ++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/api/cohorts/services.py b/api/cohorts/services.py index e5664afc1537..a263a4ade964 100644 --- a/api/cohorts/services.py +++ b/api/cohorts/services.py @@ -108,17 +108,18 @@ def edge_sync_enabled(project: "Project") -> bool: def delete_cohort(cohort: Cohort) -> None: from cohorts.tasks import apply_cohort_membership_deltas - cohort.deletion_requested_at = timezone.now() - cohort.save(update_fields=["deletion_requested_at"]) - logger.info( - "cohort.deletion_requested", - cohort__id=cohort.id, - environment__id=cohort.environment_id, - ) - CohortMembership.objects.filter(cohort=cohort).update( - state=CohortMembershipState.PENDING_REMOVE, updated_at=timezone.now() - ) - apply_cohort_membership_deltas.delay(kwargs={"cohort_id": cohort.id}) + with transaction.atomic(): + cohort.deletion_requested_at = timezone.now() + cohort.save(update_fields=["deletion_requested_at"]) + logger.info( + "cohort.deletion_requested", + cohort__id=cohort.id, + environment__id=cohort.environment_id, + ) + CohortMembership.objects.filter(cohort=cohort).update( + state=CohortMembershipState.PENDING_REMOVE, updated_at=timezone.now() + ) + apply_cohort_membership_deltas.delay(kwargs={"cohort_id": cohort.id}) def finalise_cohort_deletion(cohort: Cohort) -> None: diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index cbdfbaa18c4a..88e843512fd5 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -86,7 +86,7 @@ Attributes: ### `cohorts.cohort.deleted` Logged at `info` from: - - `api/cohorts/services.py:129` + - `api/cohorts/services.py:130` Attributes: - `cohort.id` @@ -95,7 +95,7 @@ Attributes: ### `cohorts.cohort.deletion_requested` Logged at `info` from: - - `api/cohorts/services.py:113` + - `api/cohorts/services.py:114` Attributes: - `cohort.id` From a81436e8a09108832e85b68ef78db0a74ccb7ddd Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 11 Aug 2026 13:18:14 +0530 Subject: [PATCH 14/18] feat(segments): mark cohort-managed segments with managed_by --- api/cohorts/services.py | 8 ++- .../migrations/0031_segment_managed_by.py | 27 ++++++++++ api/segments/models.py | 13 +++++ api/segments/serializers.py | 3 +- api/tests/unit/cohorts/test_services.py | 3 +- .../unit/segments/test_unit_segments_views.py | 53 ++++++++++++++++++- .../observability/_events-catalogue.md | 8 +-- 7 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 api/segments/migrations/0031_segment_managed_by.py diff --git a/api/cohorts/services.py b/api/cohorts/services.py index a263a4ade964..3d4566362d27 100644 --- a/api/cohorts/services.py +++ b/api/cohorts/services.py @@ -11,7 +11,7 @@ from cohorts.models import Cohort, CohortMembership, CohortMembershipState from core.dataclasses import AuthorData from environments.dynamodb import DynamoIdentityWrapper -from segments.models import Condition, Segment, SegmentRule +from segments.models import Condition, Segment, SegmentManagedBy, SegmentRule from segments.services import delete_segment if typing.TYPE_CHECKING: @@ -81,7 +81,11 @@ def apply_pending_memberships(cohort: Cohort) -> bool: def create_cohort(*, environment: "Environment", name: str) -> Cohort: with transaction.atomic(): - segment = Segment.objects.create(name=name, project=environment.project) + segment = Segment.objects.create( + name=name, + project=environment.project, + managed_by=SegmentManagedBy.COHORT, + ) rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) cohort: Cohort = Cohort.objects.create(environment=environment, segment=segment) Condition.objects.create( diff --git a/api/segments/migrations/0031_segment_managed_by.py b/api/segments/migrations/0031_segment_managed_by.py new file mode 100644 index 000000000000..7e413a253566 --- /dev/null +++ b/api/segments/migrations/0031_segment_managed_by.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.16 on 2026-08-11 07:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("segments", "0030_add_default_to_segment_version"), + ] + + operations = [ + migrations.AddField( + model_name="historicalsegment", + name="managed_by", + field=models.CharField( + blank=True, choices=[("cohort", "Cohort")], default="", max_length=50 + ), + ), + migrations.AddField( + model_name="segment", + name="managed_by", + field=models.CharField( + blank=True, choices=[("cohort", "Cohort")], default="", max_length=50 + ), + ), + ] diff --git a/api/segments/models.py b/api/segments/models.py index 5d0ddeb0636f..0424147d52e8 100644 --- a/api/segments/models.py +++ b/api/segments/models.py @@ -77,6 +77,10 @@ class SegmentConditionManager(ConfiguredOrderManager["Condition"]): setting_name = "SEGMENT_CONDITIONS_EXPLICIT_ORDERING_ENABLED" +class SegmentManagedBy(models.TextChoices): + COHORT = "cohort", "Cohort" + + class Segment( LifecycleModelMixin, # type: ignore[misc] SoftDeleteExportableModel, @@ -121,6 +125,12 @@ class Segment( created_at = models.DateTimeField(null=True, auto_now_add=True) updated_at = models.DateTimeField(null=True, auto_now=True) is_system_segment = models.BooleanField(default=False) + # A managed segment is created and maintained by another feature (e.g. a + # cohort). Unlike system segments it stays visible in the API, but the + # dashboard renders it differently and cannot edit it. + managed_by = models.CharField( + max_length=50, choices=SegmentManagedBy.choices, default="", blank=True + ) objects = SegmentManager() # type: ignore[misc] @@ -158,6 +168,9 @@ def clone(self, is_revision: bool = False, **extra_attrs: typing.Any) -> "Segmen cloned_segment.uuid = uuid.uuid4() cloned_segment.version_of = None # Unset for now cloned_segment.version = 0 # Unset for now + if not is_revision: + # A copy belongs to the user, not to whatever manages the original. + cloned_segment.managed_by = "" for attr_name, value in extra_attrs.items(): setattr(cloned_segment, attr_name, value) cloned_segment.save() diff --git a/api/segments/serializers.py b/api/segments/serializers.py index 7bb06eaa0f85..eb0caee4ed92 100644 --- a/api/segments/serializers.py +++ b/api/segments/serializers.py @@ -126,8 +126,9 @@ class Meta: "rules", "metadata", "membership_counts", + "managed_by", ] - read_only_fields = ["membership_counts"] + read_only_fields = ["membership_counts", "managed_by"] def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: attrs = super().validate(attrs) diff --git a/api/tests/unit/cohorts/test_services.py b/api/tests/unit/cohorts/test_services.py index b641dec22f1a..5bf7eb51b3cf 100644 --- a/api/tests/unit/cohorts/test_services.py +++ b/api/tests/unit/cohorts/test_services.py @@ -10,7 +10,7 @@ ) from environments.dynamodb import DynamoIdentityWrapper from environments.models import Environment -from segments.models import SegmentRule +from segments.models import SegmentManagedBy, SegmentRule def test_apply_pending_memberships__no_pending_rows__returns_false( @@ -127,6 +127,7 @@ def test_create_cohort__valid_name__creates_segment_with_is_set_condition( segment = cohort.segment assert segment.name == "Beta users" assert segment.project == environment.project + assert segment.managed_by == SegmentManagedBy.COHORT rule = segment.rules.get() assert rule.type == SegmentRule.ALL_RULE condition = rule.conditions.get() diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index ab987e326c53..2ae28c733ba3 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -33,7 +33,13 @@ ) from organisations.models import Organisation from projects.models import Project -from segments.models import Condition, Segment, SegmentRule, WhitelistedSegment +from segments.models import ( + Condition, + Segment, + SegmentManagedBy, + SegmentRule, + WhitelistedSegment, +) from tests.types import WithProjectPermissionsCallable from util.mappers import map_identity_to_identity_document @@ -2014,3 +2020,48 @@ def test_delete_segment__cohort_managed__returns_403( # Then assert response.status_code == status.HTTP_403_FORBIDDEN assert Segment.objects.filter(id=segment.id).exists() + + +def test_create_segment__managed_by_in_payload__ignored( + admin_client: APIClient, + project: Project, +) -> None: + # Given + url = reverse("api-v1:projects:project-segments-list", args=[project.id]) + data = { + "name": "New segment", + "project": project.id, + "managed_by": SegmentManagedBy.COHORT, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["managed_by"] == "" + assert Segment.objects.get(id=response.json()["id"]).managed_by == "" + + +def test_clone_segment__managed_segment__clone_is_not_managed( + admin_client: APIClient, + project: Project, + segment: Segment, +) -> None: + # Given + Segment.objects.filter(id=segment.id).update(managed_by=SegmentManagedBy.COHORT) + url = reverse( + "api-v1:projects:project-segments-clone", args=[project.id, segment.id] + ) + + # When + response = admin_client.post( + url, data=json.dumps({"name": "my copy"}), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert Segment.objects.get(id=response.json()["id"]).managed_by == "" diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 88e843512fd5..2435ad94fef6 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -74,7 +74,7 @@ Attributes: ### `cohorts.cohort.created` Logged at `info` from: - - `api/cohorts/services.py:93` + - `api/cohorts/services.py:97` Attributes: - `cohort.id` @@ -86,7 +86,7 @@ Attributes: ### `cohorts.cohort.deleted` Logged at `info` from: - - `api/cohorts/services.py:130` + - `api/cohorts/services.py:134` Attributes: - `cohort.id` @@ -95,7 +95,7 @@ Attributes: ### `cohorts.cohort.deletion_requested` Logged at `info` from: - - `api/cohorts/services.py:114` + - `api/cohorts/services.py:118` Attributes: - `cohort.id` @@ -607,7 +607,7 @@ Attributes: ### `segments.serializers.segment_revision_created` Logged at `info` from: - - `api/segments/serializers.py:158` + - `api/segments/serializers.py:159` Attributes: - `revision_id` From 35eefe2558d7c940162562e5684b319f2fba1315 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Tue, 11 Aug 2026 07:50:12 +0000 Subject: [PATCH 15/18] chore: Update documentation artefacts --- mcp/src/flagsmith_mcp/openapi.json | 15 +++++++++++++++ openapi.yaml | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/mcp/src/flagsmith_mcp/openapi.json b/mcp/src/flagsmith_mcp/openapi.json index 330b2f25c097..d9baec716a50 100644 --- a/mcp/src/flagsmith_mcp/openapi.json +++ b/mcp/src/flagsmith_mcp/openapi.json @@ -5460,6 +5460,13 @@ "last_name" ] }, + "ManagedByEnum": { + "description": "* `cohort` - Cohort", + "type": "string", + "enum": [ + "cohort" + ] + }, "Metadata": { "type": "object", "properties": { @@ -6897,6 +6904,14 @@ "$ref": "#/components/schemas/SegmentMembershipCount" }, "readOnly": true + }, + "managed_by": { + "allOf": [ + { + "$ref": "#/components/schemas/ManagedByEnum" + } + ], + "readOnly": true } }, "required": [ diff --git a/openapi.yaml b/openapi.yaml index e099f3dc2a18..e52c6df2872c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -18670,6 +18670,10 @@ components: items: $ref: '#/components/schemas/SegmentMembershipCount' readOnly: true + managed_by: + allOf: + - $ref: '#/components/schemas/ManagedByEnum' + readOnly: true change_request: type: - integer @@ -21875,6 +21879,11 @@ components: $ref: '#/components/schemas/UserList' required: - user + ManagedByEnum: + description: '* `cohort` - Cohort' + type: string + enum: + - cohort MasterAPIKey: type: object properties: @@ -24746,6 +24755,10 @@ components: items: $ref: '#/components/schemas/SegmentMembershipCount' readOnly: true + managed_by: + allOf: + - $ref: '#/components/schemas/ManagedByEnum' + readOnly: true PatchedSegmentConfiguration: type: object properties: @@ -26421,6 +26434,10 @@ components: items: $ref: '#/components/schemas/SegmentMembershipCount' readOnly: true + managed_by: + allOf: + - $ref: '#/components/schemas/ManagedByEnum' + readOnly: true required: - name - project From 42e69f550ab8df74ed75b0ef4d9a27cbf95d25dc Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 11 Aug 2026 13:25:52 +0530 Subject: [PATCH 16/18] feat(segments): block cloning cohort-managed segments --- api/segments/models.py | 3 --- api/segments/views.py | 5 +++-- api/tests/unit/segments/test_unit_segments_views.py | 9 +++++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/api/segments/models.py b/api/segments/models.py index 0424147d52e8..aa771c6e1e81 100644 --- a/api/segments/models.py +++ b/api/segments/models.py @@ -168,9 +168,6 @@ def clone(self, is_revision: bool = False, **extra_attrs: typing.Any) -> "Segmen cloned_segment.uuid = uuid.uuid4() cloned_segment.version_of = None # Unset for now cloned_segment.version = 0 # Unset for now - if not is_revision: - # A copy belongs to the user, not to whatever manages the original. - cloned_segment.managed_by = "" for attr_name, value in extra_attrs.items(): setattr(cloned_segment, attr_name, value) cloned_segment.save() diff --git a/api/segments/views.py b/api/segments/views.py index ce6345e652dd..51a80de92eb7 100644 --- a/api/segments/views.py +++ b/api/segments/views.py @@ -217,11 +217,12 @@ def members(self, request: Request, *args: Any, **kwargs: Any) -> Response: def check_object_permissions(self, request: Request, obj: Segment) -> None: super().check_object_permissions(request, obj) if ( - self.action in ("update", "partial_update", "destroy") + self.action in ("update", "partial_update", "destroy", "clone") and obj.cohorts.exists() ): raise PermissionDenied( - "This segment is managed by a cohort and cannot be edited directly." + "This segment is managed by a cohort and cannot be edited " + "or cloned directly." ) def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 2ae28c733ba3..111e8f2a6d98 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -2046,13 +2046,14 @@ def test_create_segment__managed_by_in_payload__ignored( assert Segment.objects.get(id=response.json()["id"]).managed_by == "" -def test_clone_segment__managed_segment__clone_is_not_managed( +def test_clone_segment__cohort_managed__returns_403( admin_client: APIClient, project: Project, segment: Segment, + environment: Environment, ) -> None: # Given - Segment.objects.filter(id=segment.id).update(managed_by=SegmentManagedBy.COHORT) + Cohort.objects.create(environment=environment, segment=segment) url = reverse( "api-v1:projects:project-segments-clone", args=[project.id, segment.id] ) @@ -2063,5 +2064,5 @@ def test_clone_segment__managed_segment__clone_is_not_managed( ) # Then - assert response.status_code == status.HTTP_201_CREATED - assert Segment.objects.get(id=response.json()["id"]).managed_by == "" + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Segment.objects.count() == 1 From 87e1fdf8456756e9a233546afe65962eb0b43399 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 11 Aug 2026 15:06:08 +0530 Subject: [PATCH 17/18] feat(cohorts): accept optional description for the managed segment --- api/cohorts/serializers.py | 8 +++++++- api/cohorts/services.py | 8 +++++++- api/tests/unit/cohorts/test_views.py | 8 +++++++- .../observability/_events-catalogue.md | 6 +++--- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/api/cohorts/serializers.py b/api/cohorts/serializers.py index fc311c817349..780216bb9f4a 100644 --- a/api/cohorts/serializers.py +++ b/api/cohorts/serializers.py @@ -8,6 +8,9 @@ class CohortSerializer(serializers.ModelSerializer[Cohort]): name = serializers.CharField(max_length=2000, source="segment.name") + description = serializers.CharField( + source="segment.description", required=False, allow_null=True + ) class Meta: model = Cohort @@ -15,6 +18,7 @@ class Meta: "id", "uuid", "name", + "description", "segment", "source_type", "version", @@ -23,7 +27,9 @@ class Meta: read_only_fields = ("segment", "source_type", "version", "created_at") def create(self, validated_data: dict[str, typing.Any]) -> Cohort: + segment_data = validated_data["segment"] return create_cohort( environment=validated_data["environment"], - name=validated_data["segment"]["name"], + name=segment_data["name"], + description=segment_data.get("description"), ) diff --git a/api/cohorts/services.py b/api/cohorts/services.py index 3d4566362d27..c2ba9f3bd111 100644 --- a/api/cohorts/services.py +++ b/api/cohorts/services.py @@ -79,11 +79,17 @@ def apply_pending_memberships(cohort: Cohort) -> bool: return pending_memberships(cohort).exists() -def create_cohort(*, environment: "Environment", name: str) -> Cohort: +def create_cohort( + *, + environment: "Environment", + name: str, + description: str | None = None, +) -> Cohort: with transaction.atomic(): segment = Segment.objects.create( name=name, project=environment.project, + description=description, managed_by=SegmentManagedBy.COHORT, ) rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) diff --git a/api/tests/unit/cohorts/test_views.py b/api/tests/unit/cohorts/test_views.py index 74eb5b2106b2..fb56c55d64ae 100644 --- a/api/tests/unit/cohorts/test_views.py +++ b/api/tests/unit/cohorts/test_views.py @@ -36,12 +36,18 @@ def test_create_cohort__staff_with_manage_segments__returns_201( ) # When - response = staff_client.post(url, data={"name": "Beta users"}, format="json") + response = staff_client.post( + url, + data={"name": "Beta users", "description": "Early access group"}, + format="json", + ) # Then assert response.status_code == status.HTTP_201_CREATED cohort = Cohort.objects.get(id=response.json()["id"]) assert response.json()["name"] == "Beta users" + assert response.json()["description"] == "Early access group" + assert cohort.segment.description == "Early access group" assert response.json()["segment"] == cohort.segment_id assert cohort.environment == dynamo_enabled_project_environment_one diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 2435ad94fef6..f1dc15ec4238 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -74,7 +74,7 @@ Attributes: ### `cohorts.cohort.created` Logged at `info` from: - - `api/cohorts/services.py:97` + - `api/cohorts/services.py:103` Attributes: - `cohort.id` @@ -86,7 +86,7 @@ Attributes: ### `cohorts.cohort.deleted` Logged at `info` from: - - `api/cohorts/services.py:134` + - `api/cohorts/services.py:140` Attributes: - `cohort.id` @@ -95,7 +95,7 @@ Attributes: ### `cohorts.cohort.deletion_requested` Logged at `info` from: - - `api/cohorts/services.py:118` + - `api/cohorts/services.py:124` Attributes: - `cohort.id` From faa346b3b982e0c6073460778bf344d9dfc4eec5 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Tue, 11 Aug 2026 09:38:15 +0000 Subject: [PATCH 18/18] chore: Update documentation artefacts --- openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index e52c6df2872c..18c5b9007d3d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -18810,6 +18810,10 @@ components: name: type: string maxLength: 2000 + description: + type: + - string + - 'null' segment: type: integer readOnly: true