-
Notifications
You must be signed in to change notification settings - Fork 557
feat(__future__): Experimental update-flag #8102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
5c2b471
first the dream
emyller 47f53c9
then the contract
emyller 98593e0
get priorities straight
emyller e111394
tried so hard, got so far
emyller 51740b8
percentage rules
emyller 4b2328a
dang it Venn
emyller cfe48fa
未来を見て!
emyller 81077d9
now i see the full picture
emyller b964909
🚫🐬
emyller c1ebb0e
clarity
emyller 4889df1
who needs tests
emyller 2278900
ah the happy path
emyller 3209f05
be conservative
emyller 9fbed00
responses!
emyller 0399237
deny
emyller 2d87c6f
it may vary
emyller 96b4b7d
expect telemetry
emyller 1a136cb
Merge remote-tracking branch 'github/main' into feat/update-flag-opti…
emyller f4bddec
IT'S GREEN
emyller eb1a497
even more clarity
emyller 14c3324
the laws of relativity don't work here
emyller 348f5a9
chore: Update documentation artefacts
flagsmith-engineering[bot] f74048f
fix docs
emyller e0a0f0a
fail safely
emyller 89b7b85
almost ignorable edge case
emyller ee2c22f
yes it works
emyller e49f757
DRY
emyller 9ebb0c3
works for both auth types
emyller dfe720d
py3.11 is still here
emyller 36b9208
fix docs link
emyller ce77df3
fix contradiction
emyller 2f070b6
get priorities straight (again)
emyller 7da1e6b
fix flag state inheritance
emyller dfc9da6
chore: Update documentation artefacts
flagsmith-engineering[bot] 1304243
why not a get
emyller 6531905
better name now
emyller 05b52cf
chore: Update documentation artefacts
flagsmith-engineering[bot] 9d640b5
no --allow-empty here
emyller b2efd4e
better error
emyller 27dcc94
this aint the korok forest
emyller File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| """ | ||
| Experimental API endpoints intended to become canonical. | ||
|
|
||
| These endpoints are subject to change and should not be considered stable. | ||
| Use at your own risk - breaking changes may occur without prior notice. | ||
| """ | ||
|
|
||
| from django.urls import path | ||
|
|
||
| from features.future.views import FlagAPIView | ||
|
|
||
| app_name = "future" | ||
|
|
||
| urlpatterns = [ | ||
| path( | ||
| "environments/<str:environment_key>/features/<int:feature_id>/", | ||
| FlagAPIView.as_view(), | ||
| name="flag", | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| class FeatureValueError(ValueError): | ||
| """Raised when a feature value is invalid.""" | ||
|
|
||
| pass |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| """https://docs.flagsmith.com/managing-flags/updating-flags""" | ||
|
|
||
| from rest_framework import status | ||
| from rest_framework.exceptions import APIException | ||
|
|
||
|
|
||
| class ChangeRequestsEnabledError(APIException): | ||
| """Raised where a flag can only be changed by going through a change request.""" | ||
|
|
||
| status_code = status.HTTP_409_CONFLICT | ||
| default_code = "change_requests_enabled" | ||
| default_detail = ( | ||
| "Cannot update flags in an environment with change requests enabled." | ||
| ) | ||
|
|
||
| def __init__(self) -> None: | ||
| # DRF's default exception handler renders `detail` alone. | ||
| super().__init__({"detail": self.default_detail, "code": self.default_code}) | ||
|
|
||
|
|
||
| class DuplicatePriorityError(APIException): | ||
| """Raised where a flag's segment overrides would end up sharing a priority.""" | ||
|
|
||
| status_code = status.HTTP_400_BAD_REQUEST | ||
| default_detail = "Segment overrides must not share a priority." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| """https://docs.flagsmith.com/managing-flags/updating-flags""" | ||
|
|
||
| from features.feature_states.models import API_VALUE_TYPES | ||
| from features.future.types import ( | ||
| EnvironmentDefaultResponse, | ||
| FlagValue, | ||
| SegmentOverrideResponse, | ||
| SegmentReference, | ||
| Variant, | ||
| ) | ||
| from features.models import FeatureState, FeatureStateValue | ||
|
|
||
|
|
||
| def map_flag_value(feature_state_value: FeatureStateValue) -> FlagValue | None: | ||
| """Render a stored value as a typed value object, always with a string value.""" | ||
| value = feature_state_value.value | ||
| if value is None: | ||
| return None | ||
| return FlagValue( | ||
| type=API_VALUE_TYPES.get(feature_state_value.type, "string"), # type: ignore[arg-type] | ||
| value=("true" if value else "false") if isinstance(value, bool) else str(value), | ||
| ) | ||
|
|
||
|
|
||
| def map_variants(feature_state: FeatureState) -> list[Variant]: | ||
| """List a feature state's variant weights, ordered by variant.""" | ||
| return [ | ||
| Variant( | ||
| id=multivariate_value.multivariate_feature_option_id, | ||
| weight=multivariate_value.percentage_allocation, | ||
| ) | ||
| for multivariate_value in sorted( | ||
| feature_state.multivariate_feature_state_values.all(), | ||
| key=lambda multivariate_value: ( | ||
| multivariate_value.multivariate_feature_option_id | ||
| ), | ||
| ) | ||
| ] | ||
|
|
||
|
|
||
| def map_environment_default(feature_state: FeatureState) -> EnvironmentDefaultResponse: | ||
| """Render a feature state as the flag's default for its environment.""" | ||
| return EnvironmentDefaultResponse( | ||
| enabled=feature_state.enabled, | ||
| value=map_flag_value(feature_state.feature_state_value), | ||
| variants=map_variants(feature_state), | ||
| ) | ||
|
|
||
|
|
||
| def map_segment_override( | ||
| feature_state: FeatureState, | ||
| ) -> SegmentOverrideResponse | None: | ||
| """Render a feature state as one of the flag's segment overrides.""" | ||
| feature_segment = feature_state.feature_segment | ||
| if feature_segment is None: | ||
| return None | ||
| return SegmentOverrideResponse( | ||
| segment=SegmentReference(id=feature_segment.segment_id), | ||
| priority=feature_segment.priority, | ||
| enabled=feature_state.enabled, | ||
| value=map_flag_value(feature_state.feature_state_value), | ||
| variants=map_variants(feature_state), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| """https://docs.flagsmith.com/managing-flags/updating-flags""" | ||
|
|
||
| from collections.abc import Mapping | ||
|
|
||
| from common.environments.permissions import ( | ||
| MANAGE_SEGMENT_OVERRIDES, | ||
| UPDATE_FEATURE_STATE, | ||
| VIEW_ENVIRONMENT, | ||
| ) | ||
| from rest_framework.exceptions import NotFound, PermissionDenied | ||
|
|
||
| from environments.models import Environment | ||
| from users.abc import UserABC | ||
|
|
||
| PROPERTY_PERMISSIONS = { | ||
| "environment_default": UPDATE_FEATURE_STATE, | ||
| "segment_overrides": MANAGE_SEGMENT_OVERRIDES, | ||
| } | ||
|
|
||
| READ_PERMISSIONS = [VIEW_ENVIRONMENT, *PROPERTY_PERMISSIONS.values()] | ||
|
|
||
|
|
||
| def check_read_permissions(user: UserABC, environment: Environment) -> None: | ||
| """Authorise a caller to read a flag. | ||
|
|
||
| A caller who may neither view nor write the environment is not told it exists. | ||
| """ | ||
| if not any( | ||
| user.has_environment_permission(permission, environment) | ||
| for permission in READ_PERMISSIONS | ||
| ): | ||
| raise NotFound() | ||
|
|
||
|
|
||
| def check_update_permissions( | ||
| user: UserABC, environment: Environment, properties: Mapping[str, object] | ||
| ) -> None: | ||
| """Authorise a caller to write the flag properties they sent. | ||
|
|
||
| A caller who may write no property at all is not told the environment exists. | ||
| """ | ||
| denied = { | ||
| property_name | ||
| for property_name, permission in PROPERTY_PERMISSIONS.items() | ||
| if not user.has_environment_permission(permission, environment) | ||
| } | ||
| if len(denied) == len(PROPERTY_PERMISSIONS): | ||
| raise NotFound() | ||
| if any(property_name in properties for property_name in denied): | ||
| raise PermissionDenied() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| """https://docs.flagsmith.com/managing-flags/updating-flags""" | ||
|
|
||
| from typing import get_args | ||
|
|
||
| from rest_framework import serializers | ||
|
|
||
| from features.feature_states.exceptions import FeatureValueError | ||
| from features.feature_states.models import FeatureValueType | ||
| from features.feature_types import MULTIVARIATE | ||
| from features.future.types import ( | ||
| EnvironmentDefaultRequest, | ||
| FlagValue, | ||
| SegmentOverrideRequest, | ||
| UpdateFlagRequest, | ||
| Variant, | ||
| ) | ||
| from features.models import Feature, FeatureStateValue | ||
| from segments.models import Segment | ||
|
|
||
|
|
||
| class FlagValueSerializer(serializers.Serializer[FlagValue]): | ||
| """A flag value, typed by the caller so it survives the round trip as a string.""" | ||
|
|
||
| type = serializers.ChoiceField(choices=get_args(FeatureValueType)) | ||
| value = serializers.CharField(allow_blank=True) | ||
|
khvn26 marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class VariantSerializer(serializers.Serializer[Variant]): | ||
| """The share of an environment or segment a multivariate variant is served to.""" | ||
|
|
||
| id = serializers.IntegerField() | ||
| weight = serializers.FloatField(min_value=0, max_value=100) | ||
|
|
||
|
|
||
| class SegmentReferenceSerializer(serializers.Serializer[Segment]): | ||
| """A segment of the feature's project, referenced by an override.""" | ||
|
|
||
| id = serializers.IntegerField() | ||
|
|
||
| def validate_id(self, id: int) -> int: | ||
| feature: Feature = self.context["feature"] | ||
| if not Segment.live_objects.filter( | ||
| id=id, project_id=feature.project_id | ||
| ).exists(): | ||
| raise serializers.ValidationError("Segment not found.") | ||
| return id | ||
|
|
||
|
|
||
| class FlagStateSerializer(serializers.Serializer[dict[str, object]]): | ||
| """What a flag serves somewhere in an environment.""" | ||
|
|
||
| enabled = serializers.BooleanField(required=False) | ||
| value = FlagValueSerializer(required=False) | ||
| variants = VariantSerializer(many=True, required=False) | ||
|
|
||
| def validate_value(self, value: FlagValue) -> FlagValue: | ||
| try: | ||
| FeatureStateValue().set_value(value["value"], value["type"]) | ||
| except FeatureValueError as error: | ||
| raise serializers.ValidationError(str(error)) from error | ||
|
emyller marked this conversation as resolved.
Dismissed
coderabbitai[bot] marked this conversation as resolved.
|
||
| return value | ||
|
|
||
| def validate_variants(self, variants: list[Variant]) -> list[Variant]: | ||
| feature: Feature = self.context["feature"] | ||
| if feature.type != MULTIVARIATE: | ||
| raise serializers.ValidationError("Feature is not multivariate.") | ||
| known_ids = set(feature.multivariate_options.values_list("id", flat=True)) | ||
| given_ids = {variant["id"] for variant in variants} | ||
| if given_ids - known_ids: | ||
| raise serializers.ValidationError("Variant not found.") | ||
| if known_ids - given_ids: | ||
| raise serializers.ValidationError("Must include all feature's variants.") | ||
| if sum(variant["weight"] for variant in variants) > 100: | ||
| raise serializers.ValidationError("Total weight must not exceed 100.") | ||
| return variants | ||
|
|
||
|
|
||
| class EnvironmentDefaultSerializer(FlagStateSerializer): | ||
| """What the flag serves to everyone the segment overrides do not match.""" | ||
|
|
||
| def validate(self, attrs: EnvironmentDefaultRequest) -> EnvironmentDefaultRequest: | ||
| feature: Feature = self.context["feature"] | ||
| if ( | ||
| self.context["replace"] | ||
| and feature.type == MULTIVARIATE | ||
| and "variants" not in attrs | ||
| ): | ||
| raise serializers.ValidationError( | ||
| {"variants": ["Must include all feature's variants."]} | ||
| ) | ||
| return attrs | ||
|
|
||
|
|
||
| class SegmentOverrideSerializer(FlagStateSerializer): | ||
| """What the flag serves to the identities a segment matches.""" | ||
|
|
||
| segment = SegmentReferenceSerializer() | ||
| priority = serializers.IntegerField(min_value=0, required=False) | ||
|
khvn26 marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class UpdateFlagSerializer(serializers.Serializer[UpdateFlagRequest]): | ||
| """The parts of a flag a caller wants to write in one request.""" | ||
|
|
||
| environment_default = EnvironmentDefaultSerializer(required=False) | ||
| segment_overrides = SegmentOverrideSerializer(many=True, required=False) | ||
|
|
||
| def validate_segment_overrides( | ||
| self, segment_overrides: list[SegmentOverrideRequest] | ||
| ) -> list[SegmentOverrideRequest]: | ||
| seen: set[int] = set() | ||
| for override in segment_overrides: | ||
| segment_id = override["segment"]["id"] | ||
| if segment_id in seen: | ||
| raise serializers.ValidationError(f"Duplicate segment: {segment_id}.") | ||
| seen.add(segment_id) | ||
| return segment_overrides | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.