Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion api/environments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
EdgeIdentityWithIdentifierFeatureStateView,
get_edge_identity_overrides,
)
from features.dependencies.views import FeatureDependencyAPIView
from features.dependencies.views import (
FeatureDependenciesAPIView,
FeatureDependencyAPIView,
FeatureDependentsAPIView,
)
from features.views import (
EnvironmentFeatureStateViewSet,
IdentityFeatureStateViewSet,
Expand Down Expand Up @@ -170,6 +174,16 @@
FeatureDependencyAPIView.as_view(),
name="feature-dependency",
),
path(
"<str:environment_api_key>/features/<int:feature_id>/dependencies/",
FeatureDependenciesAPIView.as_view(),
name="feature-dependencies",
),
path(
"<str:environment_api_key>/features/<int:feature_id>/dependents/",
FeatureDependentsAPIView.as_view(),
name="feature-dependents",
),
path(
"<str:environment_api_key>/edge-identity-overrides",
get_edge_identity_overrides,
Expand Down
14 changes: 14 additions & 0 deletions api/features/dependencies/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ def get_message(self, path: DependencyPath) -> str:
)


class EnvironmentNotFoundError(NotFound):
"""Raised where an environment key does not exist."""

default_code = "environment_not_found"

def __init__(self, environment_api_key: str) -> None:
super().__init__(
{
"code": self.default_code,
"message": f"Environment key '{environment_api_key}' does not exist.",
}
)


class FeatureNotFoundError(NotFound):
"""Raised where a feature ID is not in the environment's project."""

Expand Down
78 changes: 51 additions & 27 deletions api/features/dependencies/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import structlog
from django.db import transaction
from django.db.models import QuerySet
from flag_engine.segments import constants
from ordered_model.models import OrderedModelQuerySet # type: ignore[import-untyped]

Expand All @@ -27,16 +28,14 @@
from features.dependencies.models import SegmentFlagReference
from features.dependencies.types import (
DependencyEdge,
DependencyList,
DependencyPath,
FeatureName,
ReferencingEnvironment,
)
from features.models import Feature, FeatureSegment
from segments.models import Segment
from segments.services import (
get_all_live_or_scheduled_overrides,
write_segment_rules,
)
from segments.services import get_live_overrides, write_segment_rules
from segments.types import SegmentCondition, SegmentRule
from users.models import FFAdminUser

Expand Down Expand Up @@ -132,13 +131,15 @@ def validate_segment_flag_dependencies(segment: "Segment") -> None:
return
edges_by_environment_id: dict[int, dict[FeatureName, list[DependencyEdge]]] = {}
for override in (
get_all_live_or_scheduled_overrides()
get_live_overrides(include_scheduled=True)
.filter(segment=segment)
.select_related("environment", "feature")
):
if override.environment_id not in edges_by_environment_id:
edges_by_environment_id[override.environment_id] = _get_dependency_edges(
override.environment
get_live_overrides(include_scheduled=True).filter(
environment=override.environment
)
)
edges = edges_by_environment_id[override.environment_id]
pending: list[DependencyPath] = [
Expand Down Expand Up @@ -172,7 +173,7 @@ def validate_segment_flag_dependencies(segment: "Segment") -> None:


def _get_dependency_edges(
environment: Environment,
overrides: "QuerySet[FeatureSegment]",
) -> dict[FeatureName, list[DependencyEdge]]:
edges: dict[FeatureName, list[DependencyEdge]] = defaultdict(list)
for (
Expand All @@ -185,20 +186,16 @@ def _get_dependency_edges(
segment_rules,
condition_json_path,
is_system_segment,
) in (
get_all_live_or_scheduled_overrides()
.filter(environment=environment, segment__flag_references__isnull=False)
.values_list(
"feature__id",
"feature__name",
"segment__flag_references__prerequisite_feature__id",
"segment__flag_references__prerequisite_feature__name",
"segment__id",
"segment__name",
"segment__rules_data",
"segment__flag_references__condition_json_path",
"segment__is_system_segment",
)
) in overrides.filter(segment__flag_references__isnull=False).values_list(
"feature__id",
"feature__name",
"segment__flag_references__prerequisite_feature__id",
"segment__flag_references__prerequisite_feature__name",
"segment__id",
"segment__name",
"segment__rules_data",
"segment__flag_references__condition_json_path",
"segment__is_system_segment",
):
assert segment_rules is not None
edges[feature_name].append(
Expand All @@ -220,6 +217,33 @@ def _get_dependency_edges(
return edges


def list_flag_dependencies(
*,
environment: Environment,
feature: Feature,
) -> DependencyList:
"""List the features the feature depends on in the environment."""
edges = _get_dependency_edges(get_live_overrides().filter(environment=environment))
return {"results": edges[feature.name]}


def list_flag_dependents(
*,
environment: Environment,
feature: Feature,
) -> DependencyList:
"""List the features depending on the feature in the environment."""
edges = _get_dependency_edges(get_live_overrides().filter(environment=environment))
return {
"results": [
edge
for feature_edges in edges.values()
for edge in feature_edges
if edge["prerequisite"]["id"] == feature.id
]
}


def create_flag_dependency(
*,
environment: Environment,
Expand Down Expand Up @@ -254,7 +278,9 @@ def create_flag_dependency(
}
segment_name = f"{feature.name}-dependencies-{environment.api_key}"
with transaction.atomic():
edges = _get_dependency_edges(environment)
edges = _get_dependency_edges(
get_live_overrides(include_scheduled=True).filter(environment=environment)
)
if existing_edges := [
edge
for edge in edges[feature.name]
Expand Down Expand Up @@ -298,11 +324,9 @@ def create_flag_dependency(
)
write_segment_rules(segment, rules)
index_segment_flag_references(segment)
overrides: OrderedModelQuerySet = (
get_all_live_or_scheduled_overrides().filter(
environment=environment, feature=feature
)
)
overrides: OrderedModelQuerySet = get_live_overrides(
include_scheduled=True
).filter(environment=environment, feature=feature)
update_flag(
environment=environment,
feature=feature,
Expand Down
6 changes: 6 additions & 0 deletions api/features/dependencies/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ class DependencyEdge(TypedDict):


DependencyPath = list[DependencyEdge]


class DependencyList(TypedDict):
"""The live dependencies a feature takes part in within an environment."""

results: list[DependencyEdge]
64 changes: 60 additions & 4 deletions api/features/dependencies/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
Expand All @@ -10,14 +9,27 @@
from features.dependencies.exceptions import (
DependencyConflictDetail,
DependencyErrorDetail,
EnvironmentNotFoundError,
FeatureNotFoundError,
)
from features.dependencies.permissions import check_manage_permissions
from features.dependencies.services import create_flag_dependency
from features.dependencies.types import DependencyEdge
from features.dependencies.services import (
create_flag_dependency,
list_flag_dependencies,
list_flag_dependents,
)
from features.dependencies.types import DependencyEdge, DependencyList
from features.future.permissions import check_read_permissions
from features.models import Feature


def _get_environment(environment_api_key: str) -> Environment:
try:
return Environment.objects.get(api_key=environment_api_key) # type: ignore[no-any-return]
except Environment.DoesNotExist:
raise EnvironmentNotFoundError(environment_api_key) from None


def _get_feature(environment: Environment, feature_id: int) -> Feature:
try:
return Feature.objects.get( # type: ignore[no-any-return]
Expand Down Expand Up @@ -51,7 +63,7 @@ def post(
feature_id: int,
prerequisite_feature_id: int,
) -> Response:
environment = get_object_or_404(Environment, api_key=environment_api_key)
environment = _get_environment(environment_api_key)
check_manage_permissions(request.user, environment)
feature = _get_feature(environment, feature_id)
prerequisite_feature = _get_feature(environment, prerequisite_feature_id)
Expand All @@ -64,3 +76,47 @@ def post(
),
status=status.HTTP_201_CREATED,
)


class FeatureDependenciesAPIView(APIView):
"""List the features a feature depends on in an environment."""

permission_classes = [IsAuthenticated]

@extend_schema(
responses={200: DependencyList, 404: DependencyErrorDetail},
description="List the features the feature depends on in the environment.",
)
def get(
self,
request: AuthenticatedRequest,
environment_api_key: str,
feature_id: int,
) -> Response:
environment = _get_environment(environment_api_key)
check_read_permissions(request.user, environment)
feature = _get_feature(environment, feature_id)
return Response(
list_flag_dependencies(environment=environment, feature=feature)
)


class FeatureDependentsAPIView(APIView):
"""List the features depending on a feature in an environment."""

permission_classes = [IsAuthenticated]

@extend_schema(
responses={200: DependencyList, 404: DependencyErrorDetail},
description="List the features depending on the feature in the environment.",
)
def get(
self,
request: AuthenticatedRequest,
environment_api_key: str,
feature_id: int,
) -> Response:
environment = _get_environment(environment_api_key)
check_read_permissions(request.user, environment)
feature = _get_feature(environment, feature_id)
return Response(list_flag_dependents(environment=environment, feature=feature))
6 changes: 4 additions & 2 deletions api/segments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from segment_membership.models import SegmentMembershipCount
from segment_membership.services import enqueue_membership_refresh
from segments.models import Condition, Segment, SegmentRule, WhitelistedSegment
from segments.services import get_all_live_or_scheduled_overrides
from segments.services import get_live_overrides
from segments.types import (
LegacySegmentRule,
)
Expand Down Expand Up @@ -195,7 +195,9 @@ def get_has_overrides(self, segment: Segment) -> bool:
# is serialized outside that queryset.
if (has_overrides := getattr(segment, "has_overrides", None)) is not None:
return bool(has_overrides)
return get_all_live_or_scheduled_overrides().filter(segment=segment).exists()
return (
get_live_overrides(include_scheduled=True).filter(segment=segment).exists()
)

def to_internal_value(self, data: dict[str, Any]) -> Any:
self._validate_rules_depth(data.get("rules", []))
Expand Down
19 changes: 15 additions & 4 deletions api/segments/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@
from segments.models import Segment, SegmentRule


def get_all_live_or_scheduled_overrides() -> "QuerySet[FeatureSegment]":
"""Get the feature overrides that are live now or scheduled to go live."""
def get_live_overrides(
*, include_scheduled: bool = False
) -> "QuerySet[FeatureSegment]":
"""Get the feature overrides that are live now, or also scheduled to go live."""
no_change_request = models.Q(change_request__isnull=True)
committed_change_request = models.Q(change_request__committed_at__isnull=False)
with_feature_versioning_v1 = models.Q(
environment__use_v2_feature_versioning=False,
) & (no_change_request | committed_change_request)
if not include_scheduled:
with_feature_versioning_v1 &= models.Q(live_from__lte=timezone.now())

superseding_versions = EnvironmentFeatureVersion.objects.filter(
environment_id=models.OuterRef("environment_id"),
Expand All @@ -34,10 +38,17 @@ def get_all_live_or_scheduled_overrides() -> "QuerySet[FeatureSegment]":
)
# Filtering on not superseded is the same as filtering on the latest
# live EFV but uses the index on feature, environment.
with_feature_versioning_v2 = models.Q(
published_v2_versions = models.Q(
environment__use_v2_feature_versioning=True,
environment_feature_version__published_at__isnull=False,
) & ~models.Exists(superseding_versions)
)
if not include_scheduled:
published_v2_versions &= models.Q(
environment_feature_version__live_from__lte=timezone.now(),
)
with_feature_versioning_v2 = published_v2_versions & ~models.Exists(
superseding_versions
)

live_or_scheduled_feature_states = FeatureState.objects.filter(
with_feature_versioning_v1 | with_feature_versioning_v2,
Expand Down
10 changes: 7 additions & 3 deletions api/segments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
SegmentMembersResponseSerializer,
SegmentSerializer,
)
from .services import delete_segment, get_all_live_or_scheduled_overrides
from .services import delete_segment, get_live_overrides

if TYPE_CHECKING:
from users.models import FFAdminUser
Expand Down Expand Up @@ -106,7 +106,7 @@ def get_queryset(self): # type: ignore[no-untyped-def]
project=project, is_system_segment=False
).annotate(
has_overrides=models.Exists(
get_all_live_or_scheduled_overrides().filter(
get_live_overrides(include_scheduled=True).filter(
segment_id=models.OuterRef("pk")
)
)
Expand Down Expand Up @@ -270,7 +270,11 @@ def _check_segment_is_deletable(self, segment: Segment) -> None:
"""
if not segment.project.is_workflow_enabled:
return
if not get_all_live_or_scheduled_overrides().filter(segment=segment).exists():
if (
not get_live_overrides(include_scheduled=True)
.filter(segment=segment)
.exists()
):
return
api_error = ChangeRequestsEnabledError(
"Cannot delete a segment with feature overrides in a project with "
Expand Down
Loading
Loading