Skip to content

Commit

Permalink
Merge branch 'staging' into 'master'
Browse files Browse the repository at this point in the history
Release v2.2.0

See merge request bullet-train/bullet-train-api!132
  • Loading branch information
matthewelwell committed Aug 13, 2020
2 parents 843e2af + 9cd3eeb commit d8f73a2
Show file tree
Hide file tree
Showing 37 changed files with 1,181 additions and 625 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ checkstyle.txt
.env
.direnv
.envrc
.elasticbeanstalk/
5 changes: 2 additions & 3 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pylint = "*"
"autopep8" = "*"
pytest = "*"
pytest-django = "*"
django-test-migrations = "*"

[packages]
appdirs = "*"
Expand All @@ -33,9 +34,7 @@ sendgrid-django = "*"
psycopg2-binary = "*"
coreapi = "*"
Django = "<3.0"
numpy = "*"
django-simple-history = "*"
twisted = {version = "*",extras = ["tls"]}
django-debug-toolbar = "*"
google-api-python-client = "*"
"oauth2client" = "*"
Expand All @@ -46,8 +45,8 @@ chargebee = "*"
python-http-client = "<3.2.0" # 3.2.0 is the latest but throws an error on installation saying that it's not found
django-health-check = "*"
django-storages = "*"
boto3 = "*"
django-environ = "*"
django-trench = "*"
djoser = "*"
influxdb-client = "*"
django-ordered-model = "*"
573 changes: 191 additions & 382 deletions Pipfile.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ The application relies on the following environment variables to run:
* `INFLUXDB_URL`: The URL for your InfluxDB database
* `INFLUXDB_ORG`: The organisation string for your InfluxDB API call.
* `GA_TABLE_ID`: GA table ID (view) to query when looking for organisation usage
* `USE_S3_STORAGE`: 'True' to store static files in s3
* `AWS_STORAGE_BUCKET_NAME`: bucket name to store static files. Required if `USE_S3_STORAGE' is true.
* `AWS_S3_REGION_NAME`: region name of the static files bucket. Defaults to eu-west-2.
* `ALLOWED_ADMIN_IP_ADDRESSES`: restrict access to the django admin console to a comma separated list of IP addresses (e.g. `127.0.0.1,127.0.0.2`)
Expand Down
11 changes: 3 additions & 8 deletions src/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@
# health check plugins
'health_check',
'health_check.db',

# Used for ordering models (e.g. FeatureSegment)
'ordered_model',
]

if GOOGLE_ANALYTICS_KEY or INFLUXDB_TOKEN:
Expand Down Expand Up @@ -326,14 +329,6 @@
}
}

if env.bool('USE_S3_STORAGE', default=False):
STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
AWS_STORAGE_BUCKET_NAME = os.environ['AWS_STORAGE_BUCKET_NAME']
AWS_S3_REGION_NAME = os.environ.get('AWS_S3_REGION_NAME', 'eu-west-2')
AWS_LOCATION = 'static'
AWS_DEFAULT_ACL = 'public-read'
AWS_S3_ADDRESSING_STYLE = 'virtual'

LOG_LEVEL = env.str('LOG_LEVEL', 'WARNING')

TRENCH_AUTH = {
Expand Down
2 changes: 1 addition & 1 deletion src/app/settings/master.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@
REST_FRAMEWORK['PAGE_SIZE'] = 999

SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [r'^/$', r'^$'] # root is exempt as it's used for EB health checks
SECURE_REDIRECT_EXEMPT = [r'^health$'] # /health is exempt as it's used for EB health checks
2 changes: 1 addition & 1 deletion src/app/settings/staging.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@
REST_FRAMEWORK['PAGE_SIZE'] = 999

SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [r'^/$', r'^$'] # root is exempt as it's used for EB health checks
SECURE_REDIRECT_EXEMPT = [r'^health$'] # /health is exempt as it's used for EB health checks
14 changes: 13 additions & 1 deletion src/audit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
FEATURE_UPDATED_MESSAGE = "Flag / Remote Config updated: %s"
SEGMENT_CREATED_MESSAGE = "New Segment created: %s"
SEGMENT_UPDATED_MESSAGE = "Segment updated: %s"
FEATURE_SEGMENT_UPDATED_MESSAGE = "Segment rules updated for flag: %s"
FEATURE_SEGMENT_UPDATED_MESSAGE = "Segment rules updated for flag: %s in environment: %s"
ENVIRONMENT_CREATED_MESSAGE = "New Environment created: %s"
ENVIRONMENT_UPDATED_MESSAGE = "Environment updated: %s"
FEATURE_STATE_UPDATED_MESSAGE = "Flag state / Remote Config value updated for feature: %s"
Expand Down Expand Up @@ -45,3 +45,15 @@ class Meta:

def __str__(self):
return "Audit Log %s" % self.id

@classmethod
def create_record(cls, obj, obj_type, log_message, author, project=None, environment=None):
cls.objects.create(
related_object_id=obj.id,
related_object_type=obj_type.name,
log=log_message,
author=author,
project=project,
environment=environment
)

4 changes: 2 additions & 2 deletions src/audit/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

from audit.models import AuditLog
from audit.serializers import AuditLogSerializer
from util.logging import get_logger
from webhooks.webhooks import call_organisation_webhooks, WebhookEventType

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logger = get_logger(__name__)


@receiver(post_save, sender=AuditLog)
Expand Down
6 changes: 5 additions & 1 deletion src/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ def get_all_feature_states(self):
# define sub queries
belongs_to_environment_query = Q(environment=self.environment)
overridden_for_identity_query = Q(identity=self)
overridden_for_segment_query = Q(feature_segment__segment__in=segments)
overridden_for_segment_query = Q(
feature_segment__segment__in=segments, feature_segment__environment=self.environment
)
environment_default_query = Q(identity=None, feature_segment=None)

# define the full query
Expand All @@ -135,6 +137,8 @@ def get_all_feature_states(self):

all_flags = FeatureState.objects.select_related(*select_related_args).filter(full_query)

# iterate over all the flags and build a dictionary keyed on feature with the highest priority flag
# for the given identity as the value.
identity_flags = {}
for flag in all_flags:
if flag.feature_id not in identity_flags:
Expand Down
53 changes: 44 additions & 9 deletions src/environments/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from features.utils import INTEGER, STRING, BOOLEAN
from organisations.models import Organisation
from projects.models import Project
from segments.models import Segment, SegmentRule, Condition, EQUAL, GREATER_THAN_INCLUSIVE
from segments.models import Segment, SegmentRule, Condition, EQUAL, GREATER_THAN_INCLUSIVE, GREATER_THAN
from util.tests import Helper


Expand Down Expand Up @@ -190,9 +190,9 @@ def test_get_all_feature_states_for_identity_returns_correct_values_for_matching
remote_config = Feature.objects.create(name='test-remote-config', project=self.project,
initial_value='initial-value', type='CONFIG')

FeatureSegment.objects.create(feature=feature_flag, segment=segment, enabled=True)
FeatureSegment.objects.create(feature=feature_flag, segment=segment, environment=self.environment, enabled=True)
overridden_value = 'overridden-value'
FeatureSegment.objects.create(feature=remote_config, segment=segment,
FeatureSegment.objects.create(feature=remote_config, segment=segment, environment=self.environment,
value=overridden_value, value_type=STRING)

# When
Expand Down Expand Up @@ -221,9 +221,9 @@ def test_get_all_feature_states_for_identity_returns_correct_values_for_identity
remote_config = Feature.objects.create(name='test-remote-config', project=self.project,
initial_value=initial_value, type='CONFIG')

FeatureSegment.objects.create(feature=feature_flag, segment=segment, enabled=True)
FeatureSegment.objects.create(feature=feature_flag, segment=segment, environment=self.environment, enabled=True)
overridden_value = 'overridden-value'
FeatureSegment.objects.create(feature=remote_config, segment=segment,
FeatureSegment.objects.create(feature=remote_config, segment=segment, environment=self.environment,
value=overridden_value, value_type=STRING)

# When
Expand Down Expand Up @@ -252,7 +252,7 @@ def test_get_all_feature_states_for_identity_returns_correct_value_for_matching_
# Feature segment value is converted to string in the serializer so we set as a string value here to test
# bool value
overridden_value = '12'
FeatureSegment.objects.create(feature=remote_config, segment=segment,
FeatureSegment.objects.create(feature=remote_config, segment=segment, environment=self.environment,
value=overridden_value, value_type=INTEGER)

# When
Expand All @@ -279,7 +279,7 @@ def test_get_all_feature_states_for_identity_returns_correct_value_for_matching_
# Feature segment value is converted to string in the serializer so we set as a string value here to test
# bool value
overridden_value = 'false'
FeatureSegment.objects.create(feature=remote_config, segment=segment,
FeatureSegment.objects.create(feature=remote_config, segment=segment, environment=self.environment,
value=overridden_value, value_type=BOOLEAN)

# When
Expand Down Expand Up @@ -313,11 +313,11 @@ def test_get_all_feature_states_highest_value_of_highest_priority_segment(self):

# which is overridden by both segments with different values
overridden_value_1 = 'overridden-value-1'
FeatureSegment.objects.create(feature=remote_config, segment=segment_1,
FeatureSegment.objects.create(feature=remote_config, segment=segment_1, environment=self.environment,
value=overridden_value_1, value_type=STRING, priority=1)

overridden_value_2 = 'overridden-value-2'
FeatureSegment.objects.create(feature=remote_config, segment=segment_2,
FeatureSegment.objects.create(feature=remote_config, segment=segment_2, environment=self.environment,
value=overridden_value_2, value_type=STRING, priority=2)

# When - we get all feature states for an identity
Expand All @@ -327,3 +327,38 @@ def test_get_all_feature_states_highest_value_of_highest_priority_segment(self):
assert len(feature_states) == 1
remote_config_feature_state = next(filter(lambda fs: fs.feature == remote_config, feature_states))
assert remote_config_feature_state.get_feature_state_value() == overridden_value_1

def test_remote_config_override(self):
"""specific test for bug raised following work to make feature segments unique to an environment"""
# GIVEN - an identity with a trait that has a value of 10
identity = Identity.objects.create(identifier="test", environment=self.environment)
trait = Trait.objects.create(identity=identity, trait_key="my_trait", integer_value=10, value_type=INTEGER)

# and a segment that matches users that have a value for this trait greater than 5
segment = Segment.objects.create(name="Test segment", project=self.project)
segment_rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE)
condition = Condition.objects.create(
rule=segment_rule, operator=GREATER_THAN, value="5", property=trait.trait_key
)

# and a feature that has a segment override in the same environment as the identity
remote_config = Feature.objects.create(name="my_feature", initial_value="initial value", project=self.project)
feature_segment = FeatureSegment.objects.create(
feature=remote_config,
environment=self.environment,
segment=segment,
value="overridden value 1",
value_type=STRING
)

# WHEN - the value on the feature segment is updated and we get all the feature states for the identity
feature_segment.value = "overridden value 2"
feature_segment.save()
feature_states = identity.get_all_feature_states()

# THEN - the feature state value is correctly set to the newly updated feature segment value
assert len(feature_states) == 1

overridden_feature_state = feature_states[0]
assert overridden_feature_state.get_feature_state_value() == feature_segment.value

16 changes: 12 additions & 4 deletions src/environments/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,9 @@ def test_identities_endpoint_returns_value_for_segment_if_identity_in_segment(se
segment = Segment.objects.create(name='Test Segment', project=self.project)
segment_rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE)
Condition.objects.create(operator='EQUAL', property=trait_key, value=trait_value, rule=segment_rule)
FeatureSegment.objects.create(segment=segment, feature=self.feature_2, enabled=True, priority=1)
FeatureSegment.objects.create(
segment=segment, feature=self.feature_2, environment=self.environment, enabled=True, priority=1
)

# When
response = self.client.get(url)
Expand All @@ -534,7 +536,9 @@ def test_identities_endpoint_returns_value_for_segment_if_identity_in_segment_an
segment = Segment.objects.create(name='Test Segment', project=self.project)
segment_rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE)
Condition.objects.create(operator='EQUAL', property=trait_key, value=trait_value, rule=segment_rule)
FeatureSegment.objects.create(segment=segment, feature=self.feature_1, enabled=True, priority=1)
FeatureSegment.objects.create(
segment=segment, feature=self.feature_1, environment=self.environment, enabled=True, priority=1
)

# When
response = self.client.get(url)
Expand All @@ -557,7 +561,9 @@ def test_identities_endpoint_returns_value_for_segment_if_rule_type_percentage_s
Condition.objects.create(operator=models.PERCENTAGE_SPLIT,
value=(identity_percentage_value + (1 - identity_percentage_value) / 2) * 100.0,
rule=segment_rule)
FeatureSegment.objects.create(segment=segment, feature=self.feature_1, enabled=True, priority=1)
FeatureSegment.objects.create(
segment=segment, feature=self.feature_1, environment=self.environment, enabled=True, priority=1
)

# When
self.client.credentials(HTTP_X_ENVIRONMENT_KEY=self.environment.api_key)
Expand All @@ -580,7 +586,9 @@ def test_identities_endpoint_returns_default_value_if_rule_type_percentage_split
Condition.objects.create(operator=models.PERCENTAGE_SPLIT,
value=identity_percentage_value / 2,
rule=segment_rule)
FeatureSegment.objects.create(segment=segment, feature=self.feature_1, enabled=True, priority=1)
FeatureSegment.objects.create(
segment=segment, feature=self.feature_1, environment=self.environment, enabled=True, priority=1
)

# When
self.client.credentials(HTTP_X_ENVIRONMENT_KEY=self.environment.api_key)
Expand Down
3 changes: 2 additions & 1 deletion src/features/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ class FeaturesConfig(AppConfig):
name = 'features'

def ready(self):
pass
# noinspection PyUnresolvedReferences
import features.signals
19 changes: 19 additions & 0 deletions src/features/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from rest_framework import serializers

from features.utils import INTEGER, BOOLEAN, STRING


class FeatureSegmentValueField(serializers.Field):
def to_internal_value(self, data):
if data is not None:
# grab the type of the value and set the context for use
# in the create / update methods on the serializer
value_type = type(data).__name__
value_types = [STRING, BOOLEAN, INTEGER]
value_type = value_type if value_type in value_types else STRING
self.context['value_type'] = value_type

return str(data)

def to_representation(self, value):
return self.root.instance.get_value()
13 changes: 13 additions & 0 deletions src/features/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import typing

from features.utils import INTEGER, BOOLEAN


def get_correctly_typed_value(value_type: str, string_value: str) -> typing.Any:
if value_type == INTEGER:
return int(string_value)
elif value_type == BOOLEAN:
return string_value == 'True'

return string_value

25 changes: 25 additions & 0 deletions src/features/migrations/0017_auto_20200607_1005.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 2.2.12 on 2020-06-07 10:05
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('environments', '0012_auto_20200504_1322'),
('segments', '0007_auto_20190906_1416'),
('features', '0016_auto_20190916_1717'),
]

operations = [
# first, add the field, allowing null values
migrations.AddField(
model_name='featuresegment',
name='environment',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feature_segments', to='environments.Environment'),
),
migrations.AlterUniqueTogether(
name='featuresegment',
unique_together={('feature', 'environment', 'segment')},
),
]
Loading

0 comments on commit d8f73a2

Please sign in to comment.