Skip to content

Commit

Permalink
Merge branch 'staging' into 'master'
Browse files Browse the repository at this point in the history
Segments and Organisation Usage

See merge request solidstategroup/bullet-train-api!49
  • Loading branch information
matthewelwell committed May 22, 2019
2 parents 4011cb4 + 05b93a4 commit 7d19803
Show file tree
Hide file tree
Showing 32 changed files with 644 additions and 254 deletions.
7 changes: 7 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[scripts]
runserver = "python src/manage.py runserver"
makemigrations = "python src/manage.py makemigrations"
migrate = "python src/manage.py migrate"

[dev-packages]
pylint = "<2.0.0"
"pep8" = "*"
Expand Down Expand Up @@ -31,3 +36,5 @@ numpy = "*"
django-simple-history = "*"
twisted = {version = "*", extras = ["tls"]}
django-debug-toolbar = "*"
google-api-python-client = "*"
"oauth2client" = "*"
499 changes: 267 additions & 232 deletions Pipfile.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ services:
build:
context: .
dockerfile: docker/Dockerfile
command: bash -c "python manage.py migrate --noinput
&& python manage.py collectstatic --noinput
&& gunicorn --bind 0.0.0.0:8000 -w 3 app.wsgi"
command: bash -c "pipenv run python manage.py migrate --noinput
&& pipenv run python manage.py collectstatic --noinput
&& pipenv run gunicorn --bind 0.0.0.0:8000 -w 3 app.wsgi"
environment:
DJANGO_DB_NAME: bullettrain
DJANGO_DB_USER: postgres
Expand Down
5 changes: 3 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:2.7
FROM python:3.7

RUN rm /var/lib/dpkg/info/format
RUN printf "1\n" > /var/lib/dpkg/info/format
Expand All @@ -10,7 +10,8 @@ RUN apt-get clean && apt-get update \

RUN pip install pipenv

WORKDIR /usr/src/app
WORKDIR /usr/src
COPY src/ ./
COPY Pipfile* ./
RUN pipenv install --deploy

Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ The application relies on the following environment variables to run:
* `DATABASE_URL`: required by develop and master environments, should be a standard format database url e.g. postgres://user:password@host:port/db_name
* `DJANGO_SECRET_KEY`: see 'Creating a secret key' section below
* `GOOGLE_ANALYTICS_KEY`: if google analytics is required, add your tracking code
* `GOOGLE_SERVICE_ACCOUNT`: service account json for accessing the google API, used for getting usage of an organisation - needs access to analytics.readonly scope
* `GA_TABLE_ID`: GA table ID (view) to query when looking for organisation usage

### Creating a secret key
It is important to also set an environment variable on whatever platform you are using for
Expand Down
2 changes: 1 addition & 1 deletion src/analytics/middleware.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from threading import Thread

from .utils import track_request
from .track import track_request


class GoogleAnalyticsMiddleware:
Expand Down
37 changes: 37 additions & 0 deletions src/analytics/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import json

from apiclient.discovery import build
from django.conf import settings
from google.oauth2 import service_account

GA_SCOPES = ['https://www.googleapis.com/auth/analytics.readonly']
GA_API_NAME = 'analytics'
GA_API_VERSION = 'v3'


def get_service():
"""
Get the google service object to use to query the API
"""
credentials = service_account.Credentials.from_service_account_info(
json.loads(settings.GOOGLE_SERVICE_ACCOUNT), scopes=GA_SCOPES)

# Build the service object.
return build(GA_API_NAME, GA_API_VERSION, credentials=credentials)


def get_events_for_organisation(organisation):
"""
Get number of tracked events for last 30 days for an organisation
:return: number of events as integer
"""
ga_response = get_service().data().ga().get(
ids=settings.GA_TABLE_ID,
start_date='30daysAgo',
end_date='today',
metrics='ga:totalEvents',
dimensions='ga:date',
filters=f'ga:eventCategory=={organisation.get_unique_slug()}').execute()

return int(ga_response['totalsForAllResults']['ga:totalEvents'])
4 changes: 4 additions & 0 deletions src/analytics/utils.py → src/analytics/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@
GOOGLE_ANALYTICS_BATCH_URL = GOOGLE_ANALYTICS_BASE_URL + "/batch"
DEFAULT_DATA = "v=1&tid=" + settings.GOOGLE_ANALYTICS_KEY


def postpone(function):
def decorator(*args, **kwargs):
t = Thread(target = function, args=args, kwargs=kwargs)
t.daemon = True
t.start()
return decorator


@postpone
def post_async(url, data):
requests.post(GOOGLE_ANALYTICS_COLLECT_URL, data=data)


def track_request(uri):
"""
Utility function to track a request to the API with the specified URI
Expand All @@ -31,6 +34,7 @@ def track_request(uri):
data = DEFAULT_DATA + "t=pageview&dp=" + quote(uri, safe='')
post_async(GOOGLE_ANALYTICS_COLLECT_URL, data=data)


def track_event(category, action, label='', value=''):
data = DEFAULT_DATA + "&t=event" + \
"&ec=" + category + \
Expand Down
3 changes: 3 additions & 0 deletions src/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from features.views import SDKFeatureStates
from environments.views import SDKIdentities, SDKTraits
from segments.views import SDKSegments

urlpatterns = [
url(r'^v1/', include([
Expand All @@ -21,6 +22,8 @@
url(r'^identities/(?P<identifier>[-\w.]+)/traits/(?P<trait_key>[-\w.]+)', SDKTraits.as_view()),
url(r'^identities/(?P<identifier>[-\w.]+)/', SDKIdentities.as_view()),

url(r'^segments/', SDKSegments.as_view()),

# API documentation
url(r'^docs/', include('docs.urls', namespace='docs'))
], namespace='v1'))
Expand Down
7 changes: 7 additions & 0 deletions src/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@

# Google Analytics Configuration
GOOGLE_ANALYTICS_KEY = os.environ.get('GOOGLE_ANALYTICS_KEY', '')
GOOGLE_SERVICE_ACCOUNT = os.environ.get('GOOGLE_SERVICE_ACCOUNT')
if not GOOGLE_SERVICE_ACCOUNT:
warnings.warn("GOOGLE_SERVICE_ACCOUNT not configured, getting organisation usage will not work")
GA_TABLE_ID = os.environ.get('GA_TABLE_ID')
if not GA_TABLE_ID:
warnings.warn("GA_TABLE_ID not configured, getting organisation usage will not work")

if 'DJANGO_ALLOWED_HOSTS' in os.environ:
ALLOWED_HOSTS = os.environ['DJANGO_ALLOWED_HOSTS'].split(',')
Expand Down Expand Up @@ -81,6 +87,7 @@
'projects',
'environments',
'features',
'segments',
'rest_framework_swagger',
'docs',
'e2etests',
Expand Down
2 changes: 2 additions & 0 deletions src/environments/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class EnvironmentHeaderNotPresentError(Exception):
pass
11 changes: 11 additions & 0 deletions src/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from simple_history.models import HistoricalRecords

from app.utils import create_hash
from environments.exceptions import EnvironmentHeaderNotPresentError
from features.models import FeatureState
from projects.models import Project

Expand Down Expand Up @@ -69,6 +70,16 @@ def save(self, *args, **kwargs):
def __str__(self):
return "Project %s - Environment %s" % (self.project.name, self.name)

@staticmethod
def get_environment_from_request(request):
try:
environment_key = request.META['HTTP_X_ENVIRONMENT_KEY']
except KeyError:
raise EnvironmentHeaderNotPresentError

return Environment.objects.select_related('project', 'project__organisation').get(
api_key=environment_key)


@python_2_unicode_compatible
class Identity(models.Model):
Expand Down
31 changes: 25 additions & 6 deletions src/environments/views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from collections import namedtuple

import coreapi
from rest_framework import viewsets, status
from rest_framework.generics import GenericAPIView
from rest_framework.generics import GenericAPIView, get_object_or_404
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.schemas import AutoSchema

from util.util import get_user_permitted_identities, get_user_permitted_environments, get_user_permitted_projects
from .models import Environment, Identity, Trait
from .serializers import EnvironmentSerializerLight, IdentitySerializer, TraitSerializerBasic, TraitSerializerFull,\
from .serializers import EnvironmentSerializerLight, IdentitySerializer, TraitSerializerBasic, TraitSerializerFull, \
IdentitySerializerTraitFlags


Expand Down Expand Up @@ -44,6 +46,16 @@ def get_queryset(self):

return queryset

def create(self, request, *args, **kwargs):
project_pk = request.data.get('project')

if not project_pk:
return Response(data = {"detail": "No project provided"}, status=status.HTTP_400_BAD_REQUEST)

get_object_or_404(get_user_permitted_projects(self.request.user), pk=project_pk)

return super().create(request, *args, **kwargs)


class IdentityViewSet(viewsets.ModelViewSet):
"""
Expand All @@ -70,8 +82,10 @@ class IdentityViewSet(viewsets.ModelViewSet):
lookup_field = 'identifier'

def get_queryset(self):
env_key = self.kwargs['environment_api_key']
return Identity.objects.filter(environment__api_key=env_key)
environment = self.get_environment_from_request()
user_permitted_identities = get_user_permitted_identities(self.request.user)

return user_permitted_identities.filter(environment__api_key=environment.api_key)

def get_environment_from_request(self):
"""
Expand All @@ -82,6 +96,8 @@ def get_environment_from_request(self):

def create(self, request, *args, **kwargs):
environment = self.get_environment_from_request()
if environment.project.organisation not in request.user.organisations.all():
return Response(status=status.HTTP_403_FORBIDDEN)
data = request.data
data['environment'] = environment.id
serializer = self.get_serializer(data=data)
Expand Down Expand Up @@ -120,7 +136,7 @@ def get_queryset(self):
"""
environment_api_key = self.kwargs['environment_api_key']
identifier = self.kwargs.get('identity_identifier')
environment = Environment.objects.get(api_key=environment_api_key)
environment = get_user_permitted_environments(self.request.user).get(api_key=environment_api_key)

if identifier:
identity = Identity.objects.get(identifier=identifier, environment=environment)
Expand All @@ -147,6 +163,9 @@ def create(self, request, *args, **kwargs):
"""
data = request.data
environment = self.get_environment_from_request()
if environment.project.organisation not in self.request.user.organisations.all():
return Response(status=status.HTTP_403_FORBIDDEN)

identifier = self.kwargs.get('identity_identifier', None)

# check if identity in data or in request
Expand Down Expand Up @@ -319,7 +338,7 @@ def post(self, request, identifier, trait_key, *args, **kwargs):
status=status.HTTP_400_BAD_REQUEST
)

if trait and'trait_value' in trait_data:
if trait and 'trait_value' in trait_data:
# Check if trait value was provided with request data. If so, we need to figure out value_type from
# the given value and also use correct value field e.g. boolean_value, integer_value or
# string_value, and override request data
Expand Down
26 changes: 26 additions & 0 deletions src/features/migrations/0012_auto_20190424_1555.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2019-04-24 15:55
from __future__ import unicode_literals

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('features', '0011_historicalfeature_squashed_0012_historicalfeaturestate_historicalfeaturestatevalue'),
]

operations = [
migrations.AlterField(
model_name='historicalfeature',
name='project',
field=models.ForeignKey(blank=True, db_constraint=False, help_text='Changing the project selected will remove previous Feature States for the previously associated projects Environments that are related to this Feature. New default Feature States will be created for the new selected projects Environments for this Feature.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='projects.Project'),
),
migrations.AlterField(
model_name='historicalfeaturestate',
name='identity',
field=models.ForeignKey(blank=True, db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='environments.Identity'),
),
]
25 changes: 20 additions & 5 deletions src/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
from rest_framework.response import Response
from rest_framework.schemas import AutoSchema

from analytics.utils import track_event
from analytics.track import track_event
from environments.models import Environment, Identity
from projects.models import Project
from util.util import get_user_permitted_projects, get_user_permitted_environments
from .models import FeatureState, Feature
from .serializers import FeatureStateSerializerBasic, FeatureStateSerializerFull, \
FeatureStateSerializerCreate, CreateFeatureSerializer, FeatureSerializer, \
Expand All @@ -30,9 +31,20 @@ def get_serializer_class(self):
return FeatureSerializer

def get_queryset(self):
project = Project.objects.get(pk=self.kwargs['project_pk'])
user_projects = get_user_permitted_projects(self.request.user)
project = get_object_or_404(user_projects, pk=self.kwargs['project_pk'])

return project.features.all()

def create(self, request, *args, **kwargs):
project_id = request.data.get('project')
project = Project.objects.get(pk=project_id)

if project.organisation not in request.user.organisations.all():
return Response(status=status.HTTP_403_FORBIDDEN)

return super().create(request, *args, **kwargs)


class FeatureStateViewSet(viewsets.ModelViewSet):
"""
Expand Down Expand Up @@ -72,7 +84,7 @@ def get_queryset(self):
"""
environment_api_key = self.kwargs['environment_api_key']
identifier = self.kwargs.get('identity_identifier')
environment = Environment.objects.get(api_key=environment_api_key)
environment = get_object_or_404(get_user_permitted_environments(self.request.user), api_key=environment_api_key)

if identifier:
identity = Identity.objects.get(
Expand Down Expand Up @@ -104,6 +116,9 @@ def create(self, request, *args, **kwargs):
"""
data = request.data
environment = self.get_environment_from_request()
if environment.project.organisation not in self.request.user.organisations.all():
return Response(status.HTTP_403_FORBIDDEN)

data['environment'] = environment.id

if 'feature' not in data:
Expand Down Expand Up @@ -224,14 +239,14 @@ def get(self, request, identifier=None, *args, **kwargs):
return Response(error_response, status=status.HTTP_400_BAD_REQUEST)

if identifier:
track_event(environment.project.organisation.name, "identity_flags")
track_event(environment.project.organisation.get_unique_slug(), "identity_flags")

identity, _ = Identity.objects.get_or_create(
identifier=identifier,
environment=environment,
)
else:
track_event(environment.project.organisation.name, "flags")
track_event(environment.project.organisation.get_unique_slug(), "flags")
identity = None

kwargs = {
Expand Down
4 changes: 4 additions & 0 deletions src/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ class Organisation(models.Model):
free_to_use_subscription = models.BooleanField(default=True)
plan = models.CharField(max_length=20, null=True, blank=True)
subscription_date = models.DateTimeField('SubscriptionDate', blank=True, null=True)

class Meta:
ordering = ['id']

def __str__(self):
return "Org %s" % self.name

def get_unique_slug(self):
return str(self.id) + "-" + self.name
Loading

0 comments on commit 7d19803

Please sign in to comment.